diff --git a/web/src/lib/components/ui/Dropdown.svelte b/web/src/lib/components/ui/Dropdown.svelte index 4164cc29..e57de27e 100644 --- a/web/src/lib/components/ui/Dropdown.svelte +++ b/web/src/lib/components/ui/Dropdown.svelte @@ -29,12 +29,6 @@ // opening one dropdown in a group closes its siblings. const groups = new SvelteMap void>>(); - let idCounter = 0; - function nextId() { - idCounter += 1; - return `dropdown-menu-${idCounter}`; - } - function registerDropdown(group: string | undefined, close: () => void) { if (!group) return () => {}; let set = groups.get(group); @@ -45,7 +39,7 @@ set.add(close); return () => { set.delete(close); - if (set && set.size === 0) groups.delete(group); + if (set.size === 0) groups.delete(group); }; } @@ -78,7 +72,7 @@ let root = $state(); let triggerWrapper = $state(); let triggerButtonEl = $state(); - const fallbackId = nextId(); + const fallbackId = $props.id(); const menuId = $derived(id ?? fallbackId); const focusTrigger = () => { @@ -88,7 +82,9 @@ triggerButtonEl?.focus(); } }; - let items: HTMLElement[] = $state([]); + // 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; @@ -105,15 +101,11 @@ items[wrapped]?.focus(); }; - const openAndFocusFirst = () => { - open = true; - closeGroupExcept(group, close); - }; - const onTriggerKeydown = (event: KeyboardEvent) => { if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); - openAndFocusFirst(); + open = true; + closeGroupExcept(group, close); } }; @@ -141,6 +133,9 @@ close(); focusTrigger(); break; + case "Tab": + close(); + break; } }; @@ -152,7 +147,17 @@ setContext("dropdown-close", close); setContext("dropdown-register", (el: HTMLElement) => { - items.push(el); + // 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); }; @@ -161,7 +166,7 @@ onMount(() => { const unregister = registerDropdown(group, close); - const onDocumentClick = (event: MouseEvent) => { + const onDocumentClick = (event: PointerEvent) => { if (open && event.target instanceof Node && root && !root.contains(event.target)) close(); }; const onKeydown = (event: KeyboardEvent) => { @@ -172,12 +177,12 @@ } }; - document.addEventListener("click", onDocumentClick); + document.addEventListener("pointerdown", onDocumentClick); document.addEventListener("keydown", onKeydown); return () => { unregister(); - document.removeEventListener("click", onDocumentClick); + document.removeEventListener("pointerdown", onDocumentClick); document.removeEventListener("keydown", onKeydown); }; }); @@ -187,7 +192,6 @@ {#if variant === "button"}