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 { Component, Snippet } from "svelte";
6 import type { SvelteHTMLElements } from "svelte/elements";
7 import Combobox from "./Combobox.svelte";
8 import { selectField, type SelectAlign, type SelectOption } from "./selectField";
9
10 interface Props {
11 options: SelectOption[];
12 /** two-way; the selected option's value. Pass without `bind:` when the truth lives
13 * elsewhere — a ref in the url, say — and let `onSelect` do the navigating. */
14 value?: string;
15 /** Swap the native control for a searchable listbox. Same options, same box: reach for it
16 * once a list is long enough that scanning it beats reading it. */
17 rich?: boolean;
18 onSelect?: (value: string) => void;
19 /** empty-value row shown first, e.g. "Choose stat". Selectable, so it also clears. */
20 placeholder?: string;
21 /** names the control for screen readers */
22 label?: string;
23 /** glyph pinned to the leading edge, mirroring the chevron. Decorative — it stands for
24 * the current selection, so keep it in step with `value` rather than the list. */
25 icon?: Component<SvelteHTMLElements["svg"]>;
26 error?: boolean;
27 disabled?: boolean;
28 /** goes on the control itself, so a Field's label can point at it */
29 id?: string;
30 class?: string;
31 // rich only
32 searchPlaceholder?: string;
33 emptyLabel?: string;
34 /** take filtering over, e.g. to query a server: `options` is then rendered as given */
35 onSearch?: (query: string) => void;
36 loading?: boolean;
37 align?: SelectAlign;
38 panelClass?: string;
39 /** pinned below the list, e.g. a "view all branches" link */
40 footer?: Snippet;
41 }
42
43 let {
44 options,
45 value = $bindable(""),
46 rich = false,
47 onSelect,
48 placeholder,
49 label,
50 icon: Icon,
51 error = false,
52 disabled = false,
53 id,
54 class: className,
55 searchPlaceholder,
56 emptyLabel,
57 onSearch,
58 loading,
59 align,
60 panelClass,
61 footer
62 }: Props = $props();
63
64 // a native optgroup has to nest, so the flat array is folded into runs of the same group.
65 // the rich rendering keeps the array flat and emits headings between rows instead.
66 const groups = $derived(
67 options.reduce<{ label?: string; options: SelectOption[] }[]>((groups, option) => {
68 const last = groups.at(-1);
69 if (last && last.label === option.group) last.options.push(option);
70 else groups.push({ label: option.group, options: [option] });
71 return groups;
72 }, [])
73 );
74
75 const field = $derived(selectField({ error, disabled }));
76
77 // `pl-7` has to be able to beat this `px-2`, so it is merged after rather than interpolated
78 // into the same string — prettier's class sorter would hoist an inline conditional to the
79 // front, where tailwind-merge resolves it the other way and the label lands under the icon
80 const nativeBox = "min-h-9 appearance-none px-2 py-1 pr-8";
81
82 const onchange = (event: Event) => {
83 onSelect?.((event.currentTarget as HTMLSelectElement).value);
84 };
85</script>
86
87{#snippet nativeOptions(items: SelectOption[])}
88 {#each items as option (option.value)}
89 <option value={option.value} disabled={option.disabled}>{option.label ?? option.value}</option>
90 {/each}
91{/snippet}
92
93{#if rich}
94 <Combobox
95 {options}
96 bind:value
97 {onSelect}
98 {placeholder}
99 {label}
100 icon={Icon}
101 {error}
102 {disabled}
103 {id}
104 {searchPlaceholder}
105 {emptyLabel}
106 {onSearch}
107 {loading}
108 {align}
109 {panelClass}
110 {footer}
111 class={className}
112 />
113{:else}
114 <div class="relative {className ?? ''}">
115 {#if Icon}
116 <Icon
117 class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-foreground-default"
118 aria-hidden="true"
119 />
120 {/if}
121 <select
122 bind:value
123 {id}
124 {disabled}
125 {onchange}
126 aria-label={label}
127 aria-invalid={error}
128 class={field.box({ class: [nativeBox, Icon && "pl-7"] })}
129 >
130 {#if placeholder}
131 <option value="">{placeholder}</option>
132 {/if}
133 {#each groups as group (group.label ?? "")}
134 {#if group.label}
135 <optgroup label={group.label}>{@render nativeOptions(group.options)}</optgroup>
136 {:else}
137 {@render nativeOptions(group.options)}
138 {/if}
139 {/each}
140 </select>
141 <ChevronDown
142 class={field.chevron({
143 class: "pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2"
144 })}
145 aria-hidden="true"
146 />
147 </div>
148{/if}