Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463<script module lang="ts"> import { tv, type VariantProps } from "tailwind-variants";
export const combobox = tv({ slots: { root: "relative inline-block w-full", trigger: "flex min-h-8 cursor-pointer items-center gap-xs px-2.5 py-2xs", value: "min-w-0 flex-1 truncate text-left typography-paragraph-regular", panel: "combobox-panel fixed inset-auto z-50 m-none 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-none text-foreground-default shadow-regular open:flex dark:shadow-none", search: "shrink-0 border-b border-border-default p-xs", list: "min-h-0 flex-auto touch-pan-y overflow-y-auto overscroll-contain p-2xs [-webkit-overflow-scrolling:touch]", groupHeading: "px-xs pt-xs pb-2xs font-semibold text-foreground-muted first:pt-2xs", option: "flex w-full cursor-pointer items-center gap-xs rounded-sm bg-transparent px-xs 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-3.5 shrink-0 text-foreground-default", optionLabel: "min-w-0 flex-1 truncate", hint: "shrink-0 text-foreground-subtle", empty: "px-xs py-xl 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 { createAttachmentKey } from "svelte/attachments"; import Input from "$lib/components/ui/Input.svelte"; import Separator from "$lib/components/ui/Separator.svelte"; import { popoverExit } from "$lib/popoverExit"; import { popoverOrigin, type PopoverOrigin } from "$lib/components/ui/popover.svelte"; import { selectField, type SelectAlign, type SelectOption } from "$lib/components/ui/selectField";
interface Props { options: SelectOption[]; value?: string; onSelect?: (value: string) => void; multiple?: boolean; values?: string[]; onToggle?: (value: string) => void; placeholder?: string; searchPlaceholder?: string; emptyLabel?: string; label?: string; icon?: Component<SvelteHTMLElements["svg"]>; onSearch?: (query: string) => void; loading?: boolean; error?: boolean; disabled?: boolean; align?: SelectAlign; id?: string; class?: string; panelClass?: string; listClass?: string; footer?: Snippet; trigger?: Snippet<[{ props: Record<string, unknown> }]>; option?: Snippet<[SelectOption]>; filter?: boolean; origin?: PopoverOrigin; freezePosition?: boolean; }
let { options, value = $bindable(""), onSelect, multiple = false, values = $bindable([]), onToggle, 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, trigger, option, filter = true, origin, freezePosition = false }: 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); let scrollActiveIntoView = false;
let triggerEl = $state<HTMLElement>(); let panelEl = $state<HTMLElement>(); let searchEl = $state<HTMLInputElement>(); let listEl = $state<HTMLElement>(); let frozenPosition = $state<{ top: number; left: number }>(); let scrollOrigins: { element: Element; top: number; left: number }[] = []; let scrollOffset = $state({ top: 0, left: 0 });
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)); const isSelected = (option: SelectOption) => multiple ? values.includes(option.value) : option.value === value; const triggerLabel = $derived( multiple ? options .filter((option) => values.includes(option.value)) .map((option) => option.label ?? option.value) .join(", ") : 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; if (multiple) { values = values.includes(option.value) ? values.filter((picked) => picked !== option.value) : [...values, option.value]; onToggle?.(option.value); return; } value = option.value; onSelect?.(option.value); close(); triggerEl?.focus(); };
const onPanelToggle = (event: Event) => { const { newState } = event as ToggleEvent; open = newState === "open"; if (!open) return;
if (freezePosition && panelEl && triggerEl) { const trigger = triggerEl.getBoundingClientRect(); const gap = Number.parseFloat(getComputedStyle(panelEl).marginTop) || 0; const viewportInset = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) / 2; const left = align === "right" ? trigger.right - panelEl.offsetWidth : trigger.left; frozenPosition = { top: trigger.bottom + gap, left: Math.max( viewportInset, Math.min( left, document.documentElement.clientWidth - panelEl.offsetWidth - viewportInset ) ) }; scrollOffset = { top: 0, left: 0 }; scrollOrigins = []; for (let element = triggerEl?.parentElement; element; element = element.parentElement) { scrollOrigins.push({ element, top: element.scrollTop, left: element.scrollLeft }); } }
search = ""; onSearch?.(""); setActive(-1, false); void tick().then(() => searchEl?.focus({ preventScroll: true })); };
const onTriggerKeydown = (event: KeyboardEvent) => { if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); triggerEl?.click(); } };
const triggerAttachment = createAttachmentKey();
const triggerProps = $derived({ id, disabled, popovertarget: panelId, popovertargetaction: "toggle" as const, onkeydown: onTriggerKeydown, "aria-haspopup": "listbox" as const, "aria-expanded": open, "aria-controls": panelId, style: `anchor-name: ${anchorName}`, [triggerAttachment]: (node: HTMLElement) => { triggerEl = node; } });
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": { 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 || !freezePosition) return; const onScroll = () => { scrollOffset = scrollOrigins.reduce( (offset, { element, top, left }) => ({ top: offset.top + element.scrollTop - top, left: offset.left + element.scrollLeft - left }), { top: 0, left: 0 } ); }; window.addEventListener("scroll", onScroll, true); return () => window.removeEventListener("scroll", onScroll, true); });
$effect(() => { if (!open || !scrollActiveIntoView || activeIndex < 0) return; listEl?.querySelector(`#${CSS.escape(optionId(activeIndex))}`)?.scrollIntoView({ block: "nearest" }); });</script>
<div class={classes.root({ class: className })}> {#if trigger} {@render trigger({ props: triggerProps })} {:else} <button type="button" class={field.box({ class: classes.trigger() })} aria-label={label} {...triggerProps} > {#if Icon} <Icon class="size-3.5 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> {/if}
<div bind:this={panelEl} id={panelId} data-align={align} popover="auto" ontoggle={onPanelToggle} style={frozenPosition ? `position-anchor: none; position-area: none; position-try: none; position: fixed; inset: auto; margin: 0; top: ${frozenPosition.top - scrollOffset.top}px; left: ${frozenPosition.left - scrollOffset.left}px` : `position-anchor: ${anchorName}`} class={classes.panel({ class: panelClass })} {@attach popoverExit((exiting) => { if (!exiting) frozenPosition = undefined; })} {@attach popoverOrigin( () => triggerEl, () => origin )} > {#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} aria-multiselectable={multiple || undefined} class="{classes.list()} {listClass}" > {#each rows as { option: item, index, heading } (item.value)} {#if heading} <div class={classes.groupHeading()}>{heading}</div> {/if} {#if item.separator} <Separator class="my-2xs" /> {/if} <button id={optionId(index)} type="button" role="option" tabindex={-1} class={classes.option()} data-active={index === activeIndex || undefined} aria-selected={isSelected(item)} aria-disabled={item.disabled} onclick={() => select(item)} onpointermove={() => { if (!item.disabled && index !== activeIndex) setActive(index, false); }} > {#if isSelected(item)} <Check class={classes.check()} aria-hidden="true" /> {:else if item.icon} <item.icon class={classes.check()} aria-hidden="true" /> {:else} <span class="size-3.5 shrink-0" aria-hidden="true"></span> {/if} {#if option} {@render option(item)} {:else} <span class={classes.optionLabel()}>{item.label ?? item.value}</span> {#if item.hint} <span class={classes.hint()}>{item.hint}</span> {/if} {/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>