Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376<script module lang="ts"> import type { Snippet } from "svelte"; import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { tv, type VariantProps } from "tailwind-variants"; import type { ButtonVariants } from "#lib/components/ui/Button.svelte"; import type { PopoverOrigin } from "#lib/components/ui/popover.svelte.ts";
export const dropdown = tv({ slots: { root: "relative inline-flex", plainTrigger: "min-h-0 gap-2xs border-0 bg-transparent p-none text-inherit hover:bg-transparent aria-expanded:bg-transparent", menu: "dropdown-menu fixed inset-auto z-50 m-none h-fit max-h-[calc(100dvh-1rem)] w-56 max-w-[calc(100vw-1rem)] touch-pan-y overflow-auto overscroll-contain rounded-md border-0 bg-background-default p-1 text-foreground-default shadow-lg edge-ring [-webkit-overflow-scrolling:touch] [&>hr]:-mx-1 [&>hr]:my-1 [&>hr]:w-auto" }, variants: { align: { left: {}, right: {} }, width: { fixed: {}, fit: { menu: "hidden w-fit flex-col open:flex [&>a]:w-auto [&>button]:w-auto" } } }, defaultVariants: { align: "left", width: "fixed" } });
export type DropdownVariants = VariantProps<typeof dropdown>;
export interface DropdownProps { id?: string; group?: string; variant?: "plain" | "button"; buttonVariant?: ButtonVariants["variant"]; size?: ButtonVariants["size"]; square?: boolean; align?: DropdownVariants["align"]; width?: DropdownVariants["width"]; label?: string; disabled?: boolean; menuClass?: string; triggerClass?: string; matchTriggerWidth?: boolean; class?: string; testid?: string; trigger: Snippet; children: Snippet; xOffset?: number; title?: string; origin?: PopoverOrigin; onOpenChange?: (open: boolean) => void; }
// opening one dropdown in a group closes its siblings. const groups = new SvelteMap<string, SvelteSet<() => void>>();
function registerDropdown(group: string | undefined, close: () => void) { if (!group) return () => {}; let set = groups.get(group); if (!set) { set = new SvelteSet(); groups.set(group, set); } set.add(close); return () => { set.delete(close); if (set.size === 0) groups.delete(group); }; }
function closeGroupExcept(group: string | undefined, keep: () => void) { if (!group) return; groups.get(group)?.forEach((close) => { if (close !== keep) close(); }); }</script>
<script lang="ts"> import { onMount, setContext, tick } from "svelte"; import { popoverOrigin } from "#lib/components/ui/popover.svelte.ts"; import { menuItems } from "#lib/components/ui/menuItems.ts"; import Button from "#lib/components/ui/Button.svelte";
let { id, group, variant = "plain", buttonVariant = "default", square, size, align = "left", width = "fixed", label, disabled = false, menuClass, triggerClass, matchTriggerWidth = false, class: className, testid, trigger, children, xOffset, title, origin, onOpenChange }: DropdownProps = $props();
let open = $state(false); let closing = $state(false); let fixedTrigger = $state(false); const classes = $derived(dropdown({ align, width })); let triggerElement = $state<HTMLAnchorElement | HTMLButtonElement>(); let menuElement = $state<HTMLElement>(); const fallbackId = $props.id(); const menuId = $derived(id ?? fallbackId); const anchorName = $derived(`--dropdown-${menuId.replace(/[^a-zA-Z0-9_-]/g, "-")}`);
const focusTrigger = () => { triggerElement?.focus(); }; const items = menuItems();
const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
const closeListeners = new SvelteSet<() => void>();
const close = () => { const menu = menuElement; if (!menu || !open || closing) return; closeListeners.forEach((listener) => listener()); if (reducedMotion()) { menu.hidePopover(); return; } closing = true; const done = (event?: TransitionEvent) => { if (event && (event.target !== menu || event.propertyName !== "opacity")) return; menu.removeEventListener("transitionend", done); clearTimeout(fallback); if (!closing) return; closing = false; const hadFocus = menu.contains(document.activeElement); menu.hidePopover(); if (hadFocus) focusTrigger(); }; const fallback = setTimeout(done, 300); menu.addEventListener("transitionend", done); };
const show = () => { closing = false; if (!open) menuElement?.showPopover(); };
export function showMenu() { show(); }
export function hideMenu() { close(); }
const toggleMenu = () => { if (open && !closing) close(); else show(); };
const onToggle = (event: Event) => { const { newState } = event as ToggleEvent; open = newState === "open"; onOpenChange?.(open); if (!open) return;
closeGroupExcept(group, close); };
$effect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { const target = event.target as Node; if (menuElement?.contains(target) || triggerElement?.contains(target)) return; close(); }; const onKeydown = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("keydown", onKeydown); return () => { document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("keydown", onKeydown); }; });
const inFixedAncestor = (el: Element | undefined) => { for (let node = el?.parentElement; node; node = node.parentElement) { if (getComputedStyle(node).position === "fixed") return true; } return false; };
const onTriggerKeydown = (event: KeyboardEvent) => { if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); show(); } };
const onMenuKeydown = (event: KeyboardEvent) => { if (items.navigate(event)) return; if (event.key === "Tab") close(); };
$effect(() => { if (open) { tick().then(() => items.focus(0)); } });
setContext("dropdown-close", close); setContext("dropdown-register", items.register); setContext("dropdown-onclose", (listener: () => void) => { closeListeners.add(listener); return () => closeListeners.delete(listener); }); setContext("dropdown-align", () => align);
onMount(() => { return registerDropdown(group, close); });</script>
<div class={classes.root({ class: className })} {title} data-testid={testid}> <Button bind:ref={triggerElement} variant={variant === "button" ? buttonVariant : "ghost"} {size} class={variant === "button" ? triggerClass : classes.plainTrigger({ class: triggerClass })} onclick={toggleMenu} style="anchor-name: {anchorName}" onkeydown={onTriggerKeydown} aria-haspopup="menu" aria-expanded={open} aria-controls={menuId} aria-label={label} {square} {disabled} > {@render trigger()} </Button>
<div bind:this={menuElement} id={menuId} data-align={align} data-match-trigger={matchTriggerWidth ? "" : undefined} data-fixed={fixedTrigger ? "" : undefined} data-closing={closing ? "" : undefined} popover="manual" onbeforetoggle={(event) => { if (event.newState === "open") fixedTrigger = inFixedAncestor(triggerElement); }} ontoggle={onToggle} class={classes.menu({ class: menuClass })} role="menu" tabindex={-1} onkeydown={onMenuKeydown} style="position-anchor: {anchorName}; transform: translateX({xOffset}px)" {@attach popoverOrigin( () => triggerElement, () => origin )} > {@render children()} </div></div>
<style> .dropdown-menu { --menu-shift: -8px; opacity: 0; scale: 0.94; translate: 0 var(--menu-shift); transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1), scale 260ms cubic-bezier(0.23, 1, 0.32, 1), translate 260ms cubic-bezier(0.23, 1, 0.32, 1); }
.dropdown-menu:global([data-popover-origin^="bottom"]) { --menu-shift: 8px; }
.dropdown-menu:global([data-popover-origin="top-left"]) { transform-origin: top left; }
.dropdown-menu:global([data-popover-origin="top-center"]) { transform-origin: top center; }
.dropdown-menu:global([data-popover-origin="top-right"]) { transform-origin: top right; }
.dropdown-menu:global([data-popover-origin="bottom-left"]) { transform-origin: bottom left; }
.dropdown-menu:global([data-popover-origin="bottom-center"]) { transform-origin: bottom center; }
.dropdown-menu:global([data-popover-origin="bottom-right"]) { transform-origin: bottom right; }
.dropdown-menu:popover-open { opacity: 1; scale: 1; translate: 0; }
@starting-style { .dropdown-menu:popover-open { opacity: 0; scale: 0.94; translate: 0 var(--menu-shift); } }
.dropdown-menu:popover-open[data-closing] { opacity: 0; scale: 0.97; translate: 0 calc(var(--menu-shift) * 0.75); transition-duration: 120ms; transition-timing-function: cubic-bezier(0.4, 0, 1, 1); }
@media (prefers-reduced-motion: reduce) { .dropdown-menu { transition: none; } }
@supports (position-area: bottom) and (position-try: flip-block) { .dropdown-menu { position: absolute; margin-top: 0.5rem; position-try: flip-block; }
.dropdown-menu[data-fixed] { position: fixed; }
.dropdown-menu[data-align="left"] { position-area: block-end span-inline-end; }
.dropdown-menu[data-align="right"] { position-area: block-end span-inline-start; }
.dropdown-menu[data-match-trigger] { width: anchor-size(width); } }</style>