Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

web/ui/components: fix dropdown

Signed-off-by: eti <eti@eti.tf>

author
eti
committer
dawn
date (Jul 23, 2026, 6:48 PM +0300) commit 6d9a994a parent 655fa31d change-id pvvkltmt
+24 -20
+24 -20
web/src/lib/components/ui/Dropdown.svelte
··· 29 29 // opening one dropdown in a group closes its siblings. 30 30 const groups = new SvelteMap<string, SvelteSet<() => void>>(); 31 31 32 - let idCounter = 0; 33 - function nextId() { 34 - idCounter += 1; 35 - return `dropdown-menu-${idCounter}`; 36 - } 37 - 38 32 function registerDropdown(group: string | undefined, close: () => void) { 39 33 if (!group) return () => {}; 40 34 let set = groups.get(group); ··· 45 39 set.add(close); 46 40 return () => { 47 41 set.delete(close); 48 - if (set && set.size === 0) groups.delete(group); 42 + if (set.size === 0) groups.delete(group); 49 43 }; 50 44 } 51 45 ··· 78 72 let root = $state<HTMLElement>(); 79 73 let triggerWrapper = $state<HTMLElement>(); 80 74 let triggerButtonEl = $state<HTMLElement>(); 81 - const fallbackId = nextId(); 75 + const fallbackId = $props.id(); 82 76 const menuId = $derived(id ?? fallbackId); 83 77 84 78 const focusTrigger = () => { ··· 88 82 triggerButtonEl?.focus(); 89 83 } 90 84 }; 91 - let items: HTMLElement[] = $state([]); 85 + // intentionally not $state: only used for imperative focus, and reactive 86 + // reads inside DropdownItem's register $effect would loop it forever 87 + let items: HTMLElement[] = []; 92 88 93 89 const close = () => { 94 90 open = false; ··· 105 101 items[wrapped]?.focus(); 106 102 }; 107 103 108 - const openAndFocusFirst = () => { 109 - open = true; 110 - closeGroupExcept(group, close); 111 - }; 112 - 113 104 const onTriggerKeydown = (event: KeyboardEvent) => { 114 105 if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { 115 106 event.preventDefault(); 116 - openAndFocusFirst(); 107 + open = true; 108 + closeGroupExcept(group, close); 117 109 } 118 110 }; 119 111 ··· 141 133 close(); 142 134 focusTrigger(); 143 135 break; 136 + case "Tab": 137 + close(); 138 + break; 144 139 } 145 140 }; 146 141 ··· 152 147 153 148 setContext("dropdown-close", close); 154 149 setContext("dropdown-register", (el: HTMLElement) => { 155 - items.push(el); 150 + // insert in DOM order, not mount order, since conditionally rendered 151 + // items can register after already-registered items that appear later 152 + // in the DOM 153 + const index = items.findIndex( 154 + (item) => item.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING 155 + ); 156 + if (index === -1) { 157 + items.push(el); 158 + } else { 159 + items.splice(index, 0, el); 160 + } 156 161 return () => { 157 162 items = items.filter((item) => item !== el); 158 163 }; ··· 161 166 onMount(() => { 162 167 const unregister = registerDropdown(group, close); 163 168 164 - const onDocumentClick = (event: MouseEvent) => { 169 + const onDocumentClick = (event: PointerEvent) => { 165 170 if (open && event.target instanceof Node && root && !root.contains(event.target)) close(); 166 171 }; 167 172 const onKeydown = (event: KeyboardEvent) => { ··· 172 177 } 173 178 }; 174 179 175 - document.addEventListener("click", onDocumentClick); 180 + document.addEventListener("pointerdown", onDocumentClick); 176 181 document.addEventListener("keydown", onKeydown); 177 182 178 183 return () => { 179 184 unregister(); 180 - document.removeEventListener("click", onDocumentClick); 185 + document.removeEventListener("pointerdown", onDocumentClick); 181 186 document.removeEventListener("keydown", onKeydown); 182 187 }; 183 188 }); ··· 187 192 {#if variant === "button"} 188 193 <span bind:this={triggerWrapper} class="contents"> 189 194 <Button 190 - size="sm" 191 195 class="px-3" 192 196 onclick={toggle} 193 197 onkeydown={onTriggerKeydown}