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}.
287 lines
6.2 KiB
Svelte
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>
|