Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363<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>