sveltelean Primitives
Versionv0.2.0 GitHub

Example

City
  • Ankara
  • Berlin
  • Cairo
  • Lisbon
  • Oslo
<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>
Click the field to open the list, type to filter (contains, case-insensitive), move with ArrowDown and ArrowUp or the pointer, and accept with Enter or a click. Reopening shows every city again with a check mark on the chosen one. Cairo is aria-disabled and skipped; a text that matches nothing shows the empty message; the clear button empties the field.

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/primitives
npm install --save-dev @svelte-lean/vite
npm install @svelte-lean/styles

With 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).

the manual escape hatch
// 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';
app.css or +layout.svelte
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:

data-slean-filter, data-slean-autoselect, data-slean-loop
<!-- 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

PartElementdata-slean-partRequiredNotes
root<div id="…" data-slean="combobox">–yesAn 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">inputyesExactly one, with an accessible name (label for, aria-label or aria-labelledby). DOM focus stays here.
popup<div popover="manual"> or <div hidden>popupyesExactly 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="…">togglenoThe 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="…">clearnoShown 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>emptynoIn 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"> with role="combobox", aria-expanded (written by the behavior), aria-controls naming the listbox, aria-autocomplete="list" (or "none" with data-slean-filter="none"), autocomplete="off" and an accessible name. aria-activedescendant is written by the behavior.
  • Popup: a popover="manual" element or one toggled with hidden, containing one role="listbox" with a name and role="option" children with ids. The active option carries aria-selected="true" and data-slean-active; options the filter hides carry hidden.
  • 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 no focusout closes the popup.
  • Toggle and clear: <button type="button" tabindex="-1"> with a name; the input is the tab stop. The toggle's aria-expanded follows the input's. The option that holds the input's value carries data-slean-selected (the check mark); it is styling, the selection announced to assistive technology is the input's value.
  • Disabled: data-slean-disabled or aria-disabled="true" on the root skips routing; with the native disabled on 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 with aria-disabled="true" is skipped by the arrows and by autoselect and ignores clicks.
  • IME: keys during a composition (isComposing, or keyCode 229 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, and popover="auto".

Keyboard

KeyWhenResult
a character/Backspace/Deletefocus in the inputNative 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
ArrowDownfocus in the inputOpens 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"
ArrowUpfocus in the inputOpens on the option that holds the input’s value, else activates the last enabled visible option; then the previous
Alt+ArrowDownpopup closedOpens without an active option
Enterpopup openAccepts the active option (value, slean:select, close); with no active option closes and stays native
Escapefocus in the inputCloses the popup; when it is closed, clears the input; otherwise left to ancestors
Tab/Shift+Tabpopup openAccepts the active option with data-slean-autoselect, otherwise closes; focus moves natively
Home/End/Shift+Arrow/Ctrl+Arrowfocus in the inputNative caret movement and text selection; never intercepted
any key during an IME compositionisComposing or keyCode 229Ignored; 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

FeatureBaselineOutside the target
Popover API: popover, showPopover(), hidePopover(), :popover-open, ToggleEventNewly 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 BaselinePassed 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 writingA 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 availableNot 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 technologyNot applicable within the support policy
KeyboardEvent.isComposingWidely available; keyCode 229 covers browsers that report the composition state lateNot applicable within the support policy
Shared router: composedPath(), getAttribute, ES2022 outputWidely availableNo 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:

TokenDefault (light)Applies to
--slean-space-10.25remgap, padding-inline, margin-block, padding, scroll-padding-block, padding-block
--slean-control-height-md2.25remmin-block-size
--slean-space-30.75rempadding-inline
--slean-space-20.5rempadding-inline, max-inline-size, gap, padding-block
--slean-control-bordervar(--slean-border-strong)border
--slean-radius-md0.625remborder-radius
--slean-control-bgvar(--slean-surface)background
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-leading1.5line-height
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-control-border-hovervar(--slean-accent)border-color
--slean-control-border-focusvar(--slean-accent)border-color
--slean-control-ring0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent)box-shadow
--slean-dangeroklch(55% 0.2 25)border-color
--slean-control-ring-danger0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent)box-shadow
--slean-warningoklch(76% 0.16 80)border-color
--slean-control-ring-warning0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent)box-shadow
--slean-control-border-disabledvar(--slean-border)border-color
--slean-control-bg-disabledvar(--slean-muted)background
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-control-height-sm2remmin-block-size
--slean-radius-sm0.375remborder-radius
--slean-control-height-lg2.75remmin-block-size
--slean-space-41rempadding-inline, padding-block
--slean-text-md1remfont-size
--slean-control-iconvar(--slean-fg-muted)color
--slean-icon-chevron-downurl("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-normal160mstransition
--slean-icon-clearurl("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-bordervar(--slean-neutral-6)border
--slean-popup-radiusvar(--slean-radius-md)border-radius
--slean-surfaceoklch(100% 0 0)background
--slean-popup-shadowvar(--slean-shadow-lg)box-shadow
--slean-icon-checkurl("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-activevar(--slean-muted)background
--slean-option-selectedvar(--slean-accent-soft)background
--slean-option-selected-fgvar(--slean-accent-soft-fg)color, box-shadow
--slean-font-weight-semibold600font-weight
--slean-text-xs0.75remfont-size
--slean-font-weight-medium500font-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.

city.svelte
<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.

remote-city.svelte
<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.

palette.svelte
<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

Source