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 / Select.svelte
3.7 kB 131 lines
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}