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:
Justin Edmund 2025-06-25 22:12:16 -04:00
parent fa52bb716d
commit ea7ec61377
4 changed files with 141 additions and 131 deletions

View file

@ -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

View 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>

View file

@ -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>

View file

@ -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,11 +106,11 @@
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 {
background-color: $gray-95;
}
}
</style>
</style>