Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244<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 "./Button.svelte";
export const dropdown = tv({ slots: { root: "relative inline-flex", plainTrigger: "min-h-0 gap-1 border-0 bg-transparent p-0 text-inherit hover:bg-transparent", menu: "dropdown-menu fixed inset-auto z-50 m-0 h-fit max-h-[calc(100dvh-1rem)] w-56 max-w-[calc(100vw-1rem)] popover-transition touch-pan-y divide-y divide-border-default overflow-auto overscroll-contain rounded-sm border border-border-default bg-background-default text-foreground-default shadow-regular [-webkit-overflow-scrolling:touch]" }, variants: { align: { left: {}, right: {} } }, defaultVariants: { align: "left" } });
export type DropdownVariants = VariantProps<typeof dropdown>;
export interface DropdownProps { id?: string; group?: string; variant?: "plain" | "button"; buttonVariant?: ButtonVariants["variant"]; size?: ButtonVariants["size"]; insetShadow?: ButtonVariants["insetShadow"]; align?: DropdownVariants["align"]; label?: string; disabled?: boolean; menuClass?: string; class?: string; trigger: Snippet; children: Snippet; xOffset?: number; }
// 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 { popoverExit } from "$lib/popoverExit"; import Button from "./Button.svelte";
let { id, group, variant = "plain", buttonVariant = "default", insetShadow = false, size, align = "left", label, disabled = false, menuClass, class: className, trigger, children, xOffset }: DropdownProps = $props();
let open = $state(false); const classes = $derived(dropdown({ align })); 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(); }; // 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 = () => { if (open) menuElement?.hidePopover(); };
const onToggle = (event: Event) => { const { newState } = event as ToggleEvent; open = newState === "open"; if (!open) return;
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(); triggerElement?.click(); } };
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(); menuElement?.hidePopover(); focusTrigger(); break; case "Tab": menuElement?.hidePopover(); 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(() => { return registerDropdown(group, close); });</script>
<div class={classes.root({ class: className })}> <Button bind:ref={triggerElement} variant={variant === "button" ? buttonVariant : "ghost"} {size} class={variant === "button" ? undefined : classes.plainTrigger()} popovertarget={menuId} popovertargetaction="toggle" style="anchor-name: {anchorName}" onkeydown={onTriggerKeydown} aria-haspopup="menu" aria-expanded={open} aria-controls={menuId} aria-label={label} {insetShadow} {disabled} > {@render trigger()} </Button>
<div bind:this={menuElement} id={menuId} data-align={align} popover="auto" ontoggle={onToggle} class={classes.menu({ class: menuClass })} role="menu" tabindex={-1} onkeydown={onMenuKeydown} style="position-anchor: {anchorName}; transform: translateX({xOffset}px)" {@attach popoverExit()} > {@render children()} </div></div>
<style> @supports (position-area: bottom) and (position-try: flip-block) { .dropdown-menu { position: absolute; margin-top: 0.5rem; position-try: flip-block; }
.dropdown-menu[data-align="left"] { position-area: block-end span-inline-end; }
.dropdown-menu[data-align="right"] { position-area: block-end span-inline-start; } }</style>