Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248<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: "dropdown-menu fixed z-50 m-0 max-h-[calc(100dvh-1rem)] w-56 max-w-[calc(100vw-1rem)] divide-y divide-border-default overflow-auto rounded border border-border-default bg-background-default text-sm text-foreground-default shadow-menu" }, variants: { align: { left: {}, right: {} } }, defaultVariants: { align: "left" } });
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, { type ButtonVariants } from "./Button.svelte";
interface Props { id?: string; group?: string; variant?: "plain" | "button"; buttonVariant?: ButtonVariants["variant"]; align?: DropdownVariants["align"]; label?: string; menuClass?: string; trigger: Snippet; children: Snippet; }
let { id, group, variant = "plain", buttonVariant = "default", align = "left", label, menuClass, trigger, children }: Props = $props();
let open = $state(false); const classes = $derived(dropdown({ align })); let triggerWrapper = $state<HTMLElement>(); let triggerButtonEl = $state<HTMLElement>(); let menuElement = $state<HTMLElement>(); const fallbackId = $props.id(); const menuId = $derived(id ?? fallbackId);
const triggerElement = () => variant === "button" ? triggerWrapper?.querySelector<HTMLElement>("button, a") : triggerButtonEl;
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()}> {#if variant === "button"} <span bind:this={triggerWrapper} class="contents"> <Button variant={buttonVariant} class="px-3" popovertarget={menuId} popovertargetaction="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()} popovertarget={menuId} popovertargetaction="toggle" onkeydown={onTriggerKeydown} aria-haspopup="menu" aria-expanded={open} aria-controls={menuId} aria-label={label} > {@render trigger()} </button> {/if}
<div bind:this={menuElement} id={menuId} data-align={align} popover="auto" ontoggle={onToggle} class={classes.menu({ class: menuClass })} role="menu" tabindex={-1} onkeydown={onMenuKeydown} > {@render children()} </div></div>
<style> @supports (position-area: bottom) and (position-try: flip-block) { .dropdown-menu { margin: 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>