sveltelean Primitives
Versionv0.2.0 GitHub

Example

Project files
  • src
    • primitives
      • button.ts
      • tabs.ts
      • tree.ts
    • index.ts
  • README.md

data-slean-value = button

<ul role="tree" aria-label="Project files" data-slean="tree" data-slean-value="button">
	<li role="treeitem" aria-expanded="true" aria-selected="false" data-slean-value="src" tabindex="-1">
		<span data-slean-part="label">src</span>
		<ul role="group">
			<li role="treeitem" aria-expanded="true" aria-selected="false" data-slean-value="primitives" tabindex="-1">
				<span data-slean-part="label">primitives</span>
				<ul role="group">
					<li role="treeitem" aria-selected="true" data-slean-value="button" tabindex="0">
						<span data-slean-part="label">button.ts</span>
					</li>
					<li role="treeitem" aria-selected="false" data-slean-value="tabs" tabindex="-1">
						<span data-slean-part="label">tabs.ts</span>
					</li>
				</ul>
			</li>
			<li role="treeitem" aria-expanded="false" aria-selected="false" data-slean-value="styles" tabindex="-1">
				<span data-slean-part="label">styles</span>
				<ul role="group">…</ul>
			</li>
		</ul>
	</li>
	<li role="treeitem" aria-selected="false" data-slean-value="readme" tabindex="-1">
		<span data-slean-part="label">README.md</span>
	</li>
</ul>
Click a folder to open it. With the keyboard: arrows to move, Right and Left to open and close, Home and End, * to open siblings, and typing a letter to jump.

Why this implementation exists

The platform’s disclosure (<details>) opens and closes a section, but a tree is more: the whole hierarchy is one tab stop, the arrows move through the visible items, Right and Left open, close and move between levels, and typing jumps to an item. None of that is native.

All of it can live in the DOM. aria-expanded is the expansion and the stylesheet hides a collapsed group, aria-selected and the root’s data-slean-value are the selection, and one item holds tabindex="0". The behavior computes the visible items from the attributes when a key is pressed and keeps nothing in between.

The browser owns

  • nested lists and their semantics; the level, set size and position derived from the nesting
  • focus on the item and the click and keydown events routed to the behavior

Svelte Lean owns

  • the WAI-ARIA tree view keyboard: arrows, Home, End, *, typeahead, RTL
  • expansion in aria-expanded, selection in aria-selected and data-slean-value
  • the roving tabindex, and focus moving to a parent that hides it
  • the cancelable slean:change and slean:toggle events, development validation
  • tree.css: indentation with a guide line, chevrons, hidden collapsed groups, the focus ring on the label

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="tree"; without it, import the register module once.

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

Render nested lists: role="tree" on the root, role="treeitem" on each item with a label part as its first child, aria-expanded and a role="group" list on parents. Give exactly one item tabindex="0".

Listen to slean:change for the selected value and slean:toggle for expansion. Set data-slean-selection="none" for a navigation tree whose labels are links.

Anatomy

PartElementdata-slean-partRequiredNotes
root<ul role="tree" aria-label="…" data-slean="tree">–yesOptions: data-slean-value (the selected item), data-slean-selection (single, none).
item<li role="treeitem" tabindex="0|-1" data-slean-value="…">–yesFound by role. A parent carries aria-expanded and a role="group" list.
label<span>labelyesThe item’s first child: its text, the focus ring and the selection.
group<ul role="group">–noThe children of a parent; hidden while it is collapsed.

Runtime profile

The tree registers one click and one keydown handler with the shared router. A thousand trees keep one listener per type (tests/tree.test.ts).

Accessibility contract

  • role="tree" named by aria-label; treeitem, group, aria-expanded and aria-selected. Assistive technology derives the level and the position from the nesting.
  • One tab stop; the arrows, Home, End, * and typeahead move focus among the visible items.
  • Closing a parent that holds focus moves focus to the parent, so focus never disappears into a hidden group.
  • The focus ring and the selection are drawn on the label, not around the whole subtree.

Keyboard

KeyWhenResult
ArrowDown/ArrowUpfocus on an itemNext or previous visible item
ArrowRightfocus on a parentOpens it; when open, moves to the first child
ArrowLeftfocus on an itemCloses an open parent; otherwise moves to the parent
Home/Endfocus on an itemFirst or last visible item
Enterfocus on an itemSelects it and opens or closes a parent
Spacefocus on an itemSelects it
*focus on an itemOpens every sibling
a–zfocus on an itemNext visible item starting with the typed text

Platform features

FeatureBaselineOutside the target
ARIA tree rolesARIA 1.2, widely supported by assistive technologyNot applicable
:dir(rtl)Baseline 2023 (Chrome 120, Safari 16.4, Firefox 49)The chevrons do not mirror; dir="auto" resolves left to right

Without JavaScript

The tree renders as nested lists with the server's expansion. Collapsed groups stay hidden and cannot be opened; links in labels still work. Where a hierarchy must be browsable without JavaScript, use nested disclosures.

Server rendering

Render aria-expanded, aria-selected and the roving tabindex on the server. The behavior writes nothing until the first interaction.

Before hydration

Before the behavior loads, clicks do nothing and Tab reaches the one item with tabindex 0. The first event after hydration works on the server's markup.

Styling

tree.css indents children under a guide line, draws the chevron of a parent with two borders (turned by aria-expanded, mirrored under :dir(rtl)), hides collapsed groups and puts the focus ring and the selection on the label. The tokens it reads:

TokenDefault (light)Applies to
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-space-20.5remmargin-inline-start, gap, padding-inline, padding-inline-start
--slean-space-10.25rempadding-inline-start, padding-block
--slean-bordervar(--slean-neutral-6)border-inline-start
--slean-control-height-sm2remmin-block-size
--slean-radius-sm0.375remborder-radius
--slean-fg-mutedvar(--slean-neutral-11)background
--slean-icon-chevron-righturl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--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-control-height-md2.25remmin-block-size

State selectors the stylesheet targets, all from the platform or ARIA: :dir(rtl), :focus-visible, :hover, [aria-disabled="true"], [aria-expanded="false"], [aria-expanded="true"], [aria-expanded], [aria-selected="true"], [role="group"], [role="treeitem"].

Compatibility notes

Nothing beyond lists and ARIA tree roles. Multiple selection, checkboxes and drag and drop are not part of the primitive.

Examples

Children loaded on demand

slean:toggle is dispatched before a parent opens or closes, with the item’s value and the new state. A listener that fills the group the first time a parent opens loads a large hierarchy one level at a time; cancelling the event keeps the item as it is.

lazy.svelte
<script lang="ts">
	import type { TreeToggleDetail } from '@svelte-lean/primitives/tree';
	import { on } from 'svelte/events';

	// Children per parent value, filled the first time a parent opens.
	let children = $state<Record<string, { value: string; label: string }[]>>({});

	function toggle(event: Event) {
		const { value, expanded } = (event as CustomEvent<TreeToggleDetail>).detail;
		if (expanded && !children[value]) {
			children[value] = []; // an empty group while the request runs
			fetch(`/api/folders/${value}`)
				.then((response) => response.json())
				.then((list) => (children[value] = list));
		}
	}
</script>

<ul role="tree" aria-label="Folders" data-slean="tree" {@attach (node) => on(node, 'slean:toggle', toggle)}>
	…
</ul>

Testing

Source