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
4.5 kB 148 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 { 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}