sveltelean Primitives
Versionv0.2.0 GitHub

Example

A formatting toolbar
Keys
<div data-slean="toolbar" role="toolbar" aria-label="Formatting">
	<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false">Bold</button>
	<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false">Italic</button>
	<div role="separator" aria-orientation="vertical"></div>
	<div
		data-slean="toggle-group"
		role="group"
		aria-label="Alignment"
		data-slean-type="single"
		data-slean-required
	>
		<button type="button" data-slean-part="item" data-slean-value="left" aria-pressed="true">
			Left
		</button>
		<button type="button" data-slean-part="item" data-slean-value="center" aria-pressed="false">
			Center
		</button>
		<button type="button" data-slean-part="item" data-slean-value="right" aria-pressed="false">
			Right
		</button>
	</div>
	<div role="separator" aria-orientation="vertical"></div>
	<button type="button" data-slean="button" data-variant="ghost" data-size="sm">Clear</button>
	<a href="/primitives/toolbar#keyboard" data-slean="button" data-variant="link">Keys</a>
	<select data-slean="select" data-size="sm" aria-label="Font size">
		<option>12</option>
		<option selected>14</option>
		<option>16</option>
	</select>
</div>
Tab into the toolbar, then use the arrow keys, Home and End: they cross the toggle group and stop at the select, which keeps its own keys and its own tab stop. Tab again to leave.

Why this implementation exists

role="toolbar" tells assistive technology that a row of controls belongs together, but the platform gives it no behavior: every button is a separate tab stop. The WAI-ARIA toolbar pattern makes the row one tab stop with arrow keys between the controls, so a keyboard user passes a toolbar with one Tab.

The toolbar owns focus movement and nothing else. It finds its controls in the DOM (buttons, links, checkboxes, toggles, toggle group items), writes tabindex on them and moves focus. A toggle still toggles, a link still navigates, and a toggle group inside hands its arrow keys to the toolbar so the arrows can leave it.

A control that uses the arrow keys itself, such as a text field, a select or a slider, keeps them and keeps its own tab stop. Moving the toolbar’s tab stop onto such a control would leave no key to return to the other controls.

The browser owns

  • every control’s own behavior: buttons, links, checkboxes, selects and text fields
  • Tab and Shift+Tab into and out of the toolbar
  • announcing the toolbar name and orientation (screen readers)
  • the keydown and focusin events routed to the behavior

Svelte Lean owns

  • one tab stop across the toolbar’s controls (roving tabindex), set on the first focus
  • arrow keys by orientation, Home and End, RTL, skipping disabled controls
  • leaving the keys of text fields, selects and nested composite widgets alone
  • toolbarControls(), development validation
  • toolbar.css: the bar, separators, a flattened toggle group

Usage

Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the registration is injected for every static data-slean="toolbar"; without it, import the register module once.

npm install @svelte-lean/primitives
+layout.svelte or any client module
import '@svelte-lean/primitives/toolbar/register';
stylesheets
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/toggle.css';
import '@svelte-lean/styles/toggle-group.css';
import '@svelte-lean/styles/toolbar.css';

Wrap the controls in role="toolbar" with aria-label or aria-labelledby. For a vertical toolbar set data-slean-orientation="vertical" and aria-orientation="vertical" together.

Controls need no marker: the toolbar uses <button>, <a href>, checkbox and button-like inputs and elements with tabindex. Content in a popover, a dialog, a hidden or an inert element inside the toolbar is not part of it, so a menu opened from a toolbar button keeps its own keys.

Following WAI-ARIA, keep text fields and selects few and at the end of the toolbar. toolbarControls(root) from @svelte-lean/primitives/toolbar lists the controls the arrow keys move between.

Anatomy

PartElementdata-slean-partRequiredNotes
root<div data-slean="toolbar" role="toolbar" aria-label="…">–yesOptions: data-slean-orientation (horizontal, vertical; set aria-orientation too), -loop. The controls are found, not marked.
controls<button>, <a href>, <input type="checkbox">, toggles, toggle-group items, [tabindex]–yesThe roving controls, in document order. Disabled ones are skipped by the keys.
separate controls<select>, text <input>, <textarea>, sliders, radios, composite widgets–noKeep their keys and their own tab stop. Content in a popover, dialog, hidden or inert element is not part of the toolbar.
separator<div role="separator">–styles onlyA rule across the bar between groups of controls.

Runtime profile

The toolbar registers one keydown and one focusin handler with the shared router; the toggles inside add the shared click handler. A thousand toolbars keep one listener per type (tests/toolbar.test.ts). Its bytes, in the runtime block, are the production registration with the core kernel.

