<script lang="ts">
import type { Component, Snippet } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { getContext, setContext } from "svelte";
import ChevronRight from "$icon/chevron-right";
import Button, { buttonIcon } from "#lib/components/ui/Button.svelte";
import { dropdownItem } from "#lib/components/ui/DropdownItem.svelte";
import { menuItems } from "#lib/components/ui/menuItems.ts";
import { popoverAnchorName, popoverOrigin } from "#lib/components/ui/popover.svelte.ts";
import { popoverExit } from "#lib/popoverExit.ts";
interface Props {
label: string;
icon?: Component<SvelteHTMLElements["svg"]>;
children: Snippet;
}
type Side = "left" | "right";
type Point = { x: number; y: number };
let { label, icon, children }: Props = $props();
const onParentClose = getContext<((listener: () => void) => () => void) | undefined>(
"dropdown-onclose"
);
const parentAlign = getContext<(() => Side | undefined) | undefined>("dropdown-align");
const registerInParent = getContext<((el: HTMLElement) => () => void) | undefined>(
"dropdown-register"
);
const items = menuItems();
setContext("dropdown-register", items.register);
const uid = $props.id();
const triggerId = `${uid}-trigger`;
const menuId = `${uid}-menu`;
const anchorName = popoverAnchorName("dropdown-submenu", uid);
const side = $derived<Side>(parentAlign?.() === "right" ? "left" : "right");
let triggerEl = $state<HTMLAnchorElement | HTMLButtonElement>();
let menuEl = $state<HTMLElement>();
let open = $state(false);
const OPEN_DELAY = 100;
const CLOSE_DELAY = 200;
const REOPEN_WINDOW = 300;
const GRACE_TIMEOUT = 300;
const FAR_DISTANCE = 40;
let openTimer: ReturnType<typeof setTimeout> | undefined;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
let grace: { side: Side; area: Point[] } | null = null;
let closedAt = -Infinity;
let pointerDir: Side = "right";
let pointer: { point: Point; target: EventTarget | null } | null = null;
const parentMenu = () => triggerEl?.parentElement?.closest<HTMLElement>('[role="menu"]');
const setGrace = (next: typeof grace) => {
clearTimeout(graceTimer);
grace = next;
parentMenu()?.toggleAttribute("data-submenu-aiming", next !== null);
};
const cancelOpen = () => {
clearTimeout(openTimer);
openTimer = undefined;
};
const cancelClose = () => {
clearTimeout(closeTimer);
closeTimer = undefined;
};
const show = (focusFirst: boolean) => {
cancelOpen();
cancelClose();
if (menuEl && !menuEl.matches(":popover-open")) menuEl.showPopover();
if (focusFirst) items.focus(0);
};
const hide = () => {
cancelOpen();
cancelClose();
if (grace) setGrace(null);
if (!menuEl?.matches(":popover-open")) return;
const hadFocus = menuEl.contains(document.activeElement);
menuEl.hidePopover();
if (hadFocus) triggerEl?.focus({ preventScroll: true });
};
const inPolygon = ({ x, y }: Point, area: Point[]) => {
let inside = false;
for (let i = 0, j = area.length - 1; i < area.length; j = i++) {
const a = area[i]!;
const b = area[j]!;
if (a.y > y !== b.y > y && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) {
inside = !inside;
}
}
return inside;
};
const isOverSubmenu = (target: EventTarget | null) =>
target instanceof Node && Boolean(triggerEl?.contains(target) || menuEl?.contains(target));
const isOverParent = (target: EventTarget | null) =>
target instanceof Node && Boolean(parentMenu()?.contains(target)) && !isOverSubmenu(target);
const distanceTo = ({ x, y }: Point, rect: DOMRect) =>
Math.hypot(
Math.max(rect.left - x, 0, x - rect.right),
Math.max(rect.top - y, 0, y - rect.bottom)
);
const isFar = (point: Point) =>
[parentMenu(), menuEl].every(
(el) => !el || distanceTo(point, el.getBoundingClientRect()) > FAR_DISTANCE
);
const settle = (point: Point, target: EventTarget | null) => {
if (isOverParent(target)) {
closeTimer ??= setTimeout(hide, CLOSE_DELAY);
} else if (isFar(point)) {
hide();
}
};
const onTriggerPointerMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
if (grace) setGrace(null);
cancelClose();
if (open || openTimer) return;
const delay = performance.now() - closedAt < REOPEN_WINDOW ? 0 : OPEN_DELAY;
openTimer = setTimeout(() => show(false), delay);
};
const onTriggerPointerLeave = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
cancelOpen();
if (!open || !menuEl || !triggerEl) return;
const content = menuEl.getBoundingClientRect();
const trigger = triggerEl.getBoundingClientRect();
const actual: Side | null =
content.left >= trigger.right - 1
? "right"
: content.right <= trigger.left + 1
? "left"
: null;
if (!actual) return;
const near = actual === "right" ? content.left : content.right;
const far = actual === "right" ? content.right : content.left;
const bleed = actual === "right" ? -5 : 5;
setGrace({
side: actual,
area: [
{ x: event.clientX + bleed, y: event.clientY },
{ x: near, y: content.top },
{ x: far, y: content.top },
{ x: far, y: content.bottom },
{ x: near, y: content.bottom }
]
});
graceTimer = setTimeout(() => {
setGrace(null);
if (pointer && !isOverSubmenu(pointer.target)) settle(pointer.point, pointer.target);
}, GRACE_TIMEOUT);
};
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
const point = { x: event.clientX, y: event.clientY };
if (pointer && point.x !== pointer.point.x) {
pointerDir = point.x > pointer.point.x ? "right" : "left";
}
pointer = { point, target: event.target };
if (isOverSubmenu(event.target)) {
if (grace) setGrace(null);
cancelClose();
return;
}
if (grace && grace.side === pointerDir && inPolygon(point, grace.area)) return;
if (grace) setGrace(null);
settle(point, event.target);
};
const onTriggerClick = (event: MouseEvent) => {
if (open && (event as PointerEvent).pointerType !== "mouse") hide();
else show(false);
};
const onTriggerKeydown = (event: KeyboardEvent) => {
if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
show(true);
} else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
hide();
}
};
const onMenuKeydown = (event: KeyboardEvent) => {
if (event.key === "Tab") return;
event.stopPropagation();
if (items.navigate(event)) return;
if (event.key === "ArrowLeft" || event.key === "Escape") {
event.preventDefault();
hide();
}
};
$effect(() => {
if (!triggerEl || !registerInParent) return;
return registerInParent(triggerEl);
});
$effect(() => onParentClose?.(hide));
$effect(() => {
if (!open) return;
document.addEventListener("pointermove", onPointerMove);
return () => document.removeEventListener("pointermove", onPointerMove);
});
$effect(() => () => {
cancelOpen();
cancelClose();
setGrace(null);
});
</script>
<Button
bind:ref={triggerEl}
id={triggerId}
variant="ghost"
class={dropdownItem({ class: "[&>span]:w-full" })}
{icon}
role="menuitem"
tabindex={-1}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
style="anchor-name: {anchorName}"
onclick={onTriggerClick}
onkeydown={onTriggerKeydown}
onpointermove={onTriggerPointerMove}
onpointerleave={onTriggerPointerLeave}
>
<span class="grow">{label}</span>
<ChevronRight class={buttonIcon()} aria-hidden="true" />
</Button>
<div
bind:this={menuEl}
id={menuId}
role="menu"
tabindex={-1}
aria-labelledby={triggerId}
popover="manual"
data-side={side}
class="dropdown-submenu fixed inset-auto z-50 m-none h-fit max-h-[calc(100dvh-1rem)] w-56 max-w-[calc(100vw-1rem)] popover-transition overflow-auto overscroll-contain rounded-md bg-background-default p-1 text-foreground-default shadow-lg edge-ring [&>hr]:-mx-1 [&>hr]:my-1 [&>hr]:w-auto"
style="position-anchor: {anchorName}"
ontoggle={(event) => {
open = (event as ToggleEvent).newState === "open";
if (!open) closedAt = performance.now();
}}
onkeydown={onMenuKeydown}
{@attach popoverExit(undefined, true)}
{@attach popoverOrigin(() => triggerEl)}
>
{@render children()}
</div>
<style>
:global([data-submenu-aiming] > [role="menuitem"]:not([aria-haspopup]):hover) {
background-color: transparent;
}
@supports (top: anchor(bottom)) and (position-try: flip-block) {
.dropdown-submenu {
top: anchor(top);
margin-block: -0.25rem 0;
position-try:
flip-inline,
flip-block,
flip-block flip-inline,
--dropdown-submenu-side-clamped,
--dropdown-submenu-side-clamped flip-inline,
--dropdown-submenu-below-end,
--dropdown-submenu-below-start,
--dropdown-submenu-below-end-clamped,
--dropdown-submenu-below-start-clamped,
--dropdown-submenu-above-end,
--dropdown-submenu-above-start,
--dropdown-submenu-above-end-clamped,
--dropdown-submenu-above-start-clamped;
}
.dropdown-submenu[data-side="left"] {
right: anchor(left);
margin-inline: 0 0.375rem;
}
.dropdown-submenu[data-side="right"] {
left: anchor(right);
margin-inline: 0.375rem 0;
}
}
@position-try --dropdown-submenu-side-clamped {
top: 0.5rem;
bottom: 0.5rem;
align-self: end;
margin-block: 0;
}
@position-try --dropdown-submenu-below-end {
inset: anchor(bottom) anchor(right) auto auto;
margin: 0.25rem 0 0;
}
@position-try --dropdown-submenu-below-start {
inset: anchor(bottom) auto auto anchor(left);
margin: 0.25rem 0 0;
}
@position-try --dropdown-submenu-below-end-clamped {
inset: anchor(bottom) anchor(right) 0.5rem auto;
height: auto;
min-height: 10rem;
margin: 0.25rem 0 0;
}
@position-try --dropdown-submenu-below-start-clamped {
inset: anchor(bottom) auto 0.5rem anchor(left);
height: auto;
min-height: 10rem;
margin: 0.25rem 0 0;
}
@position-try --dropdown-submenu-above-end {
inset: auto anchor(right) anchor(top) auto;
margin: 0 0 0.25rem;
}
@position-try --dropdown-submenu-above-start {
inset: auto auto anchor(top) anchor(left);
margin: 0 0 0.25rem;
}
@position-try --dropdown-submenu-above-end-clamped {
inset: 0.5rem anchor(right) anchor(top) auto;
height: auto;
margin: 0 0 0.25rem;
}
@position-try --dropdown-submenu-above-start-clamped {
inset: 0.5rem auto anchor(top) anchor(left);
height: auto;
margin: 0 0 0.25rem;
}
</style>