Docs Architecture
DOM protocol
Behaviors are declared with data attributes, not component instances. The DOM is the boundary between server rendering, CSS, the build, developer tools and the runtime, and the protocol is public API.
On this page
Attributes
| Attribute | Meaning | Example |
|---|---|---|
data-slean="<behavior>" | The root of a behavior; a statically discoverable identifier | data-slean="tabs" |
data-slean-part="<part>" | A structural part inside the root | data-slean-part="trigger" |
data-slean-value="<value>" | Identity within a root; on the root, the selected value | data-slean-value="security" |
data-slean-<option>="…" | One stable static option per attribute, never JSON | data-slean-activation="manual" |
data-slean-disabled | The router skips the root (aria-disabled="true" on the root does the same) | data-slean-disabled |
data-slean-state="<state>" | Exposed only when no platform pseudo-class or ARIA state exists; unused today | — |
data-slean-theme="<name>" | A theme scope: the styles derive their token aliases again on it | data-slean-theme="brand" |
data-variant="<variant>" | A look, read by stylesheets only (no prefix, like data-size and data-status) | data-variant="outline" |
data-size="sm|lg" | A size, read by stylesheets only; no attribute is medium | data-size="sm" |
data-status="error|warning" | The validation status of a form control, read by stylesheets only | data-status="warning" |
Attributes are declarative identifiers, never executable code, so a strict Content Security
Policy has nothing to object to. The same markup works in a .svelte file, in plain HTML,
in any other renderer, and in server output, and CSS can target every part and every state.
<div
data-slean="tabs"
data-slean-value="account"
data-slean-activation="manual"
data-slean-orientation="vertical"
>
<div role="tablist" aria-label="Settings" aria-orientation="vertical" data-slean-part="list">
<button type="button" role="tab" data-slean-part="trigger" data-slean-value="account" …>
<button type="button" role="tab" data-slean-part="trigger" data-slean-value="security" …>
</div>
<div role="tabpanel" data-slean-part="panel" data-slean-value="account" …>
<div role="tabpanel" data-slean-part="panel" data-slean-value="security" hidden …>
</div>One attribute per option
Configuration is one attribute per stable option. A JSON attribute would cost a parse on every
read, complicate escaping, hide the options from CSS selectors and developer tools, and make
static validation harder. Options are read at the moment of an interaction with getAttribute, never cached per instance.
<!-- Not the protocol: one JSON attribute for every option -->
<div data-slean="tabs" data-slean-config='{"activation":"manual","orientation":"vertical"}'>Inherently dynamic configuration does not belong in attributes. The declarative path is the
default; where an application needs to drive a behavior from state, it uses the events below and
the pure helpers of the subpath entry (activateTab(), closeMenu()).
Platform state first
State is not mirrored into data-* attributes when the platform or ARIA already
expresses it. Stylesheets and behaviors read :checked, :disabled, [open], :popover-open, :focus-visible, aria-selected and aria-expanded. Tabs need a selection state the
platform lacks, and aria-selected already is that state, so there is no data-state="active".
/* Platform state first: no mirrored data-state attributes */
[data-slean='checkbox']:checked { … }
[data-slean='dialog'][open] { … }
[data-slean='popover']:popover-open { … }
[data-slean='disclosure'][open] > summary { … }
/* Tabs have no platform state, so the ARIA state is the selector */
[data-slean='tabs'] [role='tab'][aria-selected='true'] { … }| Primitive | Root | State the stylesheet targets |
|---|---|---|
| button | data-slean="button" on <button> or <a> | :hover, :active, :disabled, [aria-disabled], [aria-busy] |
| dialog | data-slean="dialog" on <dialog> | [open], ::backdrop |
| popover | data-slean="popover" on [popover] | :popover-open |
| disclosure | data-slean="disclosure" on <details> | [open], ::details-content |
| checkbox | data-slean="checkbox" on <input type="checkbox"> | :checked, :indeterminate, :disabled, :user-invalid |
| switch | data-slean="switch" on <input type="checkbox" role="switch"> | :checked, :disabled |
| tabs | data-slean="tabs" | [aria-selected], [hidden], :disabled |
| menu | data-slean="menu" on [popover][role="menu"] | :focus-visible, [aria-checked], [aria-disabled], [aria-expanded] |
Events
A behavior reports through native CustomEvents named slean:<event>: bubbling, cancelable, with a small serializable detail. Two exist today. slean:change is dispatched from a tabs root before the DOM is written; preventDefault() keeps the selection. slean:select is dispatched from
a menu root after the checked state is updated and before the popover hides; preventDefault() keeps the menu open.
import type { TabsChangeDetail } from '@svelte-lean/primitives/tabs';
const root = document.getElementById('settings-tabs')!;
root.addEventListener('slean:change', (event) => {
const { value, previous } = (event as CustomEvent<TabsChangeDetail>).detail;
// Cancelable: the DOM is written only if nobody calls preventDefault().
if (value === 'billing' && !subscribed) event.preventDefault();
});import type { MenuSelectDetail } from '@svelte-lean/primitives/menu';
menu.addEventListener('slean:select', (event) => {
const { value, checked } = (event as CustomEvent<MenuSelectDetail>).detail;
if (value === 'wrap') editor.wordWrap = checked === true;
});import { activateTab } from '@svelte-lean/primitives/tabs';
// Selects a value through the same path as a click; returns false when a listener cancelled.
activateTab(root, 'security');Before a behavior invents an event, the platform's own is preferred: toggle for
details and popovers, close and cancel for dialogs, change for form controls. No slean:open exists because toggle already says it.
Static markers
The behavior identifier must be a static string so the build can discover it. A dynamic expression is recorded in the manifest and reported once per file, and needs a manual import of the registration module; the runtime itself does not care how the module got there.
<!-- Not discovered: the identifier is an expression -->
<div data-slean={kind}>…</div>
<!-- Discovered: a static identifier -->
<div data-slean="tabs">…</div>Example
A tabs root with two options set: manual activation and vertical orientation. Arrow keys move
focus without selecting; Enter or Space selects; the third trigger is aria-disabled and is skipped. The last event the root dispatched is shown under the frame.
<div data-slean="tabs" data-slean-value="general" data-slean-activation="manual" data-slean-orientation="vertical">
<div role="tablist" aria-label="Preferences" aria-orientation="vertical" data-slean-part="list">
<button
type="button"
id="pref-tab-general"
role="tab"
aria-controls="pref-panel-general"
aria-selected="true"
tabindex="0"
data-slean-part="trigger"
data-slean-value="general"
>
General
</button>
<button
type="button"
id="pref-tab-editor"
role="tab"
aria-controls="pref-panel-editor"
aria-selected="false"
tabindex="-1"
data-slean-part="trigger"
data-slean-value="editor"
>
Editor
</button>
<button
type="button"
id="pref-tab-billing"
role="tab"
aria-controls="pref-panel-billing"
aria-selected="false"
tabindex="-1"
aria-disabled="true"
data-slean-part="trigger"
data-slean-value="billing"
>
Billing
</button>
</div>
<div
id="pref-panel-general"
role="tabpanel"
aria-labelledby="pref-tab-general"
data-slean-part="panel"
data-slean-value="general"
tabindex="0"
>
General preferences.
</div>
<div
id="pref-panel-editor"
role="tabpanel"
aria-labelledby="pref-tab-editor"
data-slean-part="panel"
data-slean-value="editor"
tabindex="0"
hidden
>
Editor preferences.
</div>
<div
id="pref-panel-billing"
role="tabpanel"
aria-labelledby="pref-tab-billing"
data-slean-part="panel"
data-slean-value="billing"
tabindex="0"
hidden
>
Billing.
</div>
</div><script lang="ts">
// Injected by @svelte-lean/vite for the static marker; write it yourself without the plugin.
import '@svelte-lean/primitives/tabs/register';
import '@svelte-lean/styles/tabs.css';
</script>
<div data-slean="tabs" data-slean-value="general" data-slean-activation="manual" data-slean-orientation="vertical">
<div role="tablist" aria-label="Preferences" aria-orientation="vertical" data-slean-part="list">
<button
type="button"
id="pref-tab-general"
role="tab"
aria-controls="pref-panel-general"
aria-selected="true"
tabindex="0"
data-slean-part="trigger"
data-slean-value="general"
>
General
</button>
<button
type="button"
id="pref-tab-editor"
role="tab"
aria-controls="pref-panel-editor"
aria-selected="false"
tabindex="-1"
data-slean-part="trigger"
data-slean-value="editor"
>
Editor
</button>
<button
type="button"
id="pref-tab-billing"
role="tab"
aria-controls="pref-panel-billing"
aria-selected="false"
tabindex="-1"
aria-disabled="true"
data-slean-part="trigger"
data-slean-value="billing"
>
Billing
</button>
</div>
<div
id="pref-panel-general"
role="tabpanel"
aria-labelledby="pref-tab-general"
data-slean-part="panel"
data-slean-value="general"
tabindex="0"
>
General preferences.
</div>
<div
id="pref-panel-editor"
role="tabpanel"
aria-labelledby="pref-tab-editor"
data-slean-part="panel"
data-slean-value="editor"
tabindex="0"
hidden
>
Editor preferences.
</div>
<div
id="pref-panel-billing"
role="tabpanel"
aria-labelledby="pref-tab-billing"
data-slean-part="panel"
data-slean-value="billing"
tabindex="0"
hidden
>
Billing.
</div>
</div>Last event from the root above: none yet
Stability
Renaming a protocol attribute, an event, a CSS token or a package export is a breaking change with a deprecation window: a documented replacement, a development warning, a migration period and a major release to remove. Internal implementation details change freely; the markup an application wrote does not.
Source
- ADR 0002, The DOM protocol is the public API
- core/src/dom.ts:
parts(),closestRoot(),readOption(),emit() - Tabs contract, Menu contract, Listbox contract and Combobox contract: the options and events of the first four behaviors; every other contract is linked from its primitive page
- styles README: the state selectors each stylesheet uses