This repository has no description
0

Configure Feed

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

core / web / src / lib / components / ui / Combobox.svelte
12 kB 382 lines
1<script module lang="ts"> 2 import { tv, type VariantProps } from "tailwind-variants"; 3 4 // the box, its border and its state colours come from selectField, shared with the native 5 // rendering so flipping `rich` on a Select cannot change how the field looks 6 export const combobox = tv({ 7 slots: { 8 root: "relative inline-block w-full", 9 trigger: "flex min-h-9 cursor-pointer items-center gap-2 px-2.5 py-1", 10 value: "min-w-0 flex-1 truncate typography-paragraph-regular text-left", 11 panel: 12 "combobox-panel fixed z-50 m-0 hidden max-h-[min(24rem,calc(100dvh-1rem))] w-72 max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-sm border border-border-default bg-background-default p-0 text-foreground-default shadow-regular open:flex dark:shadow-none", 13 search: "shrink-0 border-b border-border-default p-2", 14 list: "min-h-0 flex-1 overflow-y-auto p-1", 15 groupHeading: 16 "px-2 pt-2 pb-1 font-semibold text-foreground-muted first:pt-1", 17 option: 18 "flex w-full cursor-pointer items-center gap-2 rounded-sm bg-transparent px-2 py-1.5 text-left aria-disabled:cursor-not-allowed aria-disabled:text-foreground-disabled data-active:bg-background-subtle dark:data-active:bg-background-subtle", 19 check: "size-4 shrink-0 text-foreground-default", 20 optionLabel: "min-w-0 flex-1 truncate", 21 hint: "shrink-0 text-foreground-subtle", 22 empty: "px-2 py-6 text-center text-foreground-muted", 23 footer: "shrink-0 border-t border-border-default" 24 }, 25 variants: { 26 placeholder: { 27 true: { value: "text-foreground-placeholder" } 28 } 29 } 30 }); 31 32 export type ComboboxVariants = VariantProps<typeof combobox>; 33</script> 34 35<script lang="ts"> 36 import Check from "$icon/check"; 37 import ChevronsUpDown from "$icon/chevrons-up-down"; 38 import Search from "$icon/search"; 39 import type { Snippet } from "svelte"; 40 import { tick } from "svelte"; 41 import Input from "./Input.svelte"; 42 import { selectField, type SelectAlign, type SelectOption } from "./selectField"; 43 44 interface Props { 45 options: SelectOption[]; 46 /** two-way; the selected option's value. Pass without `bind:` when the truth lives 47 * elsewhere — a ref in the url, say — and let `onSelect` do the navigating. */ 48 value?: string; 49 onSelect?: (value: string) => void; 50 /** trigger text while nothing is selected */ 51 placeholder?: string; 52 searchPlaceholder?: string; 53 emptyLabel?: string; 54 /** names the trigger and the listbox for screen readers */ 55 label?: string; 56 /** take filtering over, e.g. to query a server: `options` is then rendered as given */ 57 onSearch?: (query: string) => void; 58 loading?: boolean; 59 error?: boolean; 60 disabled?: boolean; 61 align?: SelectAlign; 62 /** goes on the trigger, so a Field's label can point at it */ 63 id?: string; 64 class?: string; 65 panelClass?: string; 66 /** pinned below the list, e.g. a "view all branches" link */ 67 footer?: Snippet; 68 } 69 70 let { 71 options, 72 value = $bindable(""), 73 onSelect, 74 placeholder = "Select an option", 75 searchPlaceholder = "Filter…", 76 emptyLabel = "No matches", 77 label, 78 onSearch, 79 loading = false, 80 error = false, 81 disabled = false, 82 align = "left", 83 id, 84 class: className, 85 panelClass, 86 footer 87 }: Props = $props(); 88 89 const panelId = $props.id(); 90 const listId = `${panelId}-list`; 91 const optionId = (index: number) => `${panelId}-option-${index}`; 92 // anchor-name has to be a custom ident and unique per instance, so it cannot live in the 93 // stylesheet. the invoker is the implicit anchor in principle, but naming it explicitly is 94 // what actually holds: with only the implicit one the panel lands in the viewport's corner. 95 const anchorName = `--combobox-${panelId.replace(/[^a-zA-Z0-9_-]/g, "-")}`; 96 97 let open = $state(false); 98 let search = $state(""); 99 let activeIndex = $state(-1); 100 // only keyboard navigation scrolls the list: doing it on hover yanks the row out from 101 // under the pointer 102 let scrollActiveIntoView = false; 103 104 let triggerEl = $state<HTMLButtonElement>(); 105 let panelEl = $state<HTMLElement>(); 106 let searchEl = $state<HTMLInputElement>(); 107 let listEl = $state<HTMLElement>(); 108 109 const query = $derived(search.trim().toLowerCase()); 110 const visible = $derived( 111 onSearch || query === "" 112 ? options 113 : options.filter( 114 (option) => 115 (option.label ?? option.value).toLowerCase().includes(query) || 116 option.value.toLowerCase().includes(query) 117 ) 118 ); 119 120 // a heading per run of same-group options, so grouping falls out of the array's order 121 // instead of needing a nested shape 122 const rows = $derived( 123 visible.map((option, index) => ({ 124 option, 125 index, 126 heading: option.group !== visible[index - 1]?.group ? option.group : undefined 127 })) 128 ); 129 130 const selectable = $derived( 131 visible.reduce<number[]>((indexes, option, index) => { 132 if (!option.disabled) indexes.push(index); 133 return indexes; 134 }, []) 135 ); 136 137 const selected = $derived(options.find((option) => option.value === value)); 138 // a value with no matching option still deserves a label: the ref selector can be sitting 139 // on a commit sha, or on a branch past the point the list was capped 140 const triggerLabel = $derived(selected ? (selected.label ?? selected.value) : value); 141 142 const classes = $derived(combobox({ placeholder: triggerLabel === "" || undefined })); 143 const field = $derived(selectField({ error, disabled })); 144 145 const setActive = (index: number, fromKeyboard: boolean) => { 146 activeIndex = index; 147 scrollActiveIntoView = fromKeyboard; 148 }; 149 150 const move = (delta: number) => { 151 if (selectable.length === 0) return; 152 const current = selectable.indexOf(activeIndex); 153 // from nowhere, ArrowDown lands on the first option and ArrowUp on the last 154 const next = 155 current === -1 156 ? delta > 0 157 ? 0 158 : selectable.length - 1 159 : (((current + delta) % selectable.length) + selectable.length) % selectable.length; 160 setActive(selectable[next], true); 161 }; 162 163 const close = () => { 164 if (open) panelEl?.hidePopover(); 165 }; 166 167 const select = (option: SelectOption) => { 168 if (option.disabled) return; 169 value = option.value; 170 onSelect?.(option.value); 171 close(); 172 triggerEl?.focus(); 173 }; 174 175 const onToggle = (event: Event) => { 176 const { newState } = event as ToggleEvent; 177 open = newState === "open"; 178 if (!open) return; 179 180 // every open starts from a clean slate, with the current selection under the cursor 181 search = ""; 182 onSearch?.(""); 183 const selectedIndex = options.findIndex((option) => option.value === value); 184 setActive(selectable.includes(selectedIndex) ? selectedIndex : (selectable[0] ?? -1), true); 185 void tick().then(() => searchEl?.focus()); 186 }; 187 188 const onTriggerKeydown = (event: KeyboardEvent) => { 189 if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { 190 event.preventDefault(); 191 triggerEl?.click(); 192 } 193 }; 194 195 const onSearchInput = (event: Event) => { 196 search = (event.currentTarget as HTMLInputElement).value; 197 onSearch?.(search.trim()); 198 // the old active row is gone or has moved: start over at the top of what is left 199 setActive(selectable[0] ?? -1, true); 200 }; 201 202 const onSearchKeydown = (event: KeyboardEvent) => { 203 switch (event.key) { 204 case "ArrowDown": 205 event.preventDefault(); 206 move(1); 207 break; 208 case "ArrowUp": 209 event.preventDefault(); 210 move(-1); 211 break; 212 case "Home": 213 event.preventDefault(); 214 if (selectable.length > 0) setActive(selectable[0], true); 215 break; 216 case "End": 217 event.preventDefault(); 218 if (selectable.length > 0) setActive(selectable[selectable.length - 1], true); 219 break; 220 case "Enter": { 221 // a bare Enter in a form would submit it, whether or not a row is active 222 event.preventDefault(); 223 const option = visible[activeIndex]; 224 if (option) select(option); 225 break; 226 } 227 case "Escape": 228 event.preventDefault(); 229 close(); 230 triggerEl?.focus(); 231 break; 232 case "Tab": 233 close(); 234 break; 235 } 236 }; 237 238 $effect(() => { 239 if (!open || !scrollActiveIntoView || activeIndex < 0) return; 240 listEl?.querySelector(`#${CSS.escape(optionId(activeIndex))}`)?.scrollIntoView({ 241 block: "nearest" 242 }); 243 }); 244</script> 245 246<div class={classes.root({ class: className })}> 247 <button 248 bind:this={triggerEl} 249 {id} 250 type="button" 251 {disabled} 252 class={field.box({ class: classes.trigger() })} 253 popovertarget={panelId} 254 popovertargetaction="toggle" 255 onkeydown={onTriggerKeydown} 256 aria-haspopup="listbox" 257 aria-expanded={open} 258 aria-controls={panelId} 259 aria-label={label} 260 style="anchor-name: {anchorName}" 261 > 262 <span class={classes.value()}>{triggerLabel === "" ? placeholder : triggerLabel}</span> 263 <ChevronsUpDown class={field.chevron({ class: "size-3.5" })} aria-hidden="true" /> 264 </button> 265 266 <div 267 bind:this={panelEl} 268 id={panelId} 269 data-align={align} 270 popover="auto" 271 ontoggle={onToggle} 272 style="position-anchor: {anchorName}" 273 class={classes.panel({ class: panelClass })} 274 > 275 <div class={classes.search()}> 276 <Input 277 bind:element={searchEl} 278 type="text" 279 value={search} 280 oninput={onSearchInput} 281 onkeydown={onSearchKeydown} 282 placeholder={searchPlaceholder} 283 iconLeft={Search} 284 {loading} 285 autocomplete="off" 286 spellcheck="false" 287 role="combobox" 288 aria-expanded="true" 289 aria-controls={listId} 290 aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined} 291 aria-label={label ? `${label} filter` : "Filter options"} 292 /> 293 </div> 294 295 <div bind:this={listEl} id={listId} role="listbox" aria-label={label} class={classes.list()}> 296 {#each rows as { option, index, heading } (option.value)} 297 {#if heading} 298 <div class={classes.groupHeading()}>{heading}</div> 299 {/if} 300 <button 301 id={optionId(index)} 302 type="button" 303 role="option" 304 tabindex={-1} 305 class={classes.option()} 306 data-active={index === activeIndex || undefined} 307 aria-selected={option.value === value} 308 aria-disabled={option.disabled} 309 onclick={() => select(option)} 310 onpointermove={() => { 311 if (!option.disabled && index !== activeIndex) setActive(index, false); 312 }} 313 > 314 {#if option.value === value} 315 <Check class={classes.check()} aria-hidden="true" /> 316 {:else} 317 <span class="size-4 shrink-0" aria-hidden="true"></span> 318 {/if} 319 <span class={classes.optionLabel()}>{option.label ?? option.value}</span> 320 {#if option.hint} 321 <span class={classes.hint()}>{option.hint}</span> 322 {/if} 323 </button> 324 {:else} 325 <p class={classes.empty()}>{emptyLabel}</p> 326 {/each} 327 </div> 328 329 {#if footer} 330 <div class={classes.footer()}>{@render footer()}</div> 331 {/if} 332 </div> 333</div> 334 335<style> 336 /* anchor positioning went Baseline in early 2026; without it the popover falls back to the 337 UA's centred position, the same trade-off Dropdown already takes. the trigger is the 338 implicit anchor because it invoked the popover through popovertarget. */ 339 @supports (position-area: bottom) and (position-try: flip-block) { 340 .combobox-panel { 341 /* the gap to the trigger — margin, because position-area owns inset */ 342 margin: 0.25rem; 343 position-try: flip-block; 344 } 345 346 .combobox-panel[data-align="left"] { 347 position-area: block-end span-inline-end; 348 } 349 350 .combobox-panel[data-align="right"] { 351 position-area: block-end span-inline-start; 352 } 353 } 354 355 .combobox-panel { 356 opacity: 0; 357 scale: 0.98; 358 transition: 359 opacity 120ms ease-out, 360 scale 120ms ease-out, 361 overlay 120ms allow-discrete, 362 display 120ms allow-discrete; 363 } 364 365 .combobox-panel:popover-open { 366 opacity: 1; 367 scale: 1; 368 } 369 370 @starting-style { 371 .combobox-panel:popover-open { 372 opacity: 0; 373 scale: 0.98; 374 } 375 } 376 377 @media (prefers-reduced-motion: reduce) { 378 .combobox-panel { 379 transition: none; 380 } 381 } 382</style>