jedmund-svelte/src/lib/components/admin/DropdownMenu.svelte
Justin Edmund 4337b57dee refactor: migrate createEventDispatcher to Svelte 5 callback props
Migrate 5 components from Svelte 4 createEventDispatcher to Svelte 5 callback props:
- DropdownMenu.svelte (removed unused dispatcher)
- ProjectListItem.svelte (edit, togglePublish, delete events)
- PostListItem.svelte (edit, togglePublish, delete events)
- AlbumListItem.svelte (toggleDropdown, edit, togglePublish, delete events)
- InlineComposerModal.svelte (close, saved events + migrate export let to $props)

Updated parent components to use onevent={handler} syntax instead of on:event={handler}.
2025-11-04 19:35:53 -08:00

287 lines
6.2 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte'
import { browser } from '$app/environment'
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom'
import ChevronRight from '$icons/chevron-right.svg?component'
interface Props {
isOpen: boolean
triggerElement?: HTMLElement
items: DropdownItem[]
onClose?: () => void
isSubmenu?: boolean
}
interface DropdownItem {
id: string
label?: string
action?: () => void
variant?: 'default' | 'danger'
divider?: boolean
children?: DropdownItem[]
icon?: string
}
let { isOpen = $bindable(), triggerElement, items, onClose, isSubmenu = false }: Props = $props()
let dropdownElement: HTMLDivElement
let cleanup: (() => void) | null = null
// Track which submenu is open
let openSubmenuId = $state<string | null>(null)
let submenuElements = $state<Map<string, HTMLElement>>(new Map())
let submenuCloseTimeout: number | null = null
// Position state
let x = $state(0)
let y = $state(0)
// Action to set submenu references
function submenuRef(
node: HTMLElement,
params: { item: DropdownItem; submenuElements: Map<string, HTMLElement> }
) {
if (params.item.children) {
params.submenuElements.set(params.item.id, node)
}
return {
destroy() {
if (params.item.children) {
params.submenuElements.delete(params.item.id)
}
}
}
}
// Update position using Floating UI
async function updatePosition() {
if (!triggerElement || !dropdownElement) return
const { x: newX, y: newY } = await computePosition(triggerElement, dropdownElement, {
placement: isSubmenu ? 'right-start' : 'bottom-end',
middleware: [offset(isSubmenu ? 0 : 4), flip(), shift({ padding: 8 })]
})
x = newX
y = newY
}
function handleItemClick(item: DropdownItem, event: MouseEvent) {
event.stopPropagation()
if (item.action && !item.children) {
item.action()
isOpen = false
openSubmenuId = null // Reset submenu state
onClose?.()
}
}
function handleOutsideClick(event: MouseEvent) {
if (!dropdownElement || !isOpen) return
const target = event.target as HTMLElement
// Check if click is inside any submenu
const clickedInSubmenu = Array.from(submenuElements.values()).some((el) => el.contains(target))
if (
!dropdownElement.contains(target) &&
!triggerElement?.contains(target) &&
!clickedInSubmenu
) {
isOpen = false
openSubmenuId = null // Reset submenu state
onClose?.()
}
}
function handleItemMouseEnter(item: DropdownItem) {
if (submenuCloseTimeout) {
clearTimeout(submenuCloseTimeout)
submenuCloseTimeout = null
}
if (item.children) {
openSubmenuId = item.id
} else {
openSubmenuId = null
}
}
function handleItemMouseLeave(item: DropdownItem) {
if (item.children) {
// Add delay before closing submenu
submenuCloseTimeout = window.setTimeout(() => {
if (openSubmenuId === item.id) {
openSubmenuId = null
}
}, 300)
}
}
function handleSubmenuMouseEnter() {
if (submenuCloseTimeout) {
clearTimeout(submenuCloseTimeout)
submenuCloseTimeout = null
}
}
function handleSubmenuMouseLeave(itemId: string) {
submenuCloseTimeout = window.setTimeout(() => {
if (openSubmenuId === itemId) {
openSubmenuId = null
}
}, 300)
}
// Set up auto-update for position when dropdown is open
$effect(() => {
if (browser && isOpen && triggerElement && dropdownElement) {
// Initial position update
updatePosition()
// Set up auto-update
cleanup = autoUpdate(triggerElement, dropdownElement, updatePosition)
// Add outside click listener
document.addEventListener('click', handleOutsideClick)
return () => {
cleanup?.()
cleanup = null
document.removeEventListener('click', handleOutsideClick)
}
}
})
// Reset submenu state when dropdown closes
$effect(() => {
if (!isOpen) {
openSubmenuId = null
}
})
</script>
{#if isOpen && browser}
<div
bind:this={dropdownElement}
class="dropdown-menu"
class:submenu={isSubmenu}
style="position: fixed; left: {x}px; top: {y}px"
>
{#each items as item}
{#if item.divider}
<div class="dropdown-divider"></div>
{:else}
<button
use:submenuRef={{ item, submenuElements }}
class="dropdown-item"
class:danger={item.variant === 'danger'}
class:has-children={item.children}
onclick={(e) => handleItemClick(item, e)}
onmouseenter={() => handleItemMouseEnter(item)}
onmouseleave={() => handleItemMouseLeave(item)}
>
<span class="item-label">{item.label}</span>
{#if item.children}
<span class="submenu-icon">
<ChevronRight />
</span>
{/if}
</button>
{#if item.children && openSubmenuId === item.id}
<div
onmouseenter={handleSubmenuMouseEnter}
onmouseleave={() => handleSubmenuMouseLeave(item.id)}
>
<svelte:self
isOpen={true}
triggerElement={submenuElements.get(item.id)}
items={item.children}
{onClose}
isSubmenu={true}
/>
</div>
{/if}
{/if}
{/each}
</div>
{/if}
<style lang="scss">
@import '$styles/variables.scss';
.dropdown-menu {
background: white;
border: 1px solid $gray-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
min-width: 180px;
z-index: $z-index-modal;
max-height: 400px;
overflow-y: auto;
}
.dropdown-item {
width: 100%;
padding: $unit-2x $unit-3x;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: $gray-20;
cursor: pointer;
transition: background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
background-color: $gray-95;
}
&.danger {
color: $red-60;
}
&.has-children {
padding-right: $unit-2x;
}
}
.item-label {
flex: 1;
}
.submenu-icon {
width: 16px;
height: 16px;
margin-left: $unit;
color: $gray-40;
flex-shrink: 0;
display: inline-flex;
align-items: center;
:global(svg) {
width: 100%;
height: 100%;
fill: none;
}
:global(path) {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.dropdown-divider {
height: 1px;
background-color: $gray-80;
margin: $unit-half 0;
}
</style>