Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363<script module lang="ts"> import { tv, type VariantProps } from "tailwind-variants";
// the box, its border and its state colours come from selectField, shared with the native // rendering so flipping `rich` on a Select cannot change how the field looks export const combobox = tv({ slots: { root: "relative inline-block w-full", trigger: "flex min-h-9 cursor-pointer items-center gap-2 px-2.5 py-1", value: "min-w-0 flex-1 truncate text-left typography-paragraph-regular", panel: "combobox-panel fixed inset-auto z-50 m-0 hidden h-fit max-h-[min(24rem,calc(100dvh-1rem))] w-80 max-w-[calc(100vw-1rem)] popover-transition flex-col overflow-hidden rounded-sm border border-border-default bg-background-default p-0 text-foreground-default shadow-regular open:flex dark:shadow-none", search: "shrink-0 border-b border-border-default p-2", list: "min-h-0 flex-auto touch-pan-y overflow-y-auto overscroll-contain p-1 [-webkit-overflow-scrolling:touch]", groupHeading: "px-2 pt-2 pb-1 font-semibold text-foreground-muted first:pt-1", option: "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-background-subtle", check: "size-4 shrink-0 text-foreground-default", optionLabel: "min-w-0 flex-1 truncate", hint: "shrink-0 text-foreground-subtle", empty: "px-2 py-6 text-center text-foreground-muted", footer: "shrink-0 border-t border-border-default" }, variants: { placeholder: { true: { value: "text-foreground-placeholder" } } } });
export type ComboboxVariants = VariantProps<typeof combobox>;</script>
<script lang="ts"> import Check from "$icon/check"; import ChevronsUpDown from "$icon/chevrons-up-down"; import Search from "$icon/search"; import type { Component, Snippet } from "svelte"; import type { SvelteHTMLElements } from "svelte/elements"; import { tick } from "svelte"; import Input from "./Input.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; onSelect?: (value: string) => void; placeholder?: string; searchPlaceholder?: string; emptyLabel?: string; label?: string; icon?: Component<SvelteHTMLElements["svg"]>; /** take filtering over, e.g. to query a server: `options` is then rendered as given */ onSearch?: (query: string) => void; loading?: boolean; error?: boolean; disabled?: boolean; align?: SelectAlign; /** lets a Field's label point at this control */ id?: string; class?: string; panelClass?: string; listClass?: string; footer?: Snippet; filter?: boolean; }
let { options, value = $bindable(""), onSelect, placeholder = "Select an option", searchPlaceholder = "Filter…", emptyLabel = "No matches", label, icon: Icon, onSearch, loading = false, error = false, disabled = false, align = "left", id, class: className, panelClass, listClass, footer, filter = true }: Props = $props();
const panelId = $props.id(); const listId = `${panelId}-list`; const optionId = (index: number) => `${panelId}-option-${index}`; // anchor-name has to be a custom ident and unique per instance, so it cannot live in the // stylesheet. the invoker is the implicit anchor in principle, but naming it explicitly is // what actually holds: with only the implicit one the panel lands in the viewport's corner. const anchorName = `--combobox-${panelId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
let open = $state(false); let search = $state(""); let activeIndex = $state(-1); // only keyboard navigation scrolls the list: doing it on hover yanks the row out from // under the pointer let scrollActiveIntoView = false;
let triggerEl = $state<HTMLButtonElement>(); let panelEl = $state<HTMLElement>(); let searchEl = $state<HTMLInputElement>(); let listEl = $state<HTMLElement>();
const query = $derived(search.trim().toLowerCase()); const visible = $derived( onSearch || query === "" ? options : options.filter( (option) => (option.label ?? option.value).toLowerCase().includes(query) || option.value.toLowerCase().includes(query) ) );
const rows = $derived( visible.map((option, index) => ({ option, index, heading: option.group !== visible[index - 1]?.group ? option.group : undefined })) );
const selectable = $derived( visible.reduce<number[]>((indexes, option, index) => { if (!option.disabled) indexes.push(index); return indexes; }, []) );
const selected = $derived(options.find((option) => option.value === value)); // a value with no matching option still deserves a label: the ref selector can be sitting // on a commit sha, or on a branch past the point the list was capped const triggerLabel = $derived(selected ? (selected.label ?? selected.value) : value);
const classes = $derived(combobox({ placeholder: triggerLabel === "" || undefined })); const field = $derived(selectField({ error, disabled }));
const setActive = (index: number, fromKeyboard: boolean) => { activeIndex = index; scrollActiveIntoView = fromKeyboard; };
const move = (delta: number) => { if (selectable.length === 0) return; const current = selectable.indexOf(activeIndex); const next = current === -1 ? delta > 0 ? 0 : selectable.length - 1 : (((current + delta) % selectable.length) + selectable.length) % selectable.length; setActive(selectable[next], true); };
const close = () => { if (open) panelEl?.hidePopover(); };
const select = (option: SelectOption) => { if (option.disabled) return; value = option.value; onSelect?.(option.value); close(); triggerEl?.focus(); };
const onToggle = (event: Event) => { const { newState } = event as ToggleEvent; open = newState === "open"; if (!open) return;
search = ""; onSearch?.(""); const selectedIndex = options.findIndex((option) => option.value === value); setActive(selectable.includes(selectedIndex) ? selectedIndex : (selectable[0] ?? -1), true); void tick().then(() => searchEl?.focus()); };
const onTriggerKeydown = (event: KeyboardEvent) => { if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); triggerEl?.click(); } };
const onSearchInput = (event: Event) => { search = (event.currentTarget as HTMLInputElement).value; onSearch?.(search.trim()); setActive(selectable[0] ?? -1, true); };
const onSearchKeydown = (event: KeyboardEvent) => { switch (event.key) { case "ArrowDown": event.preventDefault(); move(1); break; case "ArrowUp": event.preventDefault(); move(-1); break; case "Home": event.preventDefault(); if (selectable.length > 0) setActive(selectable[0], true); break; case "End": event.preventDefault(); if (selectable.length > 0) setActive(selectable[selectable.length - 1], true); break; case "Enter": { // a bare Enter in a form would submit it, whether or not a row is active event.preventDefault(); const option = visible[activeIndex]; if (option) select(option); break; } case "Escape": event.preventDefault(); close(); triggerEl?.focus(); break; case "Tab": close(); break; } };
$effect(() => { if (!open || !scrollActiveIntoView || activeIndex < 0) return; listEl?.querySelector(`#${CSS.escape(optionId(activeIndex))}`)?.scrollIntoView({ block: "nearest" }); });</script>
<div class={classes.root({ class: className })}> <button bind:this={triggerEl} {id} type="button" {disabled} class={field.box({ class: classes.trigger() })} popovertarget={panelId} popovertargetaction="toggle" onkeydown={onTriggerKeydown} aria-haspopup="listbox" aria-expanded={open} aria-controls={panelId} aria-label={label} style="anchor-name: {anchorName}" > {#if Icon} <Icon class="size-4 shrink-0 text-foreground-default" aria-hidden="true" /> {/if} <span class={classes.value()}>{triggerLabel === "" ? placeholder : triggerLabel}</span> <ChevronsUpDown class={field.chevron({ class: "size-3.5" })} aria-hidden="true" /> </button>
<div bind:this={panelEl} id={panelId} data-align={align} popover="auto" ontoggle={onToggle} style="position-anchor: {anchorName}" class={classes.panel({ class: panelClass })} > {#if filter} <div class={classes.search()}> <Input bind:element={searchEl} type="text" value={search} oninput={onSearchInput} onkeydown={onSearchKeydown} placeholder={searchPlaceholder} iconLeft={Search} {loading} autocomplete="off" spellcheck="false" role="combobox" aria-expanded="true" aria-controls={listId} aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined} aria-label={label ? `${label} filter` : "Filter options"} /> </div> {/if}
<div bind:this={listEl} id={listId} role="listbox" aria-label={label} class="{classes.list()} {listClass}" > {#each rows as { option, index, heading } (option.value)} {#if heading} <div class={classes.groupHeading()}>{heading}</div> {/if} <button id={optionId(index)} type="button" role="option" tabindex={-1} class={classes.option()} data-active={index === activeIndex || undefined} aria-selected={option.value === value} aria-disabled={option.disabled} onclick={() => select(option)} onpointermove={() => { if (!option.disabled && index !== activeIndex) setActive(index, false); }} > {#if option.value === value} <Check class={classes.check()} aria-hidden="true" /> {:else} <span class="size-4 shrink-0" aria-hidden="true"></span> {/if} <span class={classes.optionLabel()}>{option.label ?? option.value}</span> {#if option.hint} <span class={classes.hint()}>{option.hint}</span> {/if} </button> {:else} <p class={classes.empty()}>{emptyLabel}</p> {/each} </div>
{#if footer} <div class={classes.footer()}>{@render footer()}</div> {/if} </div></div>
<style> /* anchor positioning went Baseline in early 2026; without it the popover falls back to the UA's centred position, the same trade-off Dropdown already takes. the trigger is the implicit anchor because it invoked the popover through popovertarget. */ @supports (position-area: bottom) and (position-try: flip-block) { .combobox-panel { position: absolute; /* the gap to the trigger — margin, because position-area owns inset */ margin-top: 0.25rem; position-try: flip-block; }
.combobox-panel[data-align="left"] { position-area: block-end span-inline-end; }
.combobox-panel[data-align="right"] { position-area: block-end span-inline-start; } }</style>