sveltelean Primitives
Versionv0.2.0 GitHub

Example

Single select
  • Apple
  • Banana
  • Cherry
  • Date
<ul id="fruit" role="listbox" aria-label="Fruit" data-slean="listbox">
	<li id="fruit-apple" role="option" aria-selected="true" tabindex="0" data-slean-value="apple">
		Apple
	</li>
	<li id="fruit-banana" role="option" aria-selected="false" tabindex="-1" data-slean-value="banana">
		Banana
	</li>
	<li
		id="fruit-cherry"
		role="option"
		aria-selected="false"
		aria-disabled="true"
		tabindex="-1"
		data-slean-value="cherry"
	>
		Cherry
	</li>
	<li id="fruit-date" role="option" aria-selected="false" tabindex="-1" data-slean-value="date">
		Date
	</li>
</ul>
Tab enters the list on the selected option. ArrowDown and ArrowUp select as they move (automatic mode), Home and End jump, typing a letter searches, a click selects. Cherry is aria-disabled and skipped.
Multi-select
  • Cheese
  • Olives
  • Peppers
<div id="toppings" data-slean="listbox" data-slean-multiple>
	<ul role="listbox" aria-label="Toppings" aria-multiselectable="true" data-slean-part="list">
		<li id="toppings-cheese" role="option" aria-selected="true" tabindex="0" data-slean-value="cheese">
			Cheese
		</li>
		<li id="toppings-olives" role="option" aria-selected="false" tabindex="-1" data-slean-value="olives">
			Olives
		</li>
		<li id="toppings-peppers" role="option" aria-selected="false" tabindex="-1" data-slean-value="peppers">
			Peppers
		</li>
	</ul>
</div>
Arrow keys move focus only. Space, Enter or a click toggles the focused option; Shift+ArrowDown and Shift+ArrowUp extend the selection; Ctrl+A (Cmd+A) selects every enabled option. The root is a wrapper around the list part here.

Last slean:change on the examples: No change yet.

Multi-select in a popover

A multi-select dropdown is a composition: a button with popovertarget and a popover holding a multi-select listbox. The popover owns showing, light dismiss, Escape and focus return; the listbox owns the keys and the selection; the button's label follows slean:change.

  • Open
  • In review
  • Done
  • Archived

Why this implementation exists

For one choice inside a form the recommended primitive is the native <select>: the browser owns the popup, the keyboard and form participation, and the package owns nothing. A listbox is for what a select does not do: a list that is visible the whole time, several selected options with Shift and Ctrl keys, options with more than a text label. ARIA gives the roles and the states for that, and the browser gives focus and the tab order; what is missing is the movement between options, the roving tab stop, typeahead and the selection rules of the APG pattern. Svelte Lean adds only that, as one behavior shared by every listbox on the page. The selection is never mirrored in JavaScript: every event reads aria-selected from the DOM and writes it back, so a listbox the application re-renders cannot disagree with the behavior.

The browser owns

  • the tab order: Tab enters the list on the option with tabindex="0"
  • the listbox and option roles, aria-selected and aria-multiselectable announcements
  • the click and keydown events routed to the behavior
  • scrolling the focused option into view

Svelte Lean owns

  • selection on click, Space and Enter; arrow keys, Home and End; typeahead; automatic and manual modes
  • multi-select toggling, Shift+Arrow extension and Ctrl+A
  • writing aria-selected and the roving tabindex
  • the cancelable slean:change event and selectOptions(), listboxOptions(), listboxValue()
  • development validation of the markup
  • listbox.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="listbox" gets import '@svelte-lean/primitives/listbox/register' appended to its compiled module; without the plugin, write that import yourself once. The stylesheet stands alone: it draws the list, the rows, the selected state, the multi-select mark and its own focus ring.

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/listbox/register';
app.css or +layout.svelte
import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/listbox.css';

The author renders the initial state completely: aria-selected on every option and exactly one tabindex="0". The options are one attribute each on the root:

data-slean-selection, data-slean-orientation, data-slean-loop
<!-- Arrow keys move focus only; Space, Enter or a click select: -->
<ul role="listbox" aria-label="Fruit" data-slean="listbox" data-slean-selection="manual">

<!-- A row of options, ArrowRight and ArrowLeft (swapped under dir="rtl"), no wrap at the ends: -->
<ul
	role="listbox"
	aria-label="Size"
	aria-orientation="horizontal"
	data-slean="listbox"
	data-slean-orientation="horizontal"
	data-slean-loop="false"
>

Anatomy

