This repository has no description
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>