Primitives Forms
Combobox
A text field with a popup list of suggestions: the APG editable combobox with list autocomplete. A click anywhere in the field opens the list, the chosen option carries a check mark, a clear button and the chevron sit inside the field, and an empty message says when nothing matches. The input stays a native text field and keeps DOM focus; per-root state is a controller created on the first interaction and released when focus leaves. Tier 2.
- Tier
- 2 · Lazy scoped controller
- Behavior JS
- 2564 B brotli · 2849 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
<input role="combobox">,popover,aria-activedescendant,aria-expanded,KeyboardEvent.isComposing,anchor-name- Shared listeners
- click, keydown, input, pointerdown, pointerover, focusout, toggle
- Per-instance listeners
- scoped to one interaction session
- Lazy state
- WeakMap entry created on first interaction
- Native base
<input role="combobox"> + [popover] + ARIA listbox (role=listbox, option)
On this page
Example
- Ankara
- Berlin
- Cairo
- Lisbon
- Oslo
No city matches
<label for="city-input">City</label>
<div id="city" data-slean="combobox">
<input
id="city-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="city-list"
aria-autocomplete="list"
autocomplete="off"
placeholder="Select a city"
data-slean-part="input"
/>
<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
<button
type="button"
tabindex="-1"
aria-label="Show cities"
aria-expanded="false"
data-slean-part="toggle"
></button>
<div id="city-popup" popover="manual" data-slean-part="popup">
<ul id="city-list" role="listbox" aria-label="Cities">
<li id="city-ankara" role="option" aria-selected="false" data-slean-value="ankara">Ankara</li>
<li id="city-berlin" role="option" aria-selected="false" data-slean-value="berlin">Berlin</li>
<li
id="city-cairo"
role="option"
aria-selected="false"
aria-disabled="true"
data-slean-value="cairo"
>
Cairo
</li>
<li id="city-lisbon" role="option" aria-selected="false" data-slean-value="lisbon">Lisbon</li>
<li id="city-oslo" role="option" aria-selected="false" data-slean-value="oslo">Oslo</li>
</ul>
<p data-slean-part="empty" hidden>No city matches</p>
</div>
</div><script lang="ts">
// With @svelte-lean/vite this import is injected for the static data-slean="combobox" marker.
// Without the plugin, write it yourself once; both paths register the same behavior.
import '@svelte-lean/primitives/combobox/register';
import '@svelte-lean/styles/control.css';
import '@svelte-lean/styles/combobox.css';
</script>
<label for="city-input">City</label>
<div id="city" data-slean="combobox">
<input
id="city-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="city-list"
aria-autocomplete="list"
autocomplete="off"
placeholder="Select a city"
data-slean-part="input"
/>
<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
<button
type="button"
tabindex="-1"
aria-label="Show cities"
aria-expanded="false"
data-slean-part="toggle"
></button>
<div id="city-popup" popover="manual" data-slean-part="popup">
<ul id="city-list" role="listbox" aria-label="Cities">
<li id="city-ankara" role="option" aria-selected="false" data-slean-value="ankara">Ankara</li>
<li id="city-berlin" role="option" aria-selected="false" data-slean-value="berlin">Berlin</li>
<li
id="city-cairo"
role="option"
aria-selected="false"
aria-disabled="true"
data-slean-value="cairo"
>
Cairo
</li>
<li id="city-lisbon" role="option" aria-selected="false" data-slean-value="lisbon">Lisbon</li>
<li id="city-oslo" role="option" aria-selected="false" data-slean-value="oslo">Oslo</li>
</ul>
<p data-slean-part="empty" hidden>No city matches</p>
</div>
</div>Last slean:select on the example: No selection yet.. Bound value: empty.
Why this implementation exists
A combobox is a text field first. Typing, the caret, selection, Backspace, Delete, Home, End,
the browser's shortcuts and IME composition are the platform's, and a library that intercepts
them breaks input in languages that compose. The popup is a popover: the browser owns the top
layer and showPopover(), the stylesheet owns placement through CSS anchor
positioning. What ARIA asks for on top is the part Svelte Lean adds: aria-expanded, aria-activedescendant moved by ArrowDown and ArrowUp while focus never leaves the input,
acceptance on Enter and click, Escape, and a filter for a static list. Since ADR 0007 the field is
the shared control shell of the select and the date picker: the whole field is the click target, a
click opens the list, the chosen option is checked and a clear button sits inside the field. Unlike
tabs and menu, an open popup needs to know about a press anywhere on the page, which is a listener
with a lifetime: it exists while one popup is open and not otherwise. That is why the combobox is
Tier 2, and why it is the proof for shipping invariant D of ADR 0005: a page with a hundred combobox
roots allocates nothing until one is used.
The browser owns
- text editing: typing, the caret, selection, Backspace, Delete, Home, End, shortcuts and IME composition
- the popup in the top layer through showPopover() and hidePopover()
- the combobox, listbox and option roles and the aria-activedescendant announcement
- form participation of the input and its value
- placement, through CSS anchor positioning in the stylesheet
Svelte Lean owns
- opening on a click anywhere in the field, on typing, ArrowDown and ArrowUp; closing on Escape, selection, Tab, focus loss and an outside press
- the active option (aria-activedescendant, data-slean-active, aria-selected), the pointer over an option, and the contains and startsWith filters
- the check mark on the option that holds the input’s value (data-slean-selected), every option shown again when the field is reopened on an accepted value
- the clear button and the empty message when the filter leaves no option
- writing aria-expanded, the accepted value and the input event that follows it
- the cancelable slean:select event and the openCombobox(), closeCombobox(), acceptOption() helpers
- the lazy controller: a WeakMap entry on first interaction and one scoped document listener while open
- development validation of the markup
- the field, icons and option rows from control.css, combobox.css and the contract
Usage
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesnpm install --save-dev @svelte-lean/vitepnpm add -D @svelte-lean/viteyarn add -D @svelte-lean/vitebun add -d @svelte-lean/vitenpm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesWith the Vite plugin, every static data-slean="combobox" gets import '@svelte-lean/primitives/combobox/register' appended to its compiled module;
without the plugin, write that import yourself once. The field, the clear button and the
chevron, the popup surface, its placement under the root and the option rows come from control.css, the control shell shared with the select and the date picker; load it
before combobox.css. The clear button reads :placeholder-shown, so give the
input a placeholder (placeholder=" " when there is none).
// Any module that runs once on the client, for example the root layout. Safe on the server,
// idempotent, and the same module the plugin injects.
import '@svelte-lean/primitives/combobox/register';import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
// The field, the icons, the popup and the option rows are the shared control shell.
import '@svelte-lean/styles/control.css';
import '@svelte-lean/styles/combobox.css';Give the root an id: an option without one gets a deterministic id from it the first time it
becomes active, which is what aria-activedescendant needs. Use popover="manual" for the popup, so that nothing can steal focus from the input; the behavior
owns dismissal. The options are one attribute each on the root:
<!-- Match the start of the label; the first match becomes active while typing and Tab accepts it: -->
<div id="city" data-slean="combobox" data-slean-filter="startsWith" data-slean-autoselect>
<!-- ArrowDown and ArrowUp stop at the ends instead of wrapping: -->
<div id="city" data-slean="combobox" data-slean-loop="false">
<!-- A popup toggled with the hidden attribute instead of a popover (browsers without the Popover API): -->
<div id="city-popup" hidden data-slean-part="popup">Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <div id="…" data-slean="combobox"> | – | yes | An id is recommended: options without ids get deterministic ones from it. Options: data-slean-filter, data-slean-autoselect, data-slean-loop. |
| input | <input type="text" role="combobox" aria-expanded="false" aria-controls="<listbox id>" aria-autocomplete="list" autocomplete="off"> | input | yes | Exactly one, with an accessible name (label for, aria-label or aria-labelledby). DOM focus stays here. |
| popup | <div popover="manual"> or <div hidden> | popup | yes | Exactly one, after the input. Contains the role="listbox" with role="option" children (aria-selected="false", author ids). |
| toggle | <button type="button" tabindex="-1" aria-label="…"> | toggle | no | The chevron inside the field; opens or closes the popup and focuses the input. Its aria-expanded is kept in step. |
| clear | <button type="button" tabindex="-1" aria-label="…"> | clear | no | Shown on hover or focus while the input has text (it reads :placeholder-shown, so give the input a placeholder); empties the input and keeps focus. |
| empty | <p hidden> | empty | no | In the popup, outside the listbox. With a built-in filter the behavior shows it when no option matches and the popup stays open; with data-slean-filter="none" the application shows it. |
Runtime profile
The bytes in the runtime block are the production build of @svelte-lean/primitives/combobox/register including the shared kernel, from packages/primitives/artifacts/size.json (2564 B brotli · 2849 B gzip), against 1932 B brotli for the menu. A consumer sees
the same bytes: the combobox-only fixture builds to one Svelte Lean chunk of 2564 B brotli, and that chunk contains no tabs, menu or listbox module.
Seven shared listeners serve every combobox on the page: click, keydown, input, pointerdown, pointerover, focusout and toggle. Per root, the first time its popup opens (a click
in the field, an arrow key, an input event that leaves a match, a popup shown by script) creates
one small object in a WeakMap holding the session; while the popup is open the session holds one AbortController and one document pointerdown listener, which closes the popup on a press outside the root. Closing
aborts the session; focus leaving the root deletes the entry. A root nobody has touched has no
entry, and the number of entries on a page follows the roots that were used, not the roots that
exist. The playground asserts this with a hundred roots in a real browser and with a thousand in
the unit test; the proof page shows the live controller count in a development
build.
Accessibility contract
- Input:
<input type="text">withrole="combobox",aria-expanded(written by the behavior),aria-controlsnaming the listbox,aria-autocomplete="list"(or"none"withdata-slean-filter="none"),autocomplete="off"and an accessible name.aria-activedescendantis written by the behavior. - Popup: a
popover="manual"element or one toggled withhidden, containing onerole="listbox"with a name androle="option"children with ids. The active option carriesaria-selected="true"anddata-slean-active; options the filter hides carryhidden. - Focus: DOM focus stays in the input at all times; the active option is announced through
aria-activedescendant. Opening and closing never move focus away from the input; accepting keeps it there. A press anywhere in the root but the input (an option, the toggle, the clear button, the padding of the field) is prevented from moving focus, so the click still arrives and nofocusoutcloses the popup. - Toggle and clear:
<button type="button" tabindex="-1">with a name; the input is the tab stop. The toggle'saria-expandedfollows the input's. The option that holds the input's value carriesdata-slean-selected(the check mark); it is styling, the selection announced to assistive technology is the input's value. - Disabled:
data-slean-disabledoraria-disabled="true"on the root skips routing; with the nativedisabledon the input the platform delivers no key or input event to it and the behavior ignores clicks on the toggle and the options; an option witharia-disabled="true"is skipped by the arrows and by autoselect and ignores clicks. - IME: keys during a composition (
isComposing, orkeyCode229 where the state is reported late) are ignored entirely, so Enter that commits a candidate never accepts an option. Home, End, Shift and Ctrl with an arrow are never intercepted. - Development validation warns about a missing or wrong input, popup, listbox reference,
autocomplete, name, toggle attributes, options without ids when the root has none, andpopover="auto".
Keyboard
| Key | When | Result |
|---|---|---|
| a character/Backspace/Delete | focus in the input | Native editing; the input event filters the options and opens the popup (with autoselect, the first match becomes active); when the built-in filter leaves no option the empty part is shown, or without one the popup closes until a value matches |
| ArrowDown | focus in the input | Opens on the option that holds the input’s value, else activates the first enabled visible option; then the next, wrapping unless data-slean-loop="false" |
| ArrowUp | focus in the input | Opens on the option that holds the input’s value, else activates the last enabled visible option; then the previous |
| Alt+ArrowDown | popup closed | Opens without an active option |
| Enter | popup open | Accepts the active option (value, slean:select, close); with no active option closes and stays native |
| Escape | focus in the input | Closes the popup; when it is closed, clears the input; otherwise left to ancestors |
| Tab/Shift+Tab | popup open | Accepts the active option with data-slean-autoselect, otherwise closes; focus moves natively |
| Home/End/Shift+Arrow/Ctrl+Arrow | focus in the input | Native caret movement and text selection; never intercepted |
| any key during an IME composition | isComposing or keyCode 229 | Ignored; Enter that commits a candidate never accepts an option |
Keys whose target is not the input part (the toggle, content inside the popup) are ignored. Alt+ArrowUp is not handled. Vertical only: ArrowLeft and ArrowRight move the caret, and there is nothing to flip under RTL.
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
Popover API: popover, showPopover(), hidePopover(), :popover-open, ToggleEvent | Newly available since April 2024 (Chrome 114, Safari 17, Firefox 125) | The attribute is ignored and the popup renders inline; use a hidden popup there |
showPopover({ source }) | Chrome 133 and Safari 26; not Baseline | Passed as an option, so browsers without it ignore the argument |
CSS anchor positioning: anchor-name, anchor-scope, position-anchor, position-area, anchor-size() | Chrome 125 (anchor-scope 131) and Safari 26; not Baseline at the time of writing | A hidden-toggled popup is positioned under the root with absolute positioning; a popover popup is centered in the top layer |
AbortSignal on addEventListener, Element.isConnected, scrollIntoView({ block }) | Widely available | Not applicable within the support policy |
ARIA combobox roles and properties (combobox, aria-activedescendant, aria-autocomplete, aria-expanded, aria-controls, listbox, option, aria-selected) | ARIA 1.2, widely supported by assistive technology | Not applicable within the support policy |
KeyboardEvent.isComposing | Widely available; keyCode 229 covers browsers that report the composition state late | Not applicable within the support policy |
Shared router: composedPath(), getAttribute, ES2022 output | Widely available | No transpilation to an older target is provided |
Without JavaScript
The input is a plain text field: the user types a value and the form submits it. @media (scripting: none) in combobox.css hides the toggle and the
clear button, which could do nothing; the popup stays closed and the options are not reachable.
Authors who need the list without script render it inline (no popover, no hidden) or use a native <datalist>. The playground's
delayed-hydration test types into the input before any script has arrived and reads the value
back.
Server rendering
Static HTML: the input renders with its value, aria-expanded="false" and no aria-activedescendant; the popup is closed; every option is visible. No ids are
generated on the server and none at hydration. The register module is safe to import on the
server (packages/primitives/tests/ssr.test.ts); the playground's SSR page renders a
combobox and asserts the server HTML.
Before hydration
Typing works before any script because the input is native. The popup, the arrows and the filter need the register module, which evaluates before the component code once the scripts arrive. Hydration attaches nothing to a root: the seven listeners are on the document, and the controller for a root exists only after its first interaction.
Styling
control.css draws the field: the border, hover, the focus halo, sizes (data-size), status (data-status) and a disabled input; the clear button and the chevron as
icon masks from the --slean-icon-* tokens; the popup surface with its open
transition; the option rows, the active option, the check mark of the chosen option, the
disabled options and the empty message. The popup, popover or hidden-toggled, is placed under
the root with CSS anchor positioning: the root carries an anchor name scoped to its own subtree,
so many fields on a page do not interfere, and it stays unpositioned, because the anchor rules
exclude the containing block of the positioned element. Without anchor positioning a
hidden-toggled popup is positioned under the root with absolute positioning and a popover popup
keeps the platform's centered placement. combobox.css only hides the buttons without
scripting. The tokens of both files and the states they target:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-1 | 0.25rem | gap, padding-inline, margin-block, padding, scroll-padding-block, padding-block |
--slean-control-height-md | 2.25rem | min-block-size |
--slean-space-3 | 0.75rem | padding-inline |
--slean-space-2 | 0.5rem | padding-inline, max-inline-size, gap, padding-block |
--slean-control-border | var(--slean-border-strong) | border |
--slean-radius-md | 0.625rem | border-radius |
--slean-control-bg | var(--slean-surface) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-leading | 1.5 | line-height |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-control-border-hover | var(--slean-accent) | border-color |
--slean-control-border-focus | var(--slean-accent) | border-color |
--slean-control-ring | 0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent) | box-shadow |
--slean-danger | oklch(55% 0.2 25) | border-color |
--slean-control-ring-danger | 0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent) | box-shadow |
--slean-warning | oklch(76% 0.16 80) | border-color |
--slean-control-ring-warning | 0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent) | box-shadow |
--slean-control-border-disabled | var(--slean-border) | border-color |
--slean-control-bg-disabled | var(--slean-muted) | background |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-control-height-sm | 2rem | min-block-size |
--slean-radius-sm | 0.375rem | border-radius |
--slean-control-height-lg | 2.75rem | min-block-size |
--slean-space-4 | 1rem | padding-inline, padding-block |
--slean-text-md | 1rem | font-size |
--slean-control-icon | var(--slean-fg-muted) | color |
--slean-icon-chevron-down | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-duration-normal | 160ms | transition |
--slean-icon-clear | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cdefs%3E%3Cmask id='m'%3E%3Crect width='16' height='16' fill='white'/%3E%3Cpath d='M6 6l4 4m0-4l-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3C/mask%3E%3C/defs%3E%3Ccircle cx='8' cy='8' r='6.5' mask='url(%23m)'/%3E%3C/svg%3E") | mask-image |
--slean-border | var(--slean-neutral-6) | border |
--slean-popup-radius | var(--slean-radius-md) | border-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-popup-shadow | var(--slean-shadow-lg) | box-shadow |
--slean-icon-check | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-option-active | var(--slean-muted) | background |
--slean-option-selected | var(--slean-accent-soft) | background |
--slean-option-selected-fg | var(--slean-accent-soft-fg) | color, box-shadow |
--slean-font-weight-semibold | 600 | font-weight |
--slean-text-xs | 0.75rem | font-size |
--slean-font-weight-medium | 500 | font-weight |
State selectors the stylesheet targets, all from the platform or ARIA: ::placeholder, :disabled, :empty, :focus-visible, :focus-within, :hover, :placeholder-shown, :popover-open, :user-invalid, [aria-disabled="true"], [aria-expanded="true"], [aria-invalid="true"], [hidden], [popover], [role="listbox"], [role="option"].
Variant attributes: data-status (error, warning); data-size (sm, lg); data-slean-active; data-slean-selected.
Controlled integration
What exists today: the root dispatches a bubbling, cancelable slean:select CustomEvent before the input is written, with detail of type ComboboxSelectDetail (value, label, option); preventDefault() keeps the input and the popup as they are.
After writing, the behavior dispatches a bubbling input event on the field, so bind:value sees the accepted label; Escape clearing the input does the same. openCombobox(), closeCombobox(), acceptOption(), filterOptions(), activeOption() and isComboboxOpen() from @svelte-lean/primitives/combobox are the helpers. There are no slean:open or slean:close events: a popover popup dispatches the
native toggle event, and aria-expanded can be observed.
<script lang="ts">
import { closeCombobox, type ComboboxSelectDetail } from '@svelte-lean/primitives/combobox';
let root: HTMLElement;
// The behavior dispatches a bubbling input event after it writes the field (an accepted
// option, Escape clearing), so bind:value follows the accepted label.
let city = $state('');
let selected = $state<string | null>(null);
$effect(() => {
// slean:select bubbles from the root before the input is written; detail has the option's
// value, its label and the element. preventDefault() keeps the input and the popup as they are.
const onSelect = (event: Event) => {
const { value } = (event as CustomEvent<ComboboxSelectDetail>).detail;
selected = value;
};
root.addEventListener('slean:select', onSelect);
return () => root.removeEventListener('slean:select', onSelect);
});
</script>
<label for="city-input">City</label>
<div id="city" data-slean="combobox" bind:this={root}>
<input id="city-input" … data-slean-part="input" bind:value={city} />
…
</div>
<p>Typed: {city}; accepted: {selected ?? 'none'}</p>
<button type="button" onclick={() => closeCombobox(root)}>Close the popup from outside</button>For remote or computed lists, data-slean-filter="none" leaves the options to the
application: it listens to input, renders what it wants, and the behavior navigates
and accepts what is in the DOM when a key arrives. Options are read on every event and never
cached, so a list that changes between two keys cannot leave the controller pointing at a stale
index.
<script lang="ts">
// data-slean-filter="none": the application renders the options; the behavior navigates and
// accepts whatever is in the DOM when a key arrives. Debouncing, requests and the empty
// state are the application's.
let results = $state<{ id: string; name: string }[]>([]);
let pending = $state(false);
let timer: ReturnType<typeof setTimeout>;
function search(event: Event) {
const query = (event.currentTarget as HTMLInputElement).value;
clearTimeout(timer);
pending = true;
timer = setTimeout(async () => {
results = await fetchCities(query);
pending = false;
}, 250);
}
</script>
<label for="city-input">City</label>
<div id="city" data-slean="combobox" data-slean-filter="none">
<input
id="city-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="city-list"
aria-autocomplete="list"
autocomplete="off"
placeholder="Search cities"
data-slean-part="input"
oninput={search}
/>
<div id="city-popup" popover="manual" data-slean-part="popup">
<ul id="city-list" role="listbox" aria-label="Cities" aria-busy={pending}>
{#each results as city (city.id)}
<li id="city-{city.id}" role="option" aria-selected="false" data-slean-value={city.id}>
{city.name}
</li>
{/each}
</ul>
{#if results.length === 0 && !pending}
<p data-slean-part="empty">No city matches.</p>
{/if}
</div>
</div>Note A Svelte adapter is not built. No filtering engine for remote data, no debouncing, no virtualization, no multi-select combobox (for chosen tags without search, the multiple Select), no inline autocomplete and no positioning engine exist; each would be documented in the contract before it is built.
Compatibility notes
The combobox depends on the Popover API (Baseline newly available since April 2024) for a
popover popup, with a hidden-toggled popup as the documented path for browsers
without it, and on CSS anchor positioning (Chrome 125, Safari 26; not Baseline) for placement,
with the fallbacks above. showPopover({ source }) is passed as an option
that browsers without it ignore. The behavior needs ES2022 output, composedPath(), AbortSignal on addEventListener and KeyboardEvent.isComposing. Tested in Chromium, including IME composition through
the DevTools protocol; Firefox and WebKit runs do not exist yet, and whether a cancelled pointerdown keeps focus in the input there is not measured. The policy is on Browser support.
Examples
Command palette
A command palette is a composition, not a primitive: a native modal dialog holding a combobox
whose popup is toggled with hidden, so the list stays inside the dialog instead of
opening another top-layer element. The dialog owns the top layer, the backdrop, Escape and focus
return; the combobox owns filtering and the active option; slean:select runs the command
and closes the dialog.
No command run yet.
<script lang="ts">
// A composition: a native modal dialog holding a combobox with a hidden-toggled popup.
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/dialog.css';
import '@svelte-lean/styles/control.css';
import '@svelte-lean/styles/combobox.css';
import type { ComboboxSelectDetail } from '@svelte-lean/primitives/combobox';
import { on } from 'svelte/events';
let dialog: HTMLDialogElement;
const commands = [
['new', 'New file'],
['open', 'Open recent'],
['theme', 'Toggle theme'],
['settings', 'Settings']
];
function run(event: Event) {
const { value } = (event as CustomEvent<ComboboxSelectDetail>).detail;
dialog.close(value); // the dialog returns focus to the button that opened it
}
</script>
<button type="button" commandfor="palette" command="show-modal">Command palette</button>
<dialog id="palette" bind:this={dialog} data-slean="dialog" aria-label="Command palette">
<div data-slean="combobox" data-slean-autoselect {@attach (node) => on(node, 'slean:select', run)}>
<input type="text" role="combobox" aria-label="Command" aria-expanded="false"
aria-controls="palette-list" aria-autocomplete="list" autocomplete="off"
data-slean-part="input" />
<div hidden data-slean-part="popup">
<ul id="palette-list" role="listbox" aria-label="Commands">
{#each commands as [value, label] (value)}
<li id="palette-{value}" role="option" aria-selected="false" data-slean-value={value}>
{label}
</li>
{/each}
</ul>
</div>
</div>
</dialog>Testing
packages/primitives/tests/combobox.test.tsunit tests (happy-dom with a Popover API shim): lazy controller with 1000 roots, native editing never prevented, keyboard, filters, pointer, popup events, validationapps/playground/tests/primitives/combobox.spec.tsPlaywright: native typing and caret, the real popover in the top layer, anchor placement, aria-activedescendant, filters, autoselect, an asynchronous list, a modal dialog, IME composition over the DevTools protocolapps/playground/tests/primitives/lazy.spec.ts100 roots: no controller after load, one per interacted root, released on close and focus loss; the same shared listeners for 1 and 100 rootsapps/playground/tests/primitives/nested.spec.tsa combobox inside a modal dialogapps/playground/tests/stress/memory.spec.ts200 open/close cycles: heap, DOM nodes and listeners back at baselinefixtures/combobox-onlyconsumer build: only the combobox registration and lazyState ship; tabs, menu and listbox are absentapps/playground/tests/primitives/a11y.spec.tsaxe on every playground page and on open dialog, popover and menu states
Source
packages/primitives/src/combobox/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/combobox/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/combobox/behavior.tsthe behavior definition, comboboxStates, openCombobox(), closeCombobox(), acceptOption(), filterOptions()packages/primitives/src/combobox/register.tsthe registration module, the only side effectpackages/primitives/src/combobox/validate.tsdevelopment validation messagespackages/styles/css/combobox.cssthe optional stylesheet