# DOM protocol

> The data-slean attributes, one attribute per option, platform pseudo-classes and ARIA state, slean:* events, the static-marker requirement and the deprecation rule.

Page: https://svelte-lean.vercel.app/docs/architecture/dom-protocol · Markdown: https://svelte-lean.vercel.app/docs/architecture/dom-protocol.md · Index: https://svelte-lean.vercel.app/llms.txt

Behaviors are declared with data attributes, not component instances, and that protocol is public API.

## 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-<state>` | A boolean state a behavior writes when no platform pseudo-class or ARIA state exists (ADR 0011 §3.2) | `data-slean-active` |
| `data-slean-theme="<name>"` | A theme scope: the styles derive their token aliases again on it | `data-slean-theme="brand"` |
| `data-variant="<variant>"` | The shape or fill, per family with MUI names (contained\|outlined\|text, outlined\|filled\|standard, standard\|filled\|outlined, elevation\|outlined); never a color | `data-variant="outlined"` |
| `data-color="<color>"` | primary\|secondary\|error\|warning\|info\|success\|neutral, on the element or an ancestor; read by stylesheets only | `data-color="error"` |
| `data-size="small\|large"` | A size, read by stylesheets only; medium is the default | `data-size="small"` |
| `data-full-width, data-density="compact\|comfortable"` | Modifiers read by stylesheets; data-density applies to the whole subtree | `data-density="compact"` |
| `data-status="warning"` | A warning on a form control, read by stylesheets only; an error is aria-invalid="true" | `data-status="warning"` |

- Identifiers, never executable code: a strict Content Security Policy has nothing to object to.
- The same markup works in Svelte, plain HTML, any renderer and server output.
- CSS can target every part and every state.

`settings.svelte`

```html
<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

- No JSON: it costs a parse per read, complicates escaping and hides options from CSS, developer tools and static validation.
- Options are read with `getAttribute` at interaction time, never cached per instance.

```html
<!-- Not the protocol: one JSON attribute for every option -->
<div data-slean="tabs" data-slean-config='{"activation":"manual","orientation":"vertical"}'>
```

Dynamic configuration uses the events below and the subpath helpers (`activateTab()`, `closeMenu()`) instead.

## Platform state first

No `data-*` mirror of state the platform or ARIA already expresses: tabs use `aria-selected`, not `data-state="active"`.

```css
/* Platform state first: no mirrored data-state attributes */
[data-slean='checkbox']:checked { … }
[data-slean='dialog'][open] { … }
[data-slean='popover']:popover-open { … }
[data-slean='accordion'][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](https://svelte-lean.vercel.app/primitives/button.md) | `data-slean="button" on <button> or <a>` | `:hover, :active, :disabled, [aria-disabled], [aria-busy]` |
| [dialog](https://svelte-lean.vercel.app/primitives/dialog.md) | `data-slean="dialog" on <dialog>` | `[open], ::backdrop` |
| [popover](https://svelte-lean.vercel.app/primitives/popover.md) | `data-slean="popover" on [popover]` | `:popover-open` |
| [accordion](https://svelte-lean.vercel.app/primitives/accordion.md) | `data-slean="accordion" on each <details>` | `[open], ::details-content` |
| [checkbox](https://svelte-lean.vercel.app/primitives/checkbox.md) | `data-slean="checkbox" on <input type="checkbox">, with role="switch" for a switch` | `:checked, :indeterminate, :disabled, :user-invalid` |
| [tabs](https://svelte-lean.vercel.app/primitives/tabs.md) | `data-slean="tabs"` | `[aria-selected], [hidden], :disabled` |
| [menu](https://svelte-lean.vercel.app/primitives/menu.md) | `data-slean="menu" on [popover][role="menu"]` | `:focus-visible, [aria-checked], [aria-disabled], [aria-expanded]` |

## Events

Native `CustomEvent`s named `slean:<event>`, dispatched from the root: bubbling, with a small serializable `detail`. Tabs dispatch `slean:change` before the DOM is written (`preventDefault()` keeps the selection); a menu dispatches `slean:select` before it hides (`preventDefault()` keeps it open).

| Event | Dispatched by |
| --- | --- |
| `slean:action` | [toast](https://svelte-lean.vercel.app/primitives/toast.md) |
| `slean:change` | [tabs](https://svelte-lean.vercel.app/primitives/tabs.md), [listbox](https://svelte-lean.vercel.app/primitives/listbox.md), [calendar](https://svelte-lean.vercel.app/primitives/calendar.md), [select](https://svelte-lean.vercel.app/primitives/select.md), [autocomplete](https://svelte-lean.vercel.app/primitives/autocomplete.md), [toggle](https://svelte-lean.vercel.app/primitives/toggle.md), [toggle-group](https://svelte-lean.vercel.app/primitives/toggle-group.md), [tree](https://svelte-lean.vercel.app/primitives/tree.md), [sortable](https://svelte-lean.vercel.app/primitives/sortable.md) |
| `slean:copy` | [copy](https://svelte-lean.vercel.app/primitives/copy.md) |
| `slean:dismiss` | [toast](https://svelte-lean.vercel.app/primitives/toast.md) |
| `slean:findend` | [findable](https://svelte-lean.vercel.app/primitives/findable.md) |
| `slean:navigate` | [calendar](https://svelte-lean.vercel.app/primitives/calendar.md) |
| `slean:reject` | [file-drop](https://svelte-lean.vercel.app/primitives/file-drop.md) |
| `slean:resize` | [splitter](https://svelte-lean.vercel.app/primitives/splitter.md) |
| `slean:select` | [menu](https://svelte-lean.vercel.app/primitives/menu.md) |
| `slean:toggle` | [tree](https://svelte-lean.vercel.app/primitives/tree.md) |

```ts
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();
});
```

```ts
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;
});
```

```ts
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');
```

Platform events come first: `toggle` for details and popovers, `close` and `cancel` for dialogs, `change` for form controls.

## Static markers

The identifier must be a static string for the build to discover it. A dynamic one is reported once per file and needs a manual import of the registration module.

```svelte
<!-- Not discovered: the identifier is an expression -->
<div data-slean={kind}>…</div>

<!-- Discovered: a static identifier -->
<div data-slean="tabs">…</div>
```

## Example

Manual activation and vertical orientation: arrow keys move focus, Enter or Space selects, the `aria-disabled` trigger is skipped.

```svelte
<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>
```

One attribute per option. The list also carries aria-orientation for assistive technology.

Last event from the root above: none yet

## Stability

Renaming an attribute, event, CSS token or export is breaking: documented replacement, development warning, migration period, removal in a major release. Internals change freely.

## 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 Select contract (every other contract is linked from its page)
- styles README: the state selectors each stylesheet uses
