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