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 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>