This repository has no description
1<script lang="ts">
2 // SelectOption and SelectAlign live in ./selectField, which owns the shape both renderings
3 // read; re-exporting them from here trips eslint's no-import-assign on module scripts
4 import ChevronDown from "$icon/chevron-down";
5 import type { Snippet } from "svelte";
6 import Combobox from "./Combobox.svelte";
7 import { selectField, type SelectAlign, type SelectOption } from "./selectField";
8
9 interface Props {
10 options: SelectOption[];
11 /** two-way; the selected option's value. Pass without `bind:` when the truth lives
12 * elsewhere — a ref in the url, say — and let `onSelect` do the navigating. */
13 value?: string;
14 /** Swap the native control for a searchable listbox. Same options, same box: reach for it
15 * once a list is long enough that scanning it beats reading it. */
16 rich?: boolean;
17 onSelect?: (value: string) => void;
18 /** empty-value row shown first, e.g. "Choose stat". Selectable, so it also clears. */
19 placeholder?: string;
20 /** names the control for screen readers */
21 label?: string;
22 error?: boolean;
23 disabled?: boolean;
24 /** goes on the control itself, so a Field's label can point at it */
25 id?: string;
26 class?: string;
27 // rich only
28 searchPlaceholder?: string;
29 emptyLabel?: string;
30 /** take filtering over, e.g. to query a server: `options` is then rendered as given */
31 onSearch?: (query: string) => void;
32 loading?: boolean;
33 align?: SelectAlign;
34 panelClass?: string;
35 /** pinned below the list, e.g. a "view all branches" link */
36 footer?: Snippet;
37 }
38
39 let {
40 options,
41 value = $bindable(""),
42 rich = false,
43 onSelect,
44 placeholder,
45 label,
46 error = false,
47 disabled = false,
48 id,
49 class: className,
50 searchPlaceholder,
51 emptyLabel,
52 onSearch,
53 loading,
54 align,
55 panelClass,
56 footer
57 }: Props = $props();
58
59 // a native optgroup has to nest, so the flat array is folded into runs of the same group.
60 // the rich rendering keeps the array flat and emits headings between rows instead.
61 const groups = $derived(
62 options.reduce<{ label?: string; options: SelectOption[] }[]>((groups, option) => {
63 const last = groups.at(-1);
64 if (last && last.label === option.group) last.options.push(option);
65 else groups.push({ label: option.group, options: [option] });
66 return groups;
67 }, [])
68 );
69
70 const field = $derived(selectField({ error, disabled }));
71
72 const onchange = (event: Event) => {
73 onSelect?.((event.currentTarget as HTMLSelectElement).value);
74 };
75</script>
76
77{#snippet nativeOptions(items: SelectOption[])}
78 {#each items as option (option.value)}
79 <option value={option.value} disabled={option.disabled}>{option.label ?? option.value}</option>
80 {/each}
81{/snippet}
82
83{#if rich}
84 <Combobox
85 {options}
86 bind:value
87 {onSelect}
88 {placeholder}
89 {label}
90 {error}
91 {disabled}
92 {id}
93 {searchPlaceholder}
94 {emptyLabel}
95 {onSearch}
96 {loading}
97 {align}
98 {panelClass}
99 {footer}
100 class={className}
101 />
102{:else}
103 <div class="relative {className ?? ''}">
104 <select
105 bind:value
106 {id}
107 {disabled}
108 {onchange}
109 aria-label={label}
110 aria-invalid={error}
111 class={field.box({ class: "min-h-9 appearance-none px-2 py-1 pr-8" })}
112 >
113 {#if placeholder}
114 <option value="">{placeholder}</option>
115 {/if}
116 {#each groups as group (group.label ?? "")}
117 {#if group.label}
118 <optgroup label={group.label}>{@render nativeOptions(group.options)}</optgroup>
119 {:else}
120 {@render nativeOptions(group.options)}
121 {/if}
122 {/each}
123 </select>
124 <ChevronDown
125 class={field.chevron({
126 class: "pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2"
127 })}
128 aria-hidden="true"
129 />
130 </div>
131{/if}