Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236<script module lang="ts"> import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { tv, type VariantProps } from "tailwind-variants";
export const dropdown = tv({ slots: { root: "relative inline-block", plainTrigger: "flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-inherit", menu: "absolute z-50 mt-2 w-56 divide-y divide-border-default overflow-hidden rounded border border-border-default bg-background-default text-sm shadow-menu" }, variants: { align: { left: { menu: "left-0" }, right: { menu: "right-0" } }, open: { true: { menu: "block" }, false: { menu: "hidden" } } }, defaultVariants: { align: "left", open: false } });
export type DropdownVariants = VariantProps<typeof dropdown>;
// 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 type { Snippet } from "svelte"; import { onMount, setContext, tick } from "svelte"; import Button from "./Button.svelte";
interface Props { id?: string; group?: string; variant?: "plain" | "button"; align?: DropdownVariants["align"]; label?: string; trigger: Snippet; children: Snippet; }
let { id, group, variant = "plain", align = "left", label, trigger, children }: Props = $props();
let open = $state(false); const classes = $derived(dropdown({ open, align })); let root = $state<HTMLElement>(); let triggerWrapper = $state<HTMLElement>(); let triggerButtonEl = $state<HTMLElement>(); const fallbackId = $props.id(); const menuId = $derived(id ?? fallbackId);
const focusTrigger = () => { if (variant === "button") { triggerWrapper?.querySelector<HTMLElement>("button, a")?.focus(); } else { triggerButtonEl?.focus(); } }; // intentionally not $state: only used for imperative focus, and reactive // reads inside DropdownItem's register $effect would loop it forever let items: HTMLElement[] = [];
const close = () => { open = false; };
const toggle = () => { open = !open; if (open) closeGroupExcept(group, close); };
const focusItem = (index: number) => { if (items.length === 0) return; const wrapped = ((index % items.length) + items.length) % items.length; items[wrapped]?.focus(); };
const onTriggerKeydown = (event: KeyboardEvent) => { if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); open = true; closeGroupExcept(group, close); } };
const onMenuKeydown = (event: KeyboardEvent) => { const currentIndex = items.indexOf(event.target as HTMLElement); switch (event.key) { case "ArrowDown": event.preventDefault(); focusItem(currentIndex + 1); break; case "ArrowUp": event.preventDefault(); focusItem(currentIndex - 1); break; case "Home": event.preventDefault(); focusItem(0); break; case "End": event.preventDefault(); focusItem(items.length - 1); break; case "Escape": event.preventDefault(); close(); focusTrigger(); break; case "Tab": close(); break; } };
$effect(() => { if (open) { tick().then(() => items[0]?.focus()); } });
setContext("dropdown-close", close); setContext("dropdown-register", (el: HTMLElement) => { // insert in DOM order, not mount order, since conditionally rendered // items can register after already-registered items that appear later // in the DOM const index = items.findIndex( (item) => item.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING ); if (index === -1) { items.push(el); } else { items.splice(index, 0, el); } return () => { items = items.filter((item) => item !== el); }; });
onMount(() => { const unregister = registerDropdown(group, close);
const onDocumentClick = (event: PointerEvent) => { if (open && event.target instanceof Node && root && !root.contains(event.target)) close(); }; const onKeydown = (event: KeyboardEvent) => { if (!open) return; if (event.key === "Escape") { close(); focusTrigger(); } };
document.addEventListener("pointerdown", onDocumentClick); document.addEventListener("keydown", onKeydown);
return () => { unregister(); document.removeEventListener("pointerdown", onDocumentClick); document.removeEventListener("keydown", onKeydown); }; });</script>
<div bind:this={root} class={classes.root()}> {#if variant === "button"} <span bind:this={triggerWrapper} class="contents"> <Button class="px-3" onclick={toggle} onkeydown={onTriggerKeydown} aria-haspopup="menu" aria-expanded={open} aria-controls={menuId} aria-label={label} > {@render trigger()} </Button> </span> {:else} <button bind:this={triggerButtonEl} type="button" class={classes.plainTrigger()} onclick={toggle} onkeydown={onTriggerKeydown} aria-haspopup="menu" aria-expanded={open} aria-controls={menuId} aria-label={label} > {@render trigger()} </button> {/if}
<div id={menuId} class={classes.menu()} role="menu" tabindex={-1} onkeydown={onMenuKeydown}> {@render children()} </div></div>