Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148<script lang="ts"> // SelectOption and SelectAlign live in ./selectField, which owns the shape both renderings // read; re-exporting them from here trips eslint's no-import-assign on module scripts import ChevronDown from "$icon/chevron-down"; import type { Component, Snippet } from "svelte"; import type { SvelteHTMLElements } from "svelte/elements"; import Combobox from "./Combobox.svelte"; import { selectField, type SelectAlign, type SelectOption } from "./selectField";
interface Props { options: SelectOption[]; /** two-way; the selected option's value. Pass without `bind:` when the truth lives * elsewhere — a ref in the url, say — and let `onSelect` do the navigating. */ value?: string; /** Swap the native control for a searchable listbox. Same options, same box: reach for it * once a list is long enough that scanning it beats reading it. */ rich?: boolean; onSelect?: (value: string) => void; /** the empty row shown first, e.g. "Choose stat"; picking it clears the value */ placeholder?: string; label?: string; /** the icon stands for the current selection, so keep it in step with `value` * rather than the list. */ icon?: Component<SvelteHTMLElements["svg"]>; error?: boolean; disabled?: boolean; /** lets a Field's label point at this control */ id?: string; class?: string; searchPlaceholder?: string; emptyLabel?: string; /** take filtering over, e.g. to query a server: `options` is then rendered as given */ onSearch?: (query: string) => void; loading?: boolean; align?: SelectAlign; panelClass?: string; footer?: Snippet; }
let { options, value = $bindable(""), rich = false, onSelect, placeholder, label, icon: Icon, error = false, disabled = false, id, class: className, searchPlaceholder, emptyLabel, onSearch, loading, align, panelClass, footer }: Props = $props();
// a native optgroup has to nest. // the rich rendering keeps the array flat and emits headings between rows instead. const groups = $derived( options.reduce<{ label?: string; options: SelectOption[] }[]>((groups, option) => { const last = groups.at(-1); if (last && last.label === option.group) last.options.push(option); else groups.push({ label: option.group, options: [option] }); return groups; }, []) );
const field = $derived(selectField({ error, disabled }));
// `pl-7` has to be able to beat this `px-2`, so it is merged after rather than interpolated // into the same string — prettier's class sorter would hoist an inline conditional to the // front, where tailwind-merge resolves it the other way and the label lands under the icon const nativeBox = "min-h-9 appearance-none px-2 py-1 pr-8";
const onchange = (event: Event) => { onSelect?.((event.currentTarget as HTMLSelectElement).value); };</script>
{#snippet nativeOptions(items: SelectOption[])} {#each items as option (option.value)} <option value={option.value} disabled={option.disabled} >{option.label ?? option.value}</option > {/each}{/snippet}
{#if rich} <Combobox {options} bind:value {onSelect} {placeholder} {label} icon={Icon} {error} {disabled} {id} {searchPlaceholder} {emptyLabel} {onSearch} {loading} {align} {panelClass} {footer} class={className} />{:else} <div class="relative {className ?? ''}"> {#if Icon} <Icon class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-foreground-default" aria-hidden="true" /> {/if} <select bind:value {id} {disabled} {onchange} aria-label={label} aria-invalid={error} class={field.box({ class: [nativeBox, Icon && "pl-7"] })} > {#if placeholder} <option value="">{placeholder}</option> {/if} {#each groups as group (group.label ?? "")} {#if group.label} <optgroup label={group.label}>{@render nativeOptions(group.options)}</optgroup> {:else} {@render nativeOptions(group.options)} {/if} {/each} </select> <ChevronDown class={field.chevron({ class: "pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2" })} aria-hidden="true" /> </div>{/if}