PartElementdata-slean-partRequiredNotes
root<ul role="listbox" aria-label="…" data-slean="listbox"> or a wrapper element–yesThe listbox element itself, or an element around the list part. Options: data-slean-multiple, data-slean-selection, data-slean-orientation, data-slean-loop.
list<ul role="listbox" aria-label="…">listnoOnly when the root is a wrapper; exactly one. Set aria-multiselectable="true" with the multiple option and aria-orientation="horizontal" with the orientation option.
option<li role="option" aria-selected="…" tabindex="0 or -1" data-slean-value="…">–yesFound by role, no part attribute. Exactly one option has tabindex="0"; disabled options carry aria-disabled="true" or data-slean-disabled.
group<li role="group" aria-label="…">–noA section of options.
label<span>labelstyles onlyA group heading.

Runtime profile

The bytes in the runtime block are the production build of @svelte-lean/primitives/listbox/register including the shared kernel, from packages/primitives/artifacts/size.json (1838 B brotli · 2030 B gzip). A consumer sees the same bytes: the listbox-only fixture builds to one Svelte Lean chunk of 1838 B brotli, against 1521 B brotli for tabs alone. The two listeners (click and keydown) serve every listbox on the page; one typeahead buffer exists for the whole page and is created on first use. There is no per-root state, no WeakMap, no observer and no layout read; the unit test asserts that the behavior source contains none of them (no WeakMap, WeakSet or lazyState, no observer constructor, no getBoundingClientRect, offsetWidth, offsetHeight or getComputedStyle), and the proof page shows the listener account of this site.

Accessibility contract

  • Root: the role="listbox" element with a name (aria-label or aria-labelledby), or a wrapper around a data-slean-part="list" with that role. aria-multiselectable="true" goes with data-slean-multiple, aria-orientation="horizontal" with data-slean-orientation="horizontal"; development validation checks that they agree.
  • Options are role="option" elements with data-slean-value, aria-selected and a tabindex; any element works, and a <button> is not needed. Ids are authored; the behavior generates none.
  • Focus: a roving tab stop. After every move or click exactly one option has tabindex="0"; a click focuses the option: the behavior calls focus() on it instead of relying on the browser's mouse-focus rules, which differ by element and browser and are not measured here. In manual single-select mode and in multi-select mode, focus moves without changing the selection until Space, Enter or a click.
  • Disabled: data-slean-disabled or aria-disabled="true" on the root skips routing. An option with aria-disabled="true" or data-slean-disabled is skipped by arrows, Home, End, typeahead and Ctrl+A, and its clicks, Space and Enter are ignored; a disabled option the author rendered as selected keeps its state.
  • RTL: direction is read from the nearest dir attribute; dir="auto" is resolved through :dir(rtl). Under RTL with horizontal orientation, ArrowRight moves to the previous option, the one on the right.
  • When a slean:change listener cancels, the selection stays; keyboard focus and the tab stop still move to the requested option.

Keyboard

KeyWhenResult
Tab/Shift+TabanywhereEnters the list on the tabindex="0" option (native)
ArrowDown/ArrowUpfocus on an option, verticalNext or previous enabled option, wrapping unless data-slean-loop="false"; selects it in automatic single-select mode, moves focus only otherwise
ArrowRight/ArrowLeftfocus on an option, horizontalAs above; swapped under dir="rtl"
Home/Endfocus on an optionFirst or last enabled option; selects it in automatic single-select mode
a characterfocus on an optionTypeahead over option text; a repeated character cycles; selects in automatic single-select mode
Space/Enterfocus on an optionSelects it (single-select, the way to select in manual mode) or toggles it (multi-select)
Shift+ArrowDown/Shift+ArrowUpfocus on an option, multi-selectMoves and adds the focused option and the target to the selection
Ctrl+A/Cmd+Afocus on an option, multi-selectSelects every enabled option

Keys with Alt, Ctrl or Meta are otherwise ignored so browser shortcuts keep working; handled keys call preventDefault(). Shift with Home, End or a character moves focus only. The APG's optional Shift+Space, Ctrl+Shift+Home, Ctrl+Shift+End and Shift+click ranges are not implemented; the contract says so.

Platform features

FeatureBaselineOutside the target
ARIA listbox roles and states (listbox, option, group, aria-selected, aria-multiselectable, aria-orientation, aria-disabled)ARIA 1.2, widely supported by assistive technologyNot applicable within the support policy
Shared router: composedPath(), getAttribute, ES2022 outputWidely availableNo transpilation to an older target is provided
:dir(rtl), only to resolve dir="auto"Newly available since December 2023 (Chrome 120, Safari 16.4, Firefox 49)Guarded in packages/primitives/src/internal/direction.ts: dir="auto" resolves to left-to-right; explicit dir attributes work everywhere

Without JavaScript

The authored markup stays: the selected options are marked and readable, and nothing can be changed. A form that must work without script uses a native <select> or checkboxes; the listbox submits no value by itself, with or without script.

Server rendering

Everything is static markup; the server renders the selection and the tab stop, and no ids are generated. The register module is safe to import on the server: without a document the registration waits until a browser runtime exists (packages/primitives/tests/ssr.test.ts). The playground's SSR page renders a listbox and asserts that hydration changes nothing.

Before hydration

Nothing runs at hydration: the shared listeners were installed when the behavior registered, and the first interaction is the first work done for a root. Before the register module has loaded, a click on an option changes nothing, as the playground's delayed-hydration test asserts; after it, the same click selects.

Styling

listbox.css styles the list and its rows, the selected state, the hover state, the disabled state, a group heading, and the multi-select mark drawn with borders. Options are focused directly, so the file draws its own inset focus ring for :focus-visible; the shared ring in base.css covers only parts. The tokens it reads and the states it targets:

TokenDefault (light)Applies to
--slean-space-10.25rempadding, gap, padding-block
--slean-bordervar(--slean-neutral-6)border
--slean-radius-md0.625remborder-radius
--slean-surfaceoklch(100% 0 0)background
--slean-fgvar(--slean-neutral-12)color
--slean-space-20.5remgap, padding-inline, padding-inline-start, inset-inline-start
--slean-control-height-sm2remmin-block-size
--slean-radius-sm0.375remborder-radius
--slean-text-sm0.875remfont-size
--slean-mutedvar(--slean-neutral-3)background
--slean-option-activevar(--slean-muted)background
--slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
--slean-focus-ring-width2pxoutline-offset
--slean-accent-softoklch(95% 0.03 258)background
--slean-accent-soft-fgoklch(42% 0.17 258)color, box-shadow
--slean-accentoklch(54% 0.19 258)background
--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-fg-mutedvar(--slean-neutral-11)color
--slean-text-xs0.75remfont-size
--slean-font-weight-medium500font-weight

State selectors the stylesheet targets, all from the platform or ARIA: :focus-visible, :hover, [aria-disabled="true"], [aria-selected="true"], [role="group"], [role="listbox"], [role="option"].

Variant attributes: data-slean-orientation (horizontal); data-slean-multiple.

Controlled integration

What exists today: the root dispatches a bubbling, cancelable slean:change CustomEvent before the selection is written, with detail of type ListboxChangeDetail: in a single-select root value is the selected data-slean-value and previous the one before or null; in a multi-select root both are arrays in document order. preventDefault() cancels the change. selectOptions() writes a selection from script, listboxOptions() and listboxValue() read the DOM.

toppings.svelte
<script lang="ts">
	import {
		listboxValue,
		selectOptions,
		listboxOptions,
		type ListboxChangeDetail
	} from '@svelte-lean/primitives/listbox';

	let root: HTMLElement;
	let selected = $state<string[]>(['cheese']);

	$effect(() => {
		// slean:change bubbles from the root before aria-selected is written; in a multi-select
		// root value and previous are arrays in document order. preventDefault() cancels.
		const onChange = (event: Event) => {
			const { value } = (event as CustomEvent<ListboxChangeDetail>).detail;
			selected = Array.isArray(value) ? value : [value];
		};
		root.addEventListener('slean:change', onChange);
		return () => root.removeEventListener('slean:change', onChange);
	});

	function clear() {
		// Pass every option that should stay selected; returns false when a listener cancelled.
		selectOptions(root, []);
		selected = listboxValue(root);
	}

	function selectAll() {
		selectOptions(root, listboxOptions(root));
	}
</script>

<div id="toppings" data-slean="listbox" data-slean-multiple bind:this={root}>
	…
</div>
<p>Selected: {selected.join(', ') || 'nothing'}</p>
<button type="button" onclick={clear}>Clear</button>
<button type="button" onclick={selectAll}>Select all</button>

Note A Svelte adapter is not built. No hidden form field, no virtualization, no drag selection and no Shift+click or Ctrl+click ranges exist; each would be documented in the contract before it is built.

Compatibility notes

The listbox depends on ARIA 1.2 roles, ES2022 output and composedPath(), and reads :dir(rtl) only to resolve dir="auto", with a guard where the pseudo-class is missing. Tested in Chromium; Firefox and WebKit runs do not exist yet. The policy is on Browser support.

Testing

Source