Accessibility contract

  • role="toolbar" with a name; aria-orientation="vertical" for a vertical toolbar, which development validation checks against data-slean-orientation.
  • One control is in the tab order. Focus by click, by Tab or returned by a closing popover makes that control the tab stop, so Tab comes back to where the user was.
  • The arrow keys, Home and End skip disabled and aria-disabled controls. Enter and Space are each control’s own activation.
  • A text field keeps ArrowLeft, ArrowRight, Home and End for its caret; a select and a slider keep their keys. Keys a nested widget already handled, such as a menu, are left to it.
  • role="separator" between groups of controls is drawn by the stylesheet and is not focusable.

Keyboard

KeyWhenResult
Tab/Shift+Tabentering or leavingEnters on the tab stop, then leaves the toolbar (native)
ArrowRight/ArrowLefthorizontal, focus on a controlNext or previous enabled control, looping; swapped under dir="rtl"
ArrowDown/ArrowUpvertical, focus on a controlNext or previous enabled control
Home/Endfocus on a controlFirst or last enabled control
Enter/Spacefocus on a controlThe control’s own activation (native)
ArrowLeft/ArrowRight/Home/Endfocus in a text field or selectLeft to the field: caret movement or the select’s own keys

Platform features

FeatureBaselineOutside the target
role="toolbar", aria-orientationARIA 1.2, announced by screen readersNot applicable
focusinWidely availableNot applicable
:dir()Baseline 2023dir="auto" resolves to left-to-right; explicit dir attributes work

Without JavaScript

Every control works on its own: buttons, links, checkboxes and selects are native. Without an authored tabindex every control is a tab stop and the arrow keys do nothing; a toggle inside does not change.

Server rendering

Render the controls with or without the roving tabindex (see Tab order in the markup). No ids are generated, and the register module is safe to import on the server.

Before hydration

Before the behavior loads, every control without an authored tabindex is a tab stop and the arrow keys scroll. The first focus after hydration sets the tab stop; nothing runs before it.

Styling

toolbar.css lays out the bar on one surface, draws role="separator" as a rule across it, and flattens a nested toggle group and unpressed toggles into the bar. The controls bring their own stylesheets; load toolbar.css after toggle.css and toggle-group.css. The tokens it reads:

TokenDefault (light)Applies to
--slean-space-10.25remgap, padding, margin-block, margin-inline
--slean-bordervar(--slean-neutral-6)border, background
--slean-radius-md0.625remborder-radius
--slean-surfaceoklch(100% 0 0)background
--slean-fgvar(--slean-neutral-12)color
--slean-space-20.5remgap

State selectors the stylesheet targets, all from the platform or ARIA: [aria-pressed="true"], [role="separator"].

Variant attributes: data-slean-orientation (vertical).

Compatibility notes

In Safari and in Firefox on macOS a click does not focus a button, so a click on a plain button or toggle leaves the tab stop where it was; a toggle group item focuses itself on click. dir="auto" needs :dir() (Baseline 2023) to resolve; explicit dir attributes work everywhere.

Examples

Vertical

data-slean-orientation="vertical" with aria-orientation="vertical" moves between the controls with ArrowDown and ArrowUp. The disabled Eraser is skipped by the keys and stays focusable, because it uses aria-disabled.

vertical
<div
	data-slean="toolbar"
	role="toolbar"
	aria-label="Drawing tools"
	data-slean-orientation="vertical"
	aria-orientation="vertical"
>
	<button type="button" data-slean="toggle" data-size="sm" aria-pressed="true">Select</button>
	<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false">Pen</button>
	<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false" aria-disabled="true">
		Eraser
	</button>
	<div role="separator"></div>
	<button type="button" data-slean="button" data-variant="ghost" data-size="sm">Undo</button>
</div>

Tab order in the markup

Both choices work with the behavior. Without tabindex, the page stays usable by keyboard without JavaScript, and a Shift+Tab into a toolbar nobody has focused yet lands on its last control. With an authored roving tabindex, the toolbar is one tab stop from the first paint.

tab order
<!-- No tabindex: every control is reachable by Tab without JavaScript; with it, the
     first focus makes the focused control the tab stop and sets the others to -1. -->
<div data-slean="toolbar" role="toolbar" aria-label="Formatting">
	<button type="button">Bold</button>
	<button type="button">Italic</button>
</div>

<!-- Authored roving tabindex: one tab stop from the first paint; without JavaScript only
     the first control is reachable by Tab. -->
<div data-slean="toolbar" role="toolbar" aria-label="Formatting">
	<button type="button" tabindex="0">Bold</button>
	<button type="button" tabindex="-1">Italic</button>
</div>

Testing

Source