<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-md bg-background-default p-none text-foreground-default shadow-regular edge-ring 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",
checkbox:
"flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-default bg-background-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" }
},
checked: {
true: {
checkbox:
"border-foreground-default bg-foreground-default text-background-default"
}
}
}
});
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.ts";
import { popoverOrigin, type PopoverOrigin } from "#lib/components/ui/popover.svelte.ts";
import {
selectField,
type SelectAlign,
type SelectOption
} from "#lib/components/ui/selectField.ts";
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;
testid?: string;
panelClass?: string;
listClass?: string;
footer?: Snippet;
trigger?: Snippet<[{ props: Record<string, unknown> }]>;
option?: Snippet<[SelectOption]>;
filter?: boolean;
origin?: PopoverOrigin;
freezePosition?: boolean;
checkboxes?: boolean;
matchTriggerWidth?: 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,
testid,
panelClass,
listClass,
footer,
trigger,
option,
filter = true,
origin,
freezePosition = false,
checkboxes = false,
matchTriggerWidth = 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 })} data-testid={testid}>
{#if trigger}
{@render trigger({ props: triggerProps })}
{:else}
<button
data-testid="trigger"
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}
data-match-trigger={matchTriggerWidth ? "" : undefined}
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
data-testid="search"
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.group ?? ""}\u0000${item.value}`)}
{#if heading}
<div class={classes.groupHeading()}>{heading}</div>
{/if}
{#if item.separator}
<Separator class="my-2xs" />
{/if}
<button
data-testid="option"
data-value={item.value}
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 checkboxes && multiple && !item.icon}
<span
class={classes.checkbox({ checked: isSelected(item) })}
aria-hidden="true"
>
{#if isSelected(item)}
<Check class="size-3.5" />
{/if}
</span>
{:else 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;
}
.combobox-panel[data-match-trigger] {
width: anchor-size(width);
}
}
</style>