refactor: extract BaseDropdown component to reduce duplication
- Create BaseDropdown with shared dropdown logic: - Toggle state management - Click outside handling - Dropdown positioning - Trigger and dropdown slots - Refactor StatusDropdown and PublishDropdown to use BaseDropdown - Reduce code duplication by ~80 lines - Maintain all existing functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
fa52bb716d
commit
ea7ec61377
4 changed files with 141 additions and 131 deletions
|
|
@ -96,7 +96,7 @@ Refactor components to reduce duplication and complexity.
|
|||
|
||||
- [-] **Create base components**
|
||||
- [x] Extract `BaseModal` component for shared modal logic
|
||||
- [ ] Create `BaseDropdown` for dropdown patterns
|
||||
- [x] Create `BaseDropdown` for dropdown patterns
|
||||
- [ ] Merge `FormField` and `FormFieldWrapper`
|
||||
- [ ] Create `BaseSegmentedController` for shared logic
|
||||
|
||||
|
|
|
|||
96
src/lib/components/admin/BaseDropdown.svelte
Normal file
96
src/lib/components/admin/BaseDropdown.svelte
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
dropdownTriggerSize?: 'small' | 'medium' | 'large'
|
||||
class?: string
|
||||
onToggle?: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(false),
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
dropdownTriggerSize = 'large',
|
||||
class: className = '',
|
||||
onToggle
|
||||
}: Props = $props()
|
||||
|
||||
function handleDropdownToggle(e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
isOpen = !isOpen
|
||||
onToggle?.(isOpen)
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest(`.${className}`) && !target.closest('.dropdown-container')) {
|
||||
isOpen = false
|
||||
onToggle?.(false)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
// Use setTimeout to avoid immediate closing when clicking the trigger
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}, 0)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="dropdown-container {className}">
|
||||
<div class="dropdown-trigger">
|
||||
<slot name="trigger" />
|
||||
|
||||
{#if $$slots.dropdown}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
buttonSize={dropdownTriggerSize}
|
||||
onclick={handleDropdownToggle}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
class="dropdown-toggle"
|
||||
>
|
||||
<svg slot="icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isOpen && $$slots.dropdown}
|
||||
<DropdownMenuContainer>
|
||||
<slot name="dropdown" />
|
||||
</DropdownMenuContainer>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
:global(.dropdown-toggle) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
|
||||
import BaseDropdown from './BaseDropdown.svelte'
|
||||
import DropdownItem from './DropdownItem.svelte'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -29,36 +29,22 @@
|
|||
|
||||
function handlePublishClick() {
|
||||
onPublish()
|
||||
isDropdownOpen = false
|
||||
}
|
||||
|
||||
function handleSaveDraftClick() {
|
||||
onSaveDraft()
|
||||
isDropdownOpen = false
|
||||
}
|
||||
|
||||
function handleDropdownToggle(e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
isDropdownOpen = !isDropdownOpen
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.publish-dropdown')) {
|
||||
isDropdownOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDropdownOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="publish-dropdown">
|
||||
<BaseDropdown
|
||||
bind:isOpen={isDropdownOpen}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
class="publish-dropdown"
|
||||
>
|
||||
<Button
|
||||
slot="trigger"
|
||||
variant="primary"
|
||||
buttonSize="large"
|
||||
onclick={handlePublishClick}
|
||||
|
|
@ -68,40 +54,10 @@
|
|||
</Button>
|
||||
|
||||
{#if showDropdown}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
buttonSize="large"
|
||||
onclick={handleDropdownToggle}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
<svg slot="icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{#if isDropdownOpen}
|
||||
<DropdownMenuContainer>
|
||||
<DropdownItem onclick={handleSaveDraftClick}>
|
||||
{saveDraftText}
|
||||
</DropdownItem>
|
||||
</DropdownMenuContainer>
|
||||
{/if}
|
||||
<div slot="dropdown">
|
||||
<DropdownItem onclick={handleSaveDraftClick}>
|
||||
{saveDraftText}
|
||||
</DropdownItem>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.publish-dropdown {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
}
|
||||
</style>
|
||||
</BaseDropdown>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import Button from './Button.svelte'
|
||||
import DropdownMenuContainer from './DropdownMenuContainer.svelte'
|
||||
import BaseDropdown from './BaseDropdown.svelte'
|
||||
import DropdownItem from './DropdownItem.svelte'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -34,7 +34,6 @@
|
|||
|
||||
function handlePrimaryAction() {
|
||||
onStatusChange(primaryAction.status)
|
||||
isDropdownOpen = false
|
||||
}
|
||||
|
||||
function handleDropdownAction(status: string) {
|
||||
|
|
@ -42,25 +41,6 @@
|
|||
isDropdownOpen = false
|
||||
}
|
||||
|
||||
function handleDropdownToggle(e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
isDropdownOpen = !isDropdownOpen
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.status-dropdown')) {
|
||||
isDropdownOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDropdownOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
|
||||
const availableActions = $derived(
|
||||
dropdownActions.filter((action) => action.show !== false && action.status !== currentStatus)
|
||||
)
|
||||
|
|
@ -69,8 +49,14 @@
|
|||
const hasDropdownContent = $derived(availableActions.length > 0 || showViewInDropdown)
|
||||
</script>
|
||||
|
||||
<div class="status-dropdown">
|
||||
<BaseDropdown
|
||||
bind:isOpen={isDropdownOpen}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
class="status-dropdown"
|
||||
>
|
||||
<Button
|
||||
slot="trigger"
|
||||
variant="primary"
|
||||
buttonSize="large"
|
||||
onclick={handlePrimaryAction}
|
||||
|
|
@ -80,58 +66,30 @@
|
|||
</Button>
|
||||
|
||||
{#if hasDropdownContent}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
buttonSize="large"
|
||||
onclick={handleDropdownToggle}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
<svg slot="icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{#if isDropdownOpen}
|
||||
<DropdownMenuContainer>
|
||||
{#each availableActions as action}
|
||||
<DropdownItem onclick={() => handleDropdownAction(action.status)}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
{/each}
|
||||
{#if showViewInDropdown}
|
||||
{#if availableActions.length > 0}
|
||||
<div class="dropdown-divider"></div>
|
||||
{/if}
|
||||
<a
|
||||
href={viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="dropdown-item view-link"
|
||||
>
|
||||
View on site
|
||||
</a>
|
||||
<div slot="dropdown">
|
||||
{#each availableActions as action}
|
||||
<DropdownItem onclick={() => handleDropdownAction(action.status)}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
{/each}
|
||||
{#if showViewInDropdown}
|
||||
{#if availableActions.length > 0}
|
||||
<div class="dropdown-divider"></div>
|
||||
{/if}
|
||||
</DropdownMenuContainer>
|
||||
{/if}
|
||||
<a
|
||||
href={viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="dropdown-item view-link"
|
||||
>
|
||||
View on site
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</BaseDropdown>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.status-dropdown {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background-color: $gray-80;
|
||||
|
|
@ -148,7 +106,7 @@
|
|||
font-size: 0.875rem;
|
||||
color: $gray-20;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
transition: background-color $transition-normal ease;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
|
|
|
|||
Loading…
Reference in a new issue