This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web/component: add rich mode to Select for branch and tag search

Signed-off-by: eti <eti@eti.tf>

+751 -132
+2 -1
web/src/lib/components/repo/RefSelector.stories.svelte
··· 11 11 repoName: "tangled", 12 12 current: "main", 13 13 branches: ["main", "feature/storybook"], 14 - tags: ["v1.0.0", "v0.9.0"] 14 + tags: ["v1.0.0", "v0.9.0"], 15 + defaultBranch: "main" 15 16 } 16 17 }); 17 18 </script>
+32 -20
web/src/lib/components/repo/RefSelector.svelte
··· 3 3 import { resolve } from "$app/paths"; 4 4 import GitCompare from "$icon/git-compare"; 5 5 import Select from "$lib/components/ui/Select.svelte"; 6 + import type { SelectOption } from "$lib/components/ui/selectField"; 6 7 7 8 interface Props { 8 9 ownerHandle: string; ··· 10 11 current: string; 11 12 branches: string[]; 12 13 tags: string[]; 14 + defaultBranch?: string; 13 15 } 14 16 15 - let { ownerHandle, repoName, current, branches, tags }: Props = $props(); 17 + let { ownerHandle, repoName, current, branches, tags, defaultBranch }: Props = $props(); 16 18 17 19 const base = $derived(`/${ownerHandle}/${repoName}`); 18 20 19 - const onchange = (event: Event) => { 20 - const value = (event.currentTarget as HTMLSelectElement).value; 21 + // no counts in the headings: the knot caps how many refs it hands back, so the length here 22 + // is the loaded count rather than the repo's total, and it would not track the filter either 23 + const options = $derived<SelectOption[]>([ 24 + ...branches.map((branch) => ({ 25 + value: branch, 26 + hint: branch === defaultBranch ? "default" : undefined, 27 + group: "Branches" 28 + })), 29 + ...(tags.length > 0 30 + ? tags.map((tag) => ({ value: tag, group: "Tags" })) 31 + : [{ value: "No tags found", disabled: true, group: "Tags" }]) 32 + ]); 33 + 34 + const onSelect = (value: string) => { 21 35 if (value === current) return; 22 36 void goto(resolve(`${base}/tree/${encodeURIComponent(value)}` as "/")); 23 37 }; ··· 26 40 <div class="flex min-w-0 flex-1 items-stretch gap-2"> 27 41 <div class="max-w-32 sm:max-w-64"> 28 42 <!-- the ref lives in the url so navigation updates it, no two way binding --> 29 - <Select value={current} {onchange} aria-label="Switch branch or tag"> 30 - <!-- a commit sha or a ref past the knot's cap still needs a label --> 31 - {#if !branches.includes(current) && !tags.includes(current)} 32 - <option value={current}>{current}</option> 33 - {/if} 34 - <optgroup label={`Branches (${branches.length})`}> 35 - {#each branches as branch (branch)} 36 - <option value={branch}>{branch}</option> 37 - {/each} 38 - </optgroup> 39 - <optgroup label={`Tags (${tags.length})`}> 40 - {#each tags as tag (tag)} 41 - <option value={tag}>{tag}</option> 42 - {:else} 43 - <option disabled>No tags found</option> 44 - {/each} 45 - </optgroup> 43 + <Select 44 + rich 45 + {options} 46 + value={current} 47 + {onSelect} 48 + label="Switch branch or tag" 49 + searchPlaceholder="Find a branch or tag…" 50 + emptyLabel="No branch or tag matches" 51 + > 52 + {#snippet footer()} 53 + <div class="flex items-center gap-4 px-2 py-1.5"> 54 + <a href={resolve(`${base}/branches` as "/")} class="text-sm"> View all branches </a> 55 + <a href={resolve(`${base}/tags` as "/")} class="text-sm">View all tags</a> 56 + </div> 57 + {/snippet} 46 58 </Select> 47 59 </div> 48 60 <a
+1
web/src/lib/components/repo/RepoToolbar.svelte
··· 32 32 current={ref} 33 33 branches={refs.branches} 34 34 tags={refs.tags} 35 + defaultBranch={repo.defaultBranch} 35 36 /> 36 37 37 38 <form
+6 -6
web/src/lib/components/settings/tabs/ProfileTab.svelte
··· 85 85 <SettingsRow title="Vanity stats" align="start" stack> 86 86 <div class="flex w-full flex-col gap-2 sm:w-80"> 87 87 {#each form.stats.keys() as i (i)} 88 - <Select bind:value={form.stats[i]} aria-label="Vanity stat {i + 1}"> 89 - <option value="">Choose stat</option> 90 - {#each STATS as stat (stat)} 91 - <option value={stat}>{stat}</option> 92 - {/each} 93 - </Select> 88 + <Select 89 + bind:value={form.stats[i]} 90 + options={STATS.map((stat) => ({ value: stat }))} 91 + placeholder="Choose stat" 92 + label="Vanity stat {i + 1}" 93 + /> 94 94 {/each} 95 95 </div> 96 96 </SettingsRow>
+382
web/src/lib/components/ui/Combobox.svelte
··· 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 py-1", 10 + value: "min-w-0 flex-1 truncate text-left", 11 + panel: 12 + "combobox-panel fixed z-50 m-0 flex 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-sm text-foreground-default shadow-regular", 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 typography-paragraph-small font-bold 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-[rgb(55_65_81/0.5)]", 19 + check: "size-4 shrink-0 text-foreground-default", 20 + optionLabel: "min-w-0 flex-1 truncate", 21 + hint: "shrink-0 typography-paragraph-small text-foreground-muted", 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>
+8 -5
web/src/lib/components/ui/Field.stories.svelte
··· 63 63 <Story name="WithSelect"> 64 64 {#snippet template(args)} 65 65 <Field {...args} for="field-select"> 66 - <Select id="field-select"> 67 - <option value="" disabled selected>Select</option> 68 - <option value="one">One</option> 69 - <option value="two">Two</option> 70 - </Select> 66 + <Select 67 + id="field-select" 68 + placeholder="Select" 69 + options={[ 70 + { value: "one", label: "One" }, 71 + { value: "two", label: "Two" } 72 + ]} 73 + /> 71 74 </Field> 72 75 {/snippet} 73 76 </Story>
+4
web/src/lib/components/ui/Input.svelte
··· 37 37 iconRight?: Component<SvelteHTMLElements["svg"]>; 38 38 /** static, non-editable trailing text shown inside the field, e.g. a domain suffix */ 39 39 suffix?: string; 40 + /** the input itself, for callers that have to focus or measure it */ 41 + element?: HTMLInputElement; 40 42 class?: string; 41 43 } 42 44 ··· 48 50 iconLeft, 49 51 iconRight, 50 52 suffix, 53 + element = $bindable(), 51 54 class: className, 52 55 ...rest 53 56 }: Props = $props(); ··· 61 64 <IconLeft class="size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> 62 65 {/if} 63 66 <input 67 + bind:this={element} 64 68 bind:value 65 69 {disabled} 66 70 aria-invalid={error}
+120 -19
web/src/lib/components/ui/Select.stories.svelte
··· 1 1 <script module lang="ts"> 2 + import { resolve } from "$app/paths"; 2 3 import { defineMeta } from "@storybook/addon-svelte-csf"; 3 4 import Select from "./Select.svelte"; 5 + import type { SelectOption } from "./selectField"; 4 6 5 7 const { Story } = defineMeta({ 6 8 title: "UI/Select", 7 9 component: Select, 8 10 tags: ["autodocs"], 9 11 argTypes: { 12 + rich: { control: "boolean" }, 10 13 error: { control: "boolean" }, 11 14 disabled: { control: "boolean" } 12 15 }, 13 16 args: { 17 + rich: false, 14 18 error: false, 15 - disabled: false 19 + disabled: false, 20 + label: "Pick one", 21 + value: "two", 22 + options: [ 23 + { value: "one", label: "One" }, 24 + { value: "two", label: "Two" }, 25 + { value: "three", label: "Three" } 26 + ] satisfies SelectOption[] 16 27 } 17 28 }); 29 + 30 + const refs: SelectOption[] = [ 31 + { value: "main", hint: "default", group: "Branches" }, 32 + { value: "next", group: "Branches" }, 33 + { value: "feature/combobox", group: "Branches" }, 34 + { value: "feature/storybook", group: "Branches" }, 35 + { value: "v1.0.0", group: "Tags" }, 36 + { value: "v0.9.0", group: "Tags" } 37 + ]; 38 + 39 + const many: SelectOption[] = Array.from({ length: 60 }, (_, index) => ({ 40 + value: index === 0 ? "main" : `renovate/dependency-${index}`, 41 + hint: index === 0 ? "default" : undefined, 42 + group: "Branches" 43 + })); 18 44 </script> 19 45 20 - <!-- Select's content comes through its `children` snippet, provided here as Story slot content. --> 21 - <Story name="Default"> 22 - <option value="" disabled selected>Select</option> 23 - <option value="one">One</option> 24 - <option value="two">Two</option> 25 - <option value="three">Three</option> 46 + <!-- the same options render either way: `rich` only changes the control drawn around them --> 47 + <Story name="Native"> 48 + {#snippet template(args)} 49 + <div class="w-64"><Select {...args} /></div> 50 + {/snippet} 51 + </Story> 52 + 53 + <Story name="Rich" args={{ rich: true }}> 54 + {#snippet template(args)} 55 + <div class="w-64"><Select {...args} /></div> 56 + {/snippet} 57 + </Story> 58 + 59 + <Story name="Placeholder" args={{ value: "", placeholder: "Choose one" }}> 60 + {#snippet template(args)} 61 + <div class="w-64"><Select {...args} /></div> 62 + {/snippet} 63 + </Story> 64 + 65 + <Story name="Grouped" args={{ options: refs, value: "main" }}> 66 + {#snippet template(args)} 67 + <div class="w-64"><Select {...args} /></div> 68 + {/snippet} 69 + </Story> 70 + 71 + <Story name="Grouped rich" args={{ rich: true, options: refs, value: "main" }}> 72 + {#snippet template(args)} 73 + <div class="w-64"><Select {...args} /></div> 74 + {/snippet} 75 + </Story> 76 + 77 + <Story 78 + name="Rich value outside the options" 79 + args={{ rich: true, options: refs, value: "0123456789abcdef" }} 80 + > 81 + {#snippet template(args)} 82 + <div class="w-64"><Select {...args} /></div> 83 + {/snippet} 84 + </Story> 85 + 86 + <Story name="Rich scrolling list with a footer" args={{ rich: true, options: many, value: "main" }}> 87 + {#snippet template(args)} 88 + <div class="w-64"> 89 + <Select {...args}> 90 + {#snippet footer()} 91 + <a href={resolve("/")} class="block p-2 text-sm">View all branches</a> 92 + {/snippet} 93 + </Select> 94 + </div> 95 + {/snippet} 96 + </Story> 97 + 98 + <Story 99 + name="Disabled options" 100 + args={{ 101 + rich: true, 102 + value: "main", 103 + options: [ 104 + { value: "main", hint: "default", group: "Branches" }, 105 + { value: "next", group: "Branches" }, 106 + { value: "No tags found", disabled: true, group: "Tags" } 107 + ] 108 + }} 109 + > 110 + {#snippet template(args)} 111 + <div class="w-64"><Select {...args} /></div> 112 + {/snippet} 113 + </Story> 114 + 115 + <Story name="Right aligned" args={{ rich: true, align: "right" }}> 116 + {#snippet template(args)} 117 + <div class="flex justify-end"> 118 + <div class="w-64"><Select {...args} /></div> 119 + </div> 120 + {/snippet} 121 + </Story> 122 + 123 + <Story name="Error" args={{ error: true }}> 124 + {#snippet template(args)} 125 + <div class="w-64"><Select {...args} /></div> 126 + {/snippet} 26 127 </Story> 27 128 28 - <Story name="Filled" args={{ value: "two" }}> 29 - <option value="one">One</option> 30 - <option value="two">Two</option> 31 - <option value="three">Three</option> 129 + <Story name="Error rich" args={{ rich: true, error: true }}> 130 + {#snippet template(args)} 131 + <div class="w-64"><Select {...args} /></div> 132 + {/snippet} 32 133 </Story> 33 134 34 - <Story name="Error" args={{ error: true, value: "two" }}> 35 - <option value="one">One</option> 36 - <option value="two">Two</option> 37 - <option value="three">Three</option> 135 + <Story name="Disabled" args={{ disabled: true }}> 136 + {#snippet template(args)} 137 + <div class="w-64"><Select {...args} /></div> 138 + {/snippet} 38 139 </Story> 39 140 40 - <Story name="Disabled" args={{ disabled: true, value: "one" }}> 41 - <option value="one">One</option> 42 - <option value="two">Two</option> 43 - <option value="three">Three</option> 141 + <Story name="Disabled rich" args={{ rich: true, disabled: true }}> 142 + {#snippet template(args)} 143 + <div class="w-64"><Select {...args} /></div> 144 + {/snippet} 44 145 </Story>
+112 -66
web/src/lib/components/ui/Select.svelte
··· 1 - <script module lang="ts"> 2 - import { tv, type VariantProps } from "tailwind-variants"; 3 - 4 - export const select = tv({ 5 - base: "w-full appearance-none rounded-sm border bg-background-default px-3 py-2 pr-9 text-sm transition-colors outline-none disabled:cursor-not-allowed", 6 - variants: { 7 - error: { 8 - false: 9 - "border-border-default focus:border-border-strong focus:ring-1 focus:ring-border-strong", 10 - true: "border-border-danger bg-background-danger-subtle text-foreground-danger focus:border-border-focus-danger focus:ring-1 focus:ring-border-focus-danger" 11 - }, 12 - disabled: { 13 - false: "", 14 - true: "text-foreground-disabled" 15 - } 16 - }, 17 - compoundVariants: [ 18 - { 19 - error: true, 20 - disabled: true, 21 - class: 22 - "border-border-danger-subtle bg-background-danger-subtle text-foreground-danger-subtle" 23 - } 24 - ], 25 - defaultVariants: { 26 - error: false, 27 - disabled: false 28 - } 29 - }); 30 - 31 - export type SelectVariants = VariantProps<typeof select>; 32 - 33 - const chevron = tv({ 34 - base: "pointer-events-none absolute top-1/2 right-3 size-3.5 -translate-y-1/2 text-foreground-subtle", 35 - variants: { 36 - error: { 37 - true: "text-foreground-danger" 38 - }, 39 - disabled: { 40 - true: "text-foreground-disabled" 41 - } 42 - }, 43 - compoundVariants: [ 44 - { 45 - error: true, 46 - disabled: true, 47 - class: "text-foreground-danger-subtle" 48 - } 49 - ] 50 - }); 51 - </script> 52 - 53 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 54 4 import ChevronDown from "$icon/chevron-down"; 55 - import type { HTMLSelectAttributes } from "svelte/elements"; 56 5 import type { Snippet } from "svelte"; 6 + import Combobox from "./Combobox.svelte"; 7 + import { selectField, type SelectAlign, type SelectOption } from "./selectField"; 57 8 58 - interface Props extends Omit<HTMLSelectAttributes, "class" | "value"> { 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. */ 59 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; 60 22 error?: boolean; 61 23 disabled?: boolean; 24 + /** goes on the control itself, so a Field's label can point at it */ 25 + id?: string; 62 26 class?: string; 63 - children?: Snippet; 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; 64 37 } 65 38 66 39 let { 40 + options, 67 41 value = $bindable(""), 42 + rich = false, 43 + onSelect, 44 + placeholder, 45 + label, 68 46 error = false, 69 47 disabled = false, 48 + id, 70 49 class: className, 71 - children, 72 - ...rest 50 + searchPlaceholder, 51 + emptyLabel, 52 + onSearch, 53 + loading, 54 + align, 55 + panelClass, 56 + footer 73 57 }: Props = $props(); 74 58 75 - const classes = $derived(select({ error, disabled, class: className })); 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 + }; 76 75 </script> 77 76 78 - <div class="relative"> 79 - <select bind:value {disabled} aria-invalid={error} class={classes} {...rest}> 80 - {#if children} 81 - {@render children()} 82 - {/if} 83 - </select> 84 - <ChevronDown class={chevron({ error, disabled })} aria-hidden="true" /> 85 - </div> 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}
+60
web/src/lib/components/ui/selectField.ts
··· 1 + import { tv, type VariantProps } from "tailwind-variants"; 2 + 3 + /** one row of a Select, in either rendering */ 4 + export interface SelectOption { 5 + value: string; 6 + /** shown instead of `value`; `value` is still matched when filtering */ 7 + label?: string; 8 + /** right-aligned secondary text, e.g. "default" on a repo's default branch. rich only: 9 + * a native `<option>` has nowhere to put it */ 10 + hint?: string; 11 + /** consecutive options sharing one become a single optgroup or heading */ 12 + group?: string; 13 + disabled?: boolean; 14 + } 15 + 16 + export type SelectAlign = "left" | "right"; 17 + 18 + /** The box both renderings draw. Layout differs — a native select needs the chevron overlaid, 19 + * the rich trigger lays it out inline — so each renderer appends its own padding and display, 20 + * and only the border, fill and state colours live here. */ 21 + export const selectField = tv({ 22 + slots: { 23 + box: "w-full rounded-sm border bg-background-default text-sm transition-colors outline-none disabled:cursor-not-allowed", 24 + chevron: "shrink-0 text-foreground-subtle" 25 + }, 26 + variants: { 27 + error: { 28 + false: { 29 + box: "border-border-default text-foreground-default focus-visible:border-border-strong focus-visible:ring-1 focus-visible:ring-border-strong" 30 + }, 31 + true: { 32 + box: "border-border-danger bg-background-danger-subtle text-foreground-danger focus-visible:border-border-focus-danger focus-visible:ring-1 focus-visible:ring-border-focus-danger", 33 + chevron: "text-foreground-danger" 34 + } 35 + }, 36 + disabled: { 37 + false: {}, 38 + true: { 39 + box: "text-foreground-disabled", 40 + chevron: "text-foreground-disabled" 41 + } 42 + } 43 + }, 44 + compoundVariants: [ 45 + { 46 + error: true, 47 + disabled: true, 48 + class: { 49 + box: "border-border-danger-subtle bg-background-danger-subtle text-foreground-danger-subtle", 50 + chevron: "text-foreground-danger-subtle" 51 + } 52 + } 53 + ], 54 + defaultVariants: { 55 + error: false, 56 + disabled: false 57 + } 58 + }); 59 + 60 + export type SelectFieldVariants = VariantProps<typeof selectField>;
+9 -5
web/src/routes/[handle]/[repo]/settings/+page.svelte
··· 104 104 ]} 105 105 stack 106 106 > 107 - <Select bind:value={form.defaultBranch} aria-label="Default branch" class="w-full sm:w-80"> 108 - {#each branches as branch (branch)} 109 - <option value={branch}>{branch}</option> 110 - {/each} 111 - </Select> 107 + <Select 108 + rich 109 + bind:value={form.defaultBranch} 110 + options={branches.map((branch) => ({ value: branch }))} 111 + label="Default branch" 112 + searchPlaceholder="Find a branch…" 113 + emptyLabel="No branch matches" 114 + class="w-full sm:w-80" 115 + /> 112 116 </SettingsRow> 113 117 </SettingsList> 114 118 </div>
+6 -5
web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte
··· 51 51 52 52 <SettingsList> 53 53 <SettingsRow title="Spindle" stack> 54 - <Select bind:value={spindle} aria-label="Spindle" class="w-full sm:w-80"> 55 - {#each spindles as option (option)} 56 - <option value={option}>{option}</option> 57 - {/each} 58 - </Select> 54 + <Select 55 + bind:value={spindle} 56 + options={spindles.map((option) => ({ value: option }))} 57 + label="Spindle" 58 + class="w-full sm:w-80" 59 + /> 59 60 </SettingsRow> 60 61 </SettingsList> 61 62 </SettingsBlock>
+9 -5
web/src/routes/[handle]/[repo]/settings/sites/+page.svelte
··· 70 70 71 71 <SettingsList> 72 72 <SettingsRow title="Branch" description="The branch to build and deploy the site from." stack> 73 - <Select bind:value={form.branch} aria-label="Branch" class="w-full sm:w-80"> 74 - {#each branches as branch (branch.value)} 75 - <option value={branch.value}>{branch.label}</option> 76 - {/each} 77 - </Select> 73 + <Select 74 + rich 75 + bind:value={form.branch} 76 + options={branches} 77 + label="Branch" 78 + searchPlaceholder="Find a branch…" 79 + emptyLabel="No branch matches" 80 + class="w-full sm:w-80" 81 + /> 78 82 </SettingsRow> 79 83 80 84 <SettingsRow