@mlola-ui/engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mlola
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @mlola-ui/engine
2
+
3
+ Generated CSS and machine-readable metadata for Mlola UI. Framework-free: it
4
+ emits plain CSS and JSON, so it runs with React, plain HTML, Rails, Go, or a
5
+ static file, and has no runtime dependency.
6
+
7
+ The engine is the reason a model cannot hallucinate this library. Its
8
+ [`contract.json`](generated/contract.json) is derived from the stylesheet itself
9
+ and lists every class and the `data-*` / `aria-*` attributes the CSS reacts to,
10
+ and [`agents.md`](generated/agents.md) is generated from the same source. An
11
+ assistant reading them cannot invent a class that does not exist.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @mlola-ui/engine
17
+ ```
18
+
19
+ ## Use
20
+
21
+ ```css
22
+ @import "@mlola-ui/engine";
23
+ ```
24
+
25
+ ```html
26
+ <html data-theme="atelier" data-mode="light">
27
+ <button class="ml-button" data-variant="primary">Save</button>
28
+ </html>
29
+ ```
30
+
31
+ ## Exports
32
+
33
+ | Path | What it is |
34
+ | --- | --- |
35
+ | `@mlola-ui/engine` | the ordered cascade: tokens, foundations, materials, recipes, motion |
36
+ | `@mlola-ui/engine/tokens.css` | theme + semantic tokens, light and dark |
37
+ | `@mlola-ui/engine/recipes.css` | component recipes |
38
+ | `@mlola-ui/engine/motion.css` | keyframes, spring easings, reduced-motion collapse |
39
+ | `@mlola-ui/engine/contract.json` | every class and the attributes it reacts to |
40
+ | `@mlola-ui/engine/tokens.json` | DTCG token document |
41
+
42
+ ## Build
43
+
44
+ The engine is generated by a Node 20 script, so the output is reproducible:
45
+
46
+ ```bash
47
+ node build.mjs
48
+ node build.mjs --check # fails when generated output is stale
49
+ ```
50
+
51
+ MIT licensed. Part of [Mlola UI](https://ui.mlola.com).
package/build.mjs ADDED
@@ -0,0 +1,123 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { profiles } from "./src/config.mjs";
5
+ import { renderContract, renderProContract } from "./src/contract.mjs";
6
+ import { renderCatalogCss } from "./src/library-recipes.mjs";
7
+ import { renderSpecSchema } from "./src/spec.mjs";
8
+ import { renderThemeCss, renderThemeManifest } from "./src/theme.mjs";
9
+ import {
10
+ renderDtcg,
11
+ renderFoundationsCss,
12
+ renderMaterialsCss,
13
+ renderMotionCss,
14
+ renderRecipesCss,
15
+ renderTokensCss,
16
+ } from "./src/render.mjs";
17
+
18
+ const engineDirectory = dirname(fileURLToPath(import.meta.url));
19
+ const packagesDirectory = dirname(engineDirectory);
20
+ const repositoryDirectory = dirname(packagesDirectory);
21
+ const generatedDirectory = join(engineDirectory, "generated");
22
+ const tokenGeneratedDirectory = join(packagesDirectory, "tokens", "generated");
23
+ const check = process.argv.includes("--check");
24
+
25
+ const layers = {
26
+ "tokens.css": renderTokensCss(),
27
+ "foundations.css": renderFoundationsCss(),
28
+ "materials.css": renderMaterialsCss(),
29
+ "recipes.css": renderRecipesCss(),
30
+ "motion.css": renderMotionCss(),
31
+ };
32
+
33
+ const layerOrder = "mlola.tokens, mlola.foundations, mlola.materials, mlola.recipes, mlola.motion, mlola.accessibility";
34
+ const aggregate = `/* Generated by @mlola-ui/engine. React and Tailwind are not required. */
35
+ @layer ${layerOrder};
36
+ @import "./tokens.css" layer(mlola.tokens);
37
+ /* The project theme is a token source too, imported last within the same
38
+ layer so a brand wins over every canonical theme. */
39
+ @import "./theme.css" layer(mlola.tokens);
40
+ @import "./foundations.css" layer(mlola.foundations);
41
+ @import "./materials.css" layer(mlola.materials);
42
+ @import "./recipes.css" layer(mlola.recipes);
43
+ @import "./motion.css" layer(mlola.motion);
44
+ `;
45
+
46
+ const dtcg = `${JSON.stringify(renderDtcg(), null, 2)}\n`;
47
+ const manifest = `${JSON.stringify({
48
+ generatedAt: "deterministic",
49
+ generator: "@mlola-ui/engine",
50
+ profileCount: Object.keys(profiles).length,
51
+ }, null, 2)}\n`;
52
+
53
+ const outputs = new Map();
54
+ for (const [name, contents] of Object.entries(layers)) {
55
+ outputs.set(join(generatedDirectory, name), `${contents.trim()}\n`);
56
+ outputs.set(join(tokenGeneratedDirectory, name), `${contents.trim()}\n`);
57
+ }
58
+ outputs.set(join(generatedDirectory, "mlola.css"), aggregate);
59
+ outputs.set(join(tokenGeneratedDirectory, "mlola.css"), aggregate);
60
+ // A project theme is optional. When present it is compiled alongside the
61
+ // canonical themes and selects on the same data-theme contract.
62
+ const themeFile = join(repositoryDirectory, "mlola.theme.json");
63
+ let projectTheme = "";
64
+ try {
65
+ projectTheme = renderThemeCss(JSON.parse(await readFile(themeFile, "utf8")));
66
+ } catch (error) {
67
+ if (error?.code !== "ENOENT") {
68
+ throw new Error(`mlola.theme.json is present but unusable: ${error.message}`);
69
+ }
70
+ }
71
+ outputs.set(join(generatedDirectory, "theme.css"), projectTheme || "/* No mlola.theme.json in this project. */\n");
72
+
73
+ const themeManifest = `${JSON.stringify(
74
+ {
75
+ version: 1,
76
+ description:
77
+ "Themes compiled into this build. A project theme from mlola.theme.json appears here alongside the canonical ones.",
78
+ themes: renderThemeManifest(
79
+ projectTheme ? JSON.parse(await readFile(themeFile, "utf8")) : null,
80
+ ),
81
+ },
82
+ null,
83
+ 2,
84
+ )}\n`;
85
+ outputs.set(join(generatedDirectory, "themes.json"), themeManifest);
86
+
87
+ // The spec schema is what editors, the CLI, and models validate a theme against.
88
+ outputs.set(join(generatedDirectory, "theme-spec.schema.json"), `${JSON.stringify(renderSpecSchema(), null, 2)}\n`);
89
+
90
+ // The commercial catalog's styles are assembled here but written outside the
91
+ // engine package, so publishing the engine can never ship them.
92
+ outputs.set(join(packagesDirectory, "registry", "catalog", "generated", "catalog.css"), renderCatalogCss());
93
+
94
+ const contract = `${JSON.stringify(renderContract(), null, 2)}\n`;
95
+ outputs.set(join(generatedDirectory, "contract.json"), contract);
96
+ // Pro's half of the contract stays beside the Pro catalog, out of the published engine.
97
+ outputs.set(join(packagesDirectory, "registry", "catalog", "generated", "contract.json"), `${JSON.stringify(renderProContract(), null, 2)}\n`);
98
+ outputs.set(join(generatedDirectory, "tokens.json"), dtcg);
99
+ outputs.set(join(packagesDirectory, "tokens", "tokens.json"), dtcg);
100
+ outputs.set(join(generatedDirectory, "manifest.json"), manifest);
101
+
102
+ const changed = [];
103
+ for (const [path, contents] of outputs) {
104
+ let previous = null;
105
+ try {
106
+ previous = await readFile(path, "utf8");
107
+ } catch {
108
+ // Missing output is a change.
109
+ }
110
+ if (previous === contents) continue;
111
+ changed.push(path.replace(`${repositoryDirectory}/`, ""));
112
+ if (!check) {
113
+ await mkdir(dirname(path), { recursive: true });
114
+ await writeFile(path, contents);
115
+ }
116
+ }
117
+
118
+ if (check && changed.length) {
119
+ console.error(`Generated output is stale:\n${changed.map((path) => `- ${path}`).join("\n")}`);
120
+ process.exitCode = 1;
121
+ } else {
122
+ console.log(check ? "Generated output is current." : `Generated ${outputs.size} files.`);
123
+ }
@@ -0,0 +1,369 @@
1
+ # Mlola UI for code generation
2
+
3
+ Generated from the source of truth. Do not edit by hand.
4
+
5
+ ## Read this first
6
+
7
+ - **Do not import a styling framework.** The library ships its own CSS. There
8
+ is no Tailwind, no CSS-in-JS and no class name utility to install.
9
+ - **Style through the classes below, never through invented ones.** A class
10
+ that is not in this document does not exist. (Mlola Pro's classes are in the
11
+ guide that comes with Pro source.)
12
+ - **Behaviour is optional and framework-free.** `@mlola-ui/behavior` attaches
13
+ to markup you already rendered. It never renders anything itself, so it works
14
+ with React, Svelte, Vue, Rails, a Go template or a static file.
15
+ - **Native controls stay native.** Checkbox and radio are real `<input>`
16
+ elements. Do not reimplement them.
17
+
18
+ ## The shortest correct example
19
+
20
+ ```html
21
+ <link rel="stylesheet" href="@mlola-ui/engine" />
22
+
23
+ <div data-theme="graphite" data-mode="light">
24
+ <button class="ml-button" data-variant="primary">Save</button>
25
+ </div>
26
+
27
+ <script type="module">
28
+ import { observe } from "@mlola-ui/behavior";
29
+ observe();
30
+ </script>
31
+ ```
32
+
33
+ ## Themes
34
+
35
+ Set `data-theme` and `data-mode` on any ancestor. Nothing else changes.
36
+
37
+ | id | name |
38
+ | --- | --- |
39
+ | `graphite` | Graphite |
40
+ | `atelier` | Atelier Umami |
41
+ | `machined` | Machined Titanium |
42
+ | `aerogel` | Aerogel Glass |
43
+ | `nordic` | Nordic Earth |
44
+
45
+ `data-mode` is `light` or `dark`.
46
+
47
+ A project defines its own theme in one file, `mlola.theme.json`, which
48
+ overrides fonts, colours, geometry, the spacing and type scale, and any
49
+ custom property through `extend`. See `mlola.theme.example.json`.
50
+
51
+ ## Elements and their attributes
52
+
53
+ Emit these classes and attributes from any language and the visuals are
54
+ correct. This table is read out of the stylesheet, so it is never stale.
55
+
56
+ | class | attribute | allowed values |
57
+ | --- | --- | --- |
58
+ | `.ml-accordion-item` | `data-state` | `open` |
59
+ | `.ml-alert` | `data-state` | `closing` |
60
+ | `.ml-alert` | `data-tone` | `danger`, `info`, `neutral`, `success`, `warning` |
61
+ | `.ml-alert` | `data-variant` | `soft` |
62
+ | `.ml-app-shell` | `data-narrow` | _presence only_ |
63
+ | `.ml-avatar-root` | `data-size` | `lg`, `sm`, `xl`, `xs` |
64
+ | `.ml-avatar-status` | `data-status` | `away`, `busy`, `online` |
65
+ | `.ml-badge` | `data-size` | `lg`, `sm` |
66
+ | `.ml-badge` | `data-tone` | `danger`, `info`, `primary`, `success`, `warning` |
67
+ | `.ml-badge` | `data-variant` | `outline`, `solid` |
68
+ | `.ml-breadcrumb-item` | `data-collapse-indicator` | _presence only_ |
69
+ | `.ml-breadcrumb-item` | `data-collapsible` | _presence only_ |
70
+ | `.ml-button` | `aria-disabled` | `true` |
71
+ | `.ml-button` | `aria-pressed` | `true` |
72
+ | `.ml-button` | `data-loading` | _presence only_ |
73
+ | `.ml-button` | `data-size` | `icon`, `lg`, `sm` |
74
+ | `.ml-button` | `data-state` | `success` |
75
+ | `.ml-button` | `data-variant` | `danger`, `link`, `outline`, `primary`, `secondary`, `subtle` |
76
+ | `.ml-calendar-cell` | `data-preview` | _presence only_ |
77
+ | `.ml-calendar-cell` | `data-range` | `end`, `start` |
78
+ | `.ml-calendar-day` | `aria-disabled` | `true` |
79
+ | `.ml-calendar-day` | `data-outside` | _presence only_ |
80
+ | `.ml-calendar-day` | `data-selected` | _presence only_ |
81
+ | `.ml-calendar-day` | `data-today` | _presence only_ |
82
+ | `.ml-calendar-day` | `data-unreachable` | _presence only_ |
83
+ | `.ml-card` | `data-interactive` | _presence only_ |
84
+ | `.ml-card` | `data-variant` | `elevated`, `glass`, `specular` |
85
+ | `.ml-carousel` | `data-playing` | _presence only_ |
86
+ | `.ml-carousel-arrow` | `data-side` | `next`, `previous` |
87
+ | `.ml-carousel-dot` | `aria-current` | `true` |
88
+ | `.ml-chart-bar` | `data-highlighted` | `true` |
89
+ | `.ml-checkbox-field` | `data-state` | `checked`, `indeterminate` |
90
+ | `.ml-circular-progress` | `data-size` | `lg`, `sm` |
91
+ | `.ml-circular-progress` | `data-state` | `complete`, `indeterminate` |
92
+ | `.ml-circular-progress` | `data-tone` | `danger`, `info`, `success`, `warning` |
93
+ | `.ml-color-picker-preset` | `aria-pressed` | `true` |
94
+ | `.ml-combobox-control` | `data-open` | _presence only_ |
95
+ | `.ml-combobox-input` | `data-clearable` | _presence only_ |
96
+ | `.ml-combobox-input` | `data-leading` | _presence only_ |
97
+ | `.ml-combobox-option` | `aria-disabled` | `true` |
98
+ | `.ml-combobox-option` | `aria-selected` | `true` |
99
+ | `.ml-combobox-option` | `data-create` | _presence only_ |
100
+ | `.ml-combobox-option` | `data-highlighted` | _presence only_ |
101
+ | `.ml-combobox-popover` | `data-side` | `top` |
102
+ | `.ml-command-item` | `aria-disabled` | _presence only_ |
103
+ | `.ml-command-item` | `aria-selected` | `true` |
104
+ | `.ml-context-menu-item` | `aria-disabled` | `true` |
105
+ | `.ml-context-menu-item` | `data-danger` | _presence only_ |
106
+ | `.ml-context-menu-item` | `data-highlighted` | _presence only_ |
107
+ | `.ml-date-picker-panel` | `data-presets` | _presence only_ |
108
+ | `.ml-date-picker-trigger` | `data-empty` | _presence only_ |
109
+ | `.ml-dropdown-item` | `data-danger` | _presence only_ |
110
+ | `.ml-dropdown-item` | `data-highlighted` | _presence only_ |
111
+ | `.ml-dropdown-menu` | `data-align` | `end` |
112
+ | `.ml-dropdown-trigger` | `aria-expanded` | `true` |
113
+ | `.ml-dropzone` | `aria-disabled` | _presence only_ |
114
+ | `.ml-dropzone` | `data-dragging` | _presence only_ |
115
+ | `.ml-dropzone-file` | `data-status` | `error` |
116
+ | `.ml-empty` | `data-size` | `page` |
117
+ | `.ml-filter-chip` | `aria-pressed` | `true` |
118
+ | `.ml-filter-chip` | `data-state` | `active` |
119
+ | `.ml-form-message` | `data-tone` | `danger`, `success` |
120
+ | `.ml-grid` | `data-columns` | `1`, `2`, `3`, `4`, `6` |
121
+ | `.ml-grid` | `data-layout` | `3-col`, `4-col`, `list` |
122
+ | `.ml-heatmap` | `data-tone` | `info`, `success`, `warning` |
123
+ | `.ml-heatmap-cell` | `data-active` | _presence only_ |
124
+ | `.ml-heatmap-cell` | `data-level` | `1`, `2`, `3`, `4` |
125
+ | `.ml-heatmap-cell` | `data-outside` | _presence only_ |
126
+ | `.ml-input` | `aria-invalid` | `true` |
127
+ | `.ml-input` | `data-size` | `lg`, `sm` |
128
+ | `.ml-input` | `data-variant` | `filled`, `subtle` |
129
+ | `.ml-input-control` | `data-leading` | _presence only_ |
130
+ | `.ml-input-control` | `data-trailing` | _presence only_ |
131
+ | `.ml-input-field` | `data-invalid` | _presence only_ |
132
+ | `.ml-modal` | `data-size` | `full`, `lg`, `sm`, `xl` |
133
+ | `.ml-number-input-control` | `data-disabled` | _presence only_ |
134
+ | `.ml-number-input-control` | `data-invalid` | _presence only_ |
135
+ | `.ml-otp` | `data-invalid` | _presence only_ |
136
+ | `.ml-pagination-button` | `aria-current` | `page` |
137
+ | `.ml-pagination-button` | `data-state` | `active` |
138
+ | `.ml-popover` | `data-side` | `bottom`, `left`, `right`, `top` |
139
+ | `.ml-priority-icon` | `data-priority` | `high`, `none`, `urgent` |
140
+ | `.ml-progress-root` | `data-active` | _presence only_ |
141
+ | `.ml-progress-root` | `data-size` | `lg`, `sm` |
142
+ | `.ml-progress-root` | `data-state` | `complete`, `indeterminate` |
143
+ | `.ml-progress-root` | `data-tone` | `danger`, `info`, `success`, `warning` |
144
+ | `.ml-radio-group` | `data-orientation` | `horizontal` |
145
+ | `.ml-radio-item` | `data-state` | `checked` |
146
+ | `.ml-resizable` | `data-anchor` | `second` |
147
+ | `.ml-resizable` | `data-direction` | `horizontal`, `vertical` |
148
+ | `.ml-resizable-pane` | `data-folded` | _presence only_ |
149
+ | `.ml-segmented-control-item` | `aria-pressed` | `true` |
150
+ | `.ml-segmented-control-item` | `data-state` | `active` |
151
+ | `.ml-select` | `data-size` | `lg`, `sm` |
152
+ | `.ml-select` | `data-state` | `open` |
153
+ | `.ml-select-option` | `data-disabled` | _presence only_ |
154
+ | `.ml-select-option` | `data-highlighted` | _presence only_ |
155
+ | `.ml-select-option` | `data-state` | `checked` |
156
+ | `.ml-select-popover` | `data-side` | `top` |
157
+ | `.ml-select-root` | `data-invalid` | _presence only_ |
158
+ | `.ml-select-value` | `data-placeholder` | _presence only_ |
159
+ | `.ml-sheet-panel` | `data-side` | `bottom`, `left`, `right`, `top` |
160
+ | `.ml-sheet-panel` | `data-size` | `lg`, `sm` |
161
+ | `.ml-sidebar-item` | `aria-current` | `page` |
162
+ | `.ml-sidebar-title` | `aria-expanded` | `false` |
163
+ | `.ml-sidebar-title` | `data-collapsible` | _presence only_ |
164
+ | `.ml-skeleton` | `data-rounded` | `none` |
165
+ | `.ml-slider-field` | `data-size` | `sm` |
166
+ | `.ml-status-icon` | `data-status` | `backlog`, `canceled`, `done`, `in-progress`, `in-review` |
167
+ | `.ml-stepper` | `data-orientation` | `horizontal`, `vertical` |
168
+ | `.ml-stepper-step` | `data-status` | `complete`, `current`, `error`, `upcoming` |
169
+ | `.ml-table` | `data-size` | `sm` |
170
+ | `.ml-table` | `data-stripe` | `alternate` |
171
+ | `.ml-table` | `data-striped` | _presence only_ |
172
+ | `.ml-table-row` | `data-state` | `selected` |
173
+ | `.ml-tabs` | `data-orientation` | `vertical` |
174
+ | `.ml-tabs` | `data-variant` | `enclosed`, `pills` |
175
+ | `.ml-tabs-trigger` | `aria-selected` | `true` |
176
+ | `.ml-tabs-trigger` | `data-state` | `active` |
177
+ | `.ml-tag-input-control` | `data-disabled` | _presence only_ |
178
+ | `.ml-tag-input-control` | `data-invalid` | _presence only_ |
179
+ | `.ml-tag-input-tag` | `data-armed` | _presence only_ |
180
+ | `.ml-textarea-count` | `data-full` | _presence only_ |
181
+ | `.ml-time-picker-control` | `data-disabled` | _presence only_ |
182
+ | `.ml-time-picker-control` | `data-unavailable` | _presence only_ |
183
+ | `.ml-time-picker-option` | `aria-selected` | `true` |
184
+ | `.ml-time-picker-segment` | `data-empty` | _presence only_ |
185
+ | `.ml-time-picker-segment` | `data-period` | _presence only_ |
186
+ | `.ml-timeline-item` | `data-status` | `complete`, `current`, `upcoming` |
187
+ | `.ml-timeline-marker` | `data-status` | `complete`, `current`, `upcoming` |
188
+ | `.ml-timeline-marker` | `data-tone` | `danger`, `info`, `primary`, `success`, `warning` |
189
+ | `.ml-toast` | `data-has-description` | _presence only_ |
190
+ | `.ml-toast` | `data-swiping` | _presence only_ |
191
+ | `.ml-toast` | `data-tone` | `danger`, `info`, `success`, `warning` |
192
+ | `.ml-toast-slot` | `data-state` | `closed` |
193
+ | `.ml-toaster` | `data-position` | `bottom-center`, `bottom-right`, `top-center`, `top-right` |
194
+ | `.ml-toggle` | `aria-checked` | `true` |
195
+ | `.ml-toggle` | `data-size` | `lg`, `sm` |
196
+ | `.ml-toggle` | `data-state` | `checked` |
197
+ | `.ml-toggle-button` | `aria-pressed` | `true` |
198
+ | `.ml-toggle-button` | `data-state` | `on` |
199
+ | `.ml-tooltip` | `data-side` | `bottom`, `left`, `right`, `top` |
200
+ | `.ml-tour-button` | `data-primary` | _presence only_ |
201
+ | `.ml-tour-card` | `data-centred` | _presence only_ |
202
+ | `.ml-tour-dot` | `data-active` | _presence only_ |
203
+ | `.ml-tour-scrim` | `data-spotlight` | _presence only_ |
204
+
205
+ ## Behaviour
206
+
207
+ ### accordion
208
+
209
+ Disclosure list. One panel open at a time, or several.
210
+
211
+ - Root: `.ml-accordion`
212
+ - Parts: item `.ml-accordion-item`, trigger `.ml-accordion-trigger`, panel `.ml-accordion-panel`
213
+ - ARIA on trigger: `role` — button (a real <button> is preferred); `aria-expanded` — true when the item is open; `aria-controls` — id of the panel
214
+ - ARIA on panel: `role` — region; `aria-labelledby` — id of the trigger
215
+ - Keyboard:
216
+ - <kbd>Enter</kbd> / <kbd>Space</kbd>: Toggle the focused item.
217
+ - <kbd>ArrowDown</kbd> / <kbd>ArrowUp</kbd>: Move focus between triggers.
218
+ - <kbd>Home</kbd> / <kbd>End</kbd>: Focus the first or last trigger.
219
+ - State changes:
220
+ - On click or activate a trigger, set data-state on the item, trigger and panel to open, or closed when it was open and collapsing is allowed. Also: hide the closed panel from the accessibility tree.
221
+
222
+ ### tabs
223
+
224
+ One panel visible at a time, selected by a tab strip.
225
+
226
+ - Root: `.ml-tabs`
227
+ - Parts: list `.ml-tabs-list`, trigger `.ml-tabs-trigger`, content `.ml-tabs-content`
228
+ - ARIA on list: `role` — tablist; `aria-orientation` — matches data-orientation
229
+ - ARIA on trigger: `role` — tab; `aria-selected` — true on the active tab; `aria-controls` — id of the panel; `tabindex` — 0 on the active tab, -1 on the rest
230
+ - ARIA on content: `role` — tabpanel; `aria-labelledby` — id of the tab
231
+ - Keyboard:
232
+ - <kbd>ArrowRight</kbd> / <kbd>ArrowLeft</kbd>: Move to the next or previous enabled tab when horizontal, wrapping around.
233
+ - <kbd>ArrowDown</kbd> / <kbd>ArrowUp</kbd>: The same when vertical.
234
+ - <kbd>Home</kbd> / <kbd>End</kbd>: Move to the first or last enabled tab.
235
+ - State changes:
236
+ - On select a tab by pointer or key, set data-state and aria-selected on triggers, data-state on panels to active for the chosen pair, inactive for the rest. Also: move focus to the newly selected tab.
237
+ - Note: Selection follows focus. Disabled tabs are skipped, never focused.
238
+
239
+ ### dropdown-menu
240
+
241
+ A menu anchored to a trigger.
242
+
243
+ - Root: `.ml-dropdown`
244
+ - Parts: trigger `.ml-dropdown-trigger`, menu `.ml-dropdown-menu`, item `.ml-dropdown-item`
245
+ - ARIA on trigger: `aria-haspopup` — menu; `aria-expanded` — true while open
246
+ - ARIA on menu: `role` — menu
247
+ - ARIA on item: `role` — menuitem; `data-highlighted` — present on the active item
248
+ - Keyboard:
249
+ - <kbd>ArrowDown</kbd> / <kbd>ArrowUp</kbd>: Open the menu, then move the highlight.
250
+ - <kbd>Enter</kbd> / <kbd>Space</kbd>: Activate the highlighted item.
251
+ - <kbd>Escape</kbd>: Close and return focus to the trigger.
252
+ - <kbd>Tab</kbd>: Close without activating.
253
+ - State changes:
254
+ - On click the trigger, set data-state to open or closed.
255
+ - On pointer over an item, set data-highlighted to that item only.
256
+ - On pointer down outside the root, set data-state to closed.
257
+ - Note: Disabled items are skipped by the highlight and cannot be activated.
258
+
259
+ ### select
260
+
261
+ A listbox behind a combobox trigger.
262
+
263
+ - Root: `.ml-select-root`
264
+ - Parts: trigger `.ml-select`, popover `.ml-select-popover`, list `.ml-select-list`, option `.ml-select-option`, search `.ml-select-search`
265
+ - ARIA on trigger: `role` — combobox; `aria-expanded` — true while open; `aria-controls` — id of the listbox; `aria-activedescendant` — id of the highlighted option while open
266
+ - ARIA on list: `role` — listbox; `aria-multiselectable` — true when multiple
267
+ - ARIA on option: `role` — option; `aria-selected` — true when chosen; `data-highlighted` — present on the active option
268
+ - Keyboard:
269
+ - <kbd>ArrowDown</kbd> / <kbd>ArrowUp</kbd>: Open, then move the highlight past disabled options.
270
+ - <kbd>Enter</kbd> / <kbd>Space</kbd>: Choose the highlighted option. Space types when a search field has focus.
271
+ - <kbd>Home</kbd> / <kbd>End</kbd>: Highlight the first or last enabled option.
272
+ - <kbd>Escape</kbd>: Close and return focus to the trigger.
273
+ - <kbd>Tab</kbd>: Close without choosing.
274
+ - State changes:
275
+ - On choose an option, set aria-selected and data-state on options to checked for the chosen option. Also: single select closes and restores focus; multiple select stays open.
276
+
277
+ ### modal
278
+
279
+ A dialog over the page that owns focus while open.
280
+
281
+ - Root: `.ml-modal`
282
+ - Parts: overlay `.ml-modal-overlay`, close `.ml-modal-close`
283
+ - ARIA on root: `role` — dialog; `aria-modal` — true; `aria-labelledby` — id of the title, or aria-label
284
+ - Keyboard:
285
+ - <kbd>Escape</kbd>: Close, unless closing on Escape is disabled.
286
+ - <kbd>Tab</kbd> / <kbd>Shift+Tab</kbd>: Cycle focus inside the dialog and never leave it.
287
+ - State changes:
288
+ - On open, set focus to the first focusable element inside. Also: lock page scroll.
289
+ - On close, set focus to the element that opened the dialog. Also: release page scroll.
290
+ - On pointer down on the backdrop, set closed to unless closing on backdrop is disabled.
291
+
292
+ ### sheet
293
+
294
+ A dialog anchored to one edge of the viewport.
295
+
296
+ - Root: `.ml-sheet-panel`
297
+ - Parts: overlay `.ml-sheet-overlay`, close `.ml-sheet-close`
298
+ - ARIA on root: `role` — dialog; `aria-modal` — true
299
+ - Keyboard:
300
+ - <kbd>Escape</kbd>: Close, unless closing on Escape is disabled.
301
+ - <kbd>Tab</kbd> / <kbd>Shift+Tab</kbd>: Cycle focus inside the panel.
302
+ - State changes:
303
+ - On open, set focus to inside the panel. Also: lock page scroll.
304
+ - On close, set focus to the trigger. Also: release page scroll.
305
+ - Note: Identical to modal apart from which edge it is anchored to.
306
+
307
+ ### tooltip
308
+
309
+ A short label shown on hover or focus.
310
+
311
+ - Root: `.ml-tooltip-root`
312
+ - Parts: tooltip `.ml-tooltip`, arrow `.ml-tooltip-arrow`
313
+ - ARIA on tooltip: `role` — tooltip
314
+ - ARIA on trigger: `aria-describedby` — id of the tooltip while it is open
315
+ - Keyboard:
316
+ - <kbd>Escape</kbd>: Hide the tooltip.
317
+ - State changes:
318
+ - On pointer enter or focus the trigger, set visible to after the delay.
319
+ - On pointer leave or blur, set hidden to immediately, cancelling any pending delay.
320
+ - Note: Never put essential information or interactive content in a tooltip.
321
+
322
+ ### toast
323
+
324
+ Transient messages in a live region.
325
+
326
+ - Root: `.ml-toaster`
327
+ - Parts: toast `.ml-toast`, action `.ml-toast-action`, close `.ml-toast-close`
328
+ - ARIA on root: `role` — region; `aria-label` — distinct per region, so several are distinguishable
329
+ - ARIA on toast: `role` — status for ordinary messages, alert for danger and warning; `aria-live` — polite, or assertive for danger and warning; `aria-busy` — true while it waits on work (a loading toast)
330
+ - Keyboard:
331
+ - <kbd>Tab</kbd>: Reach the action and dismiss controls.
332
+ - State changes:
333
+ - On push, set a toast into the region matching its position to visible.
334
+ - On duration elapsed, set removed to unless the duration is zero.
335
+
336
+ ### switch
337
+
338
+ An on/off control that is not a native checkbox.
339
+
340
+ - Root: `.ml-toggle`
341
+ - Parts: thumb `.ml-toggle-thumb`
342
+ - ARIA on root: `role` — switch; `aria-checked` — true or false
343
+ - Keyboard:
344
+ - <kbd>Enter</kbd> / <kbd>Space</kbd>: Toggle.
345
+ - State changes:
346
+ - On activate, set aria-checked and data-state to the opposite value.
347
+ - Note: Prefer a native checkbox unless the control genuinely reads as a switch.
348
+
349
+ ### slider
350
+
351
+ A single value chosen from a range.
352
+
353
+ - Root: `.ml-slider-field`
354
+ - Parts: control `.ml-slider`, track `.ml-slider-track`, range `.ml-slider-range`, thumb `.ml-slider-thumb`
355
+ - ARIA on thumb: `role` — slider; `aria-valuenow` — current value; `aria-valuemin` — minimum; `aria-valuemax` — maximum; `tabindex` — 0 unless disabled
356
+ - Keyboard:
357
+ - <kbd>ArrowRight</kbd> / <kbd>ArrowUp</kbd>: Increase by one step.
358
+ - <kbd>ArrowLeft</kbd> / <kbd>ArrowDown</kbd>: Decrease by one step.
359
+ - <kbd>Home</kbd> / <kbd>End</kbd>: Jump to the minimum or maximum.
360
+ - <kbd>PageUp</kbd> / <kbd>PageDown</kbd>: Move by a larger step.
361
+ - State changes:
362
+ - On pointer down on the track, or drag the thumb, set the value from the pointer position, snapped to the step to within min and max.
363
+
364
+ ## If you are unsure
365
+
366
+ Prefer emitting less. A plain `<button class="ml-button">` is correct; a
367
+ button with a variant this document does not list is not. When a component
368
+ needs behaviour, mark its root with `data-ml="<behaviour name>"` and let the
369
+ runtime attach, rather than writing event handlers that guess at the contract.