@zeyos/zx 2.1.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/docs/llms.md ADDED
@@ -0,0 +1,1315 @@
1
+ # Zx — agent reference
2
+
3
+ > Zx is the dependency-free, vanilla-JavaScript UI component library for ZeyOS business
4
+ > applications. ES2022 modules, WAI-ARIA-accessible native controls, semantic `--zx-*` design
5
+ > tokens (light/dark + cozy/compact), and lifecycle-safe components. Zero runtime dependencies.
6
+ > This file is the single, complete reference a coding agent needs to build UI with Zx; each
7
+ > component below is wrapped in `<!-- doc:<name> -->` markers so tools can extract a single
8
+ > component's section (`website/docs.html` renders them as each component's Reference tab).
9
+
10
+ ## Mental model
11
+
12
+ Every visual component is an ES class extending `Component`, instantiated identically:
13
+
14
+ ```js
15
+ import { Table, Dialog, Toggle } from '/assets/zx.esm.js'; // or window.zx.* from zx.global.js
16
+ const t = new Table(target, options);
17
+ ```
18
+
19
+ - **`target`** — an `Element`, a selector string, or `null`. `null` means the component creates and
20
+ **owns** its root element; retrieve it with `t.toElement()` (or `t.el`) and insert it. A non-null
21
+ target is *enhanced in place* and restored on `destroy()`.
22
+ - **Options** — a plain object; each component declares `static defaults`. Never mutate the passed
23
+ object.
24
+ - **Value/data** — set via constructor options and mutated via methods (`set`, `setData`, …);
25
+ read via getters/methods (`.value`, `getValues()`, …).
26
+
27
+ ## Events & lifecycle
28
+
29
+ - Subscribe with `t.on('change', fn)` (alias `addEvent`), `t.once(...)`, or an `on<type>` function
30
+ **option** (`onchange`, `onrowclick`, …). Handlers receive a `CustomEvent`; read `event.detail`
31
+ (always an object, e.g. `{ value, item }`).
32
+ - Every `emit` also dispatches a **bubbling, composed** `zx-<type>` `CustomEvent` on `t.el`, so
33
+ `t.el.addEventListener('zx-change', …)` and event delegation work.
34
+ - `t.destroy()` aborts all listeners (one AbortController), unregisters the element, and removes an
35
+ owned root. `Component.from(el)` returns the component for a root element (replaces the legacy
36
+ `el.retrieve('com')`).
37
+
38
+ ## Entry points
39
+
40
+ - ESM: `import { … } from '/assets/zx.esm.js'` (or `../src/index.js` in-repo, no build).
41
+ - Global: `<script src="/assets/zx.global.js">` → `window.zx.*`.
42
+ - Styles: load `/assets/zx.css` once.
43
+ - Build bundles with `npm run build` (esbuild). No build is needed to develop against `src/`.
44
+
45
+ ## Theming
46
+
47
+ Load `zx.css`, then set theme/density on any ancestor (usually `<html>`):
48
+
49
+ ```html
50
+ <html data-zx-theme="dark" data-zx-density="compact">
51
+ ```
52
+
53
+ Themes: `light` | `dark` | `auto` (follows OS). Density: `cozy` | `compact`. Define a product theme
54
+ by overriding **semantic** tokens under a `[data-zx-theme="name"]` selector:
55
+ `--zx-color-bg-page/surface/raised/control/hover/selected`, `--zx-color-border(-strong/-control)`,
56
+ `--zx-color-text(-muted/-placeholder)`, `--zx-color-accent(-hover)/on-accent`,
57
+ `--zx-color-danger/warning/success/info(+ -bg)`, `--zx-focus-ring`, `--zx-control-height/-radius`,
58
+ `--zx-space-*`, `--zx-radius-*`, `--zx-text-*`. Never use tier-1 palette tokens (`--zx-gray-*`,
59
+ `--zx-green-*`) or raw color literals in application/component CSS. Prefer the least-specific
60
+ override that works (global token → then, if needed, a component style).
61
+
62
+ ## Talking to ZeyOS — use `@zeyos/client`
63
+
64
+ For ZeyOS business data use the **dedicated client library** `@zeyos/client`
65
+ (`npm install @zeyos/client`) — a zero-dependency, typed OpenAPI client (accounts,
66
+ transactions/invoices, tickets, 50+ resources), OAuth2/session auth, retries, schema
67
+ introspection. It replaces the legacy `gx.zeyos.Client` / `gx.zeyos.Request`.
68
+
69
+ ```js
70
+ import { createZeyosClient, MemoryTokenStore, normalizeListResult } from '@zeyos/client';
71
+ const zeyos = createZeyosClient({
72
+ platform: 'https://cloud.zeyos.com/<instance>/',
73
+ auth: { mode: 'oauth', oauth: { tokenStore: new MemoryTokenStore({ accessToken }) } }
74
+ });
75
+ const { data } = normalizeListResult(await zeyos.api.listTransactions({ filters: { visibility: 0 }, limit: 50 }));
76
+ await zeyos.api.updateTransaction({ ID, status: 9 });
77
+ ```
78
+
79
+ Feed results straight into Zx components (Table `data`, Select `items`, Form values). See the
80
+ `website/layouts/zeyos-invoices.layout.js` layout for the reference integration. Zx also ships a minimal `Http` /
81
+ `zeyosService(service, accesskey)` for ad-hoc `remotecall`, but `@zeyos/client` is the default.
82
+
83
+ <!-- doc:zeyos -->
84
+ ## ZeyOS binding (zx-zeyos)
85
+
86
+ `zx-zeyos` is the optional schema-driven layer above `@zeyos/client`: inject a client instance and
87
+ declare a resource to generate typed Zx controls. It is a separate ESM entry at
88
+ `dist/zx-zeyos.esm.js` (or `src/zeyos/index.js` during no-build development) and is deliberately
89
+ not re-exported from `src/index.js`. The binding has zero runtime dependencies and never imports or
90
+ bundles `@zeyos/client`; the application owns client creation, authentication, and transport.
91
+
92
+ ```js
93
+ import { createZeyosClient } from '@zeyos/client';
94
+ import {
95
+ connect, dataFilterStateToFilters, zeyosForm, zeyosSelect, zeyosTable
96
+ } from '/assets/zx-zeyos.esm.js';
97
+
98
+ const client = createZeyosClient({ platform, auth });
99
+ const api = connect(client, { locale: 'en-GB' });
100
+
101
+ const list = zeyosTable(client, 'transactions', {
102
+ fields: ['transactionnum', 'account', 'date', 'netamount', 'status'],
103
+ sort: { id: 'date', dir: 'desc' },
104
+ selectable: 'multi'
105
+ });
106
+ await list.load();
107
+
108
+ const editor = zeyosForm(client, 'transactions', {
109
+ fields: ['transactionnum', 'account', 'date', 'netamount', 'status'],
110
+ onSaved: () => list.load()
111
+ });
112
+ await editor.load(transactionId);
113
+ await editor.save();
114
+ ```
115
+
116
+ - `connect(client, { onError, locale })` returns `{client, list, get, create, update, reportError}`;
117
+ generated forms/tables use the same operation discovery and default `Message.error` reporting.
118
+ - `zeyosSelect(client, resource, opts)` returns an async Zx `Select`. Options include `fields`,
119
+ `labelKey`, `valueKey`, `searchFields`, `filters`, `limit`, and normal Select options. Its list
120
+ operation receives a query built by `buildListQuery`.
121
+ - `zeyosForm(client, resource, opts)` returns
122
+ `{form, ready, load(id), save(), getForm(), destroy()}`. `fields` is an ordered allow-list;
123
+ `exclude`, `labels`, `title`, `columns`, `value|id`, and `onSaved` curate behavior. `load` maps
124
+ stored timestamps to `Date`; `save` validates and creates/updates, maps back to Unix seconds,
125
+ calls `onSaved`, and shows a success toast.
126
+ - `zeyosTable(client, resource, opts)` returns
127
+ `{table, load(), setSearch(), setFilters(), loadMore(), count, page, hasMore, destroy()}`. Columns
128
+ come from schema metadata, sorting uses `sortMode:'server'`, and page zero replaces rows while
129
+ later pages append them using server `limit`/`offset` and `count`.
130
+
131
+ Field mapping is schema-driven: entity/FK → async `zxselect`; enum/list → `optionlist` or native
132
+ `select`; indexed `date` bigint → `date`; other date/time bigints → `datetime`; money/price/numeric
133
+ → right-aligned `float`/number columns (amount columns use the row currency); boolean/checked →
134
+ `toggle` and a check column; percent/progress → numeric percent; email/tel/url → typed text; arrays
135
+ → `valuelist`; long text/JSON → `textarea`; short scalar text → `text`.
136
+
137
+ ZeyOS queries always use `filters` (plural), not `filter`. Full-text input becomes `query`; sort is
138
+ sent as signed server fields (`+field`/`-field`); projection supports aliases and dot joins; and
139
+ search/filter/sort/pagination remain server-side. ZeyOS timestamps are integer Unix seconds, so
140
+ forms convert them to/from `Date`. Use `dataFilterStateToFilters(state, defs)` before
141
+ `tableBinding.setFilters(...)` when wiring a `DataFilter`.
142
+
143
+ ### Module icons and colours
144
+
145
+ `src/zeyos/modules.js` is the configuration file for module identity — one entry per ZeyOS module,
146
+ holding the icon and the colour it is drawn in:
147
+
148
+ ```js
149
+ notes: { label: 'Notes', icon: 'zeyos-notes', color: '#008853', fa: 'note-sticky' }
150
+ ```
151
+
152
+ `icon` is the glyph's name in the ZeyOS Font Awesome kit (61 custom `zeyos-*` icons, rendered as
153
+ `<i class="fa-kit fa-zeyos-notes">`); `fa` is a stock Font Awesome fallback that also exists in
154
+ Font Awesome Free. Colours are the ZeyOS runtime palette (`ICO.Colors`) where ZeyOS defines one and
155
+ Zx defaults elsewhere; they are also emitted as `--zx-module-*` CSS custom properties, generated
156
+ from this file by `tools/build-module-tokens.js` and checked by the unit tests.
157
+
158
+ ```js
159
+ import { moduleChip, moduleColor, registerModules, useZeyosIcons } from '/assets/zx-zeyos.esm.js';
160
+
161
+ await useZeyosIcons(); // loads ZEYOS_ICON_KIT, switches Zx to Font Awesome
162
+ nav.append(moduleChip('tickets', { size: 24, title: true })); // glyph on the module colour
163
+ moduleColor('invoices'); // '#535494' — aliases resolve to their module
164
+ registerModules({ tickets: '#f04639', 'my-fork': { label: 'My Fork', icon: 'zeyos-weblets' } });
165
+ ```
166
+
167
+ - `moduleIcon(name, {size, label, standard})` → the bare glyph; `standard: true` uses the stock
168
+ Font Awesome fallback, for pages on a kit without the ZeyOS uploads.
169
+ - `moduleChip(name, {size, iconSize, label, title, standard})` → `<span class="zx-module-icon">`
170
+ with the glyph on the module colour. The glyph colour is whichever of ZeyOS's two foregrounds
171
+ (`#ffffff`, `#141414`) contrasts better, via `moduleGlyphColor(hex)`.
172
+ - `moduleInfo`/`moduleColor`/`moduleIconName`/`moduleKeys`/`normalizeModuleName` read the config.
173
+ Module, entity, and API resource names are all accepted, case-insensitively, with dots and
174
+ underscores normalized: `'transactions.billing'`, `'invoices'`, and `'Transactions Billing'` all
175
+ land on `transactions-billing`. Unknown names fall back to the `default` module — lookups never
176
+ throw.
177
+ - `registerModules(map)` adds forks and weblets or overrides shipped entries (a bare string sets
178
+ the colour only). Runtime ZeyOS menu data is authoritative: feed its colours in at startup.
179
+ <!-- /doc -->
180
+
181
+ ## Custom elements
182
+
183
+ `defineElements()` (not auto-called) registers light-DOM `<zx-*>` wrappers with attribute↔option
184
+ reflection and ElementInternals form association: `<zx-toggle>`, `<zx-check-button>`, `<zx-select>`,
185
+ `<zx-checklist>`, `<zx-datebox>`, `<zx-timebox>`, `<zx-search>`, `<zx-groupbox>`, `<zx-tabbox>`,
186
+ `<zx-table>`, `<zx-dialog>`.
187
+
188
+ ## gx compatibility
189
+
190
+ Load `zx-compat.global.js` (or import `zx-compat.esm.js`) →
191
+ `window.gx.{core,ui,util,zeyos,bootstrap}` wrapping Zx components (constructor/option/event-name
192
+ translation, MooTools-style `addEvent`). `gx.compat.installGlobals()` (opt-in) installs `__()`,
193
+ `_()`, `String.htmlSpecialChars`, and the element `store/retrieve` shim. Full namespace listing in
194
+ the "gx compatibility layer" component section below; class map and unsupported legacy APIs in
195
+ `MIGRATION.md`.
196
+
197
+ ## Conventions (editing component source)
198
+
199
+ Follow `AGENTS.md`. Key rules: never declare instance class fields for state used in `render()`
200
+ (render runs inside the base constructor before field initializers); route all DOM listeners
201
+ through `this.listen()`; no `innerHTML` outside `h.raw()`; express state via ARIA/`data-*`, not
202
+ state classes; small glyph buttons use the shared `.zx-icon-btn` utility; follow the named APG
203
+ pattern with `:focus-visible` rings and `prefers-reduced-motion` guards.
204
+
205
+ ## File map & commands
206
+
207
+ ```
208
+ src/components/<name>/ component + CSS src/core/ kernel (component, dom, http, i18n, date…)
209
+ src/compat/ gx compat layer styles/ tokens + base.css
210
+ website/ marketing page + docs.html (docs.js) specs/ per-component specs
211
+ website/demos/ per-component demo modules website/layouts/ application layout examples
212
+ tests/ node unit + smoke dist/ built bundles
213
+
214
+ npm run serve # http://127.0.0.1:8321/website/docs.html (no build)
215
+ npm test # node --test tests/unit/*.test.js + node tests/lint-tokens.js
216
+ npm run build # dist/: zx.esm.js, zx.global.js (window.zx), zx-compat.global.js (window.gx), zx.css
217
+ node tools/build-module-tokens.js # regenerate styles/tokens/modules.css from src/zeyos/modules.js
218
+ ```
219
+
220
+ ---
221
+
222
+ # Components
223
+
224
+ Each component is `new Component(target, options)`. Events are the component-level names (also
225
+ dispatched as bubbling `zx-<name>` on the root). Detail is always an object.
226
+
227
+ <!-- doc:button -->
228
+ ### Button — `button(options)` / `buttonGroup(buttons)`
229
+
230
+ Factories (not classes) returning elements, used both standalone and as the
231
+ `buttons: [{ label, kind, action|onclick }]` descriptors Dialog, Modal, Panel, and MasterPanel
232
+ accept.
233
+
234
+ - **`button(options)`** → `HTMLButtonElement`. Options: `label`, `icon` (an icon name), `kind:
235
+ 'default'|'primary'|'danger'|'ghost'`, `size: 'md'|'sm'`, `disabled`, `title`, `onclick`.
236
+ - **`buttonGroup([button(…), …])`** → a container that joins the buttons into one control.
237
+ <!-- /doc -->
238
+
239
+ <!-- doc:badge -->
240
+ ### Badge — `badge(options)` / `badgeGroup(badges)`
241
+
242
+ Factories (not classes) returning a status pill: a small, non-interactive `<span>` carrying a
243
+ semantic colour, used for stages, counts, tags, and record-header metadata. A badge with no label
244
+ but a `title` is named for assistive technology through `role="img"`; everything else is plain text
245
+ and needs no ARIA.
246
+
247
+ - **Options** — `label`, `icon` (an icon name, rendered before the label), `kind:
248
+ 'neutral'|'accent'|'success'|'warning'|'danger'|'info'`, `variant: 'soft'|'solid'|'outline'`,
249
+ `size: 'md'|'sm'`, `dot` (a leading status dot instead of an icon), `title`.
250
+ - **Methods** — none. `badge(options)` → `HTMLSpanElement`, `badgeGroup([badge(…), …])` → a wrapping
251
+ inline row with its own spacing; you own both elements.
252
+ - **Events** — none; a badge is not interactive.
253
+ <!-- /doc -->
254
+
255
+ <!-- doc:toolbar -->
256
+ ### Toolbar — APG toolbar
257
+
258
+ A single non-wrapping row of controls that is one tab stop: arrows move between the controls, and
259
+ what no longer fits collapses into a trailing overflow menu. Items are `button()` descriptors,
260
+ ready-made Elements, or `'-'` for a separator; a `ResizeObserver` remeasures the row, hides the
261
+ items that overflow — removing them from the tab order and the roving group — and mirrors them into
262
+ a `MenuButton`, where choosing one activates the original control.
263
+
264
+ - **Options** — `items: [Element | '-' | {name, label, icon, kind, size, disabled, title, active,
265
+ onclick}]`, `align: 'start'|'end'|'between'`, `label` (accessible name, default `'Toolbar'`),
266
+ `overflow` (default `true`), `overflowLabel` (default `'More'`), `dense`. `active` renders the
267
+ item as a pressed toggle (`aria-pressed`).
268
+ - **Methods** — `setItems()`, `getItem(name)`, `enable(name)`, `disable(name)`,
269
+ `setActive(name, active?)`, `destroy()`.
270
+ - **Events** — `action {name, item}`, emitted for descriptor-built items in addition to their own
271
+ `onclick`, from the row and from the overflow menu alike.
272
+ - **Keyboard** — Tab reaches the toolbar once; ArrowLeft/ArrowRight move between controls and wrap;
273
+ Home/End jump to the first and last control.
274
+ <!-- /doc -->
275
+
276
+ <!-- doc:empty-state -->
277
+ ### EmptyState — `emptyState(options)`
278
+
279
+ A factory (not a class) returning the placeholder shown where a list, table, or panel has no content
280
+ yet: an optional decorative icon, a headline, a sentence, and optional actions. The headline is a
281
+ paragraph rather than a heading, so dropping one into a Table's empty slot or a Panel body never
282
+ disturbs the page's heading outline.
283
+
284
+ - **Options** — `icon` (an icon name, `null` for none; default `'folder-open'`), `title`,
285
+ `description`, `actions: [Element | buttonOptions]`, `size: 'md'|'sm'`, `align: 'center'|'start'`.
286
+ - **Methods** — none. `emptyState(options)` → `HTMLDivElement`, which you own and place yourself.
287
+ - **Events** — none; action buttons carry their own `onclick`.
288
+ <!-- /doc -->
289
+
290
+ <!-- doc:loading -->
291
+ ### Spinner / ProgressBar / InlineLoading
292
+
293
+ Three shapes of "wait". A **spinner** when the duration is unknown and there is nothing to say
294
+ about progress; a **progress bar** when the completed share is known; **inline loading** when the
295
+ wait resolves into its own outcome in the place it started. `spinner()` is a factory returning an
296
+ element — there is no state to update and nothing to destroy — while the other two are components.
297
+
298
+ #### spinner()
299
+
300
+ - **Signature** — `spinner({size, label, showLabel, kind})` → `HTMLSpanElement`.
301
+ - **Options** — `size: 'sm'|'md'|'lg'` (16px, 24px, 40px), `label: ''`, `showLabel: false`,
302
+ `kind: 'accent'|'current'` — `current` draws the ring in `currentColor`, which is what a ring
303
+ inside a button or a badge needs.
304
+ - **Accessibility** — a `label` makes the element `role="status"` and keeps the text in the
305
+ accessibility tree even when `showLabel` is false; without a label the ring is `aria-hidden`,
306
+ which is correct whenever a nearby element already announces the wait.
307
+
308
+ #### ProgressBar
309
+
310
+ - **Options** — `value: 0`, `max: 100`, `label: ''`, `helperText: ''`,
311
+ `status: 'active'|'success'|'error'`, `indeterminate: false`, `size: 'md'|'sm'`,
312
+ `hideLabel: false`, `showValue: true`, `formatValue: (value, max) => string`.
313
+ - **Methods** — `get()`, `set(value, {silent})` (clamped to `[0, max]`), `percent()`, `setMax()`,
314
+ `setStatus(status, helperText?)`, `setLabel()`, `setHelperText()`, `setIndeterminate(flag)`.
315
+ - **Events** — `change {value, percent}`, `complete {value}` once the value reaches `max`.
316
+ - **Behaviour** — `status` is a display concern only: a run that failed at 100% still reads 100%.
317
+ An indeterminate bar drops `aria-valuenow` rather than reporting a number it does not have, and
318
+ under reduced motion its travelling band becomes a static muted track — visibly busy, never a
319
+ wrong number.
320
+ - **Accessibility** — the track is `role="progressbar"` with `aria-valuemin`, `aria-valuemax`,
321
+ `aria-valuenow`, and `aria-valuetext`, labelled by the visible label (kept for assistive
322
+ technology when `hideLabel` is set).
323
+
324
+ #### InlineLoading
325
+
326
+ - **Options** — `status: 'inactive'|'active'|'success'|'error'`, `description: ''`,
327
+ `size: 'sm'|'md'`, `live: true`.
328
+ - **Methods** — `get()`, `getDescription()`, `set(status, description?, {silent})`,
329
+ `setDescription()`.
330
+ - **Events** — `statuschange {status, description}`.
331
+ - **Accessibility** — the whole element is one polite live region (`role="status"`), so the
332
+ outcome is announced without the focus moving off the control that started the work. Use
333
+ `Message` instead when the outcome deserves a dismissible notification rather than a line of
334
+ text that stays put.
335
+ <!-- /doc -->
336
+
337
+ <!-- doc:skeleton -->
338
+ ### Skeleton
339
+
340
+ Placeholders shaped like the content that is coming, so the layout does not jump when it arrives.
341
+ That is the whole reason to prefer them over a spinner, and the reason they only pay off where the
342
+ shape is genuinely known. All three are factories returning elements.
343
+
344
+ - **Factories** — `skeleton({width, height, radius})` → one block; `skeletonText({lines, width,
345
+ heading, lastLineWidth})` → a stack of lines; `skeletonTable({rows, columns, header})` → the
346
+ grid a table will fill.
347
+ - **skeleton options** — `width: '100%'`, `height: '1rem'` (a number is read as pixels),
348
+ `radius: 'sm'|'md'|'full'`. Equal sides plus `radius: 'full'` gives the circle an avatar or icon
349
+ placeholder needs.
350
+ - **skeletonText options** — `lines: 3` (1–20), `width: '100%'`, `heading: false` (draws the first
351
+ line taller), `lastLineWidth: '60%'` — the short final line is what makes the block read as
352
+ prose rather than as a filled rectangle.
353
+ - **skeletonTable options** — `rows: 5` (1–50), `columns: 4` (1–20), `header: true`. Rendered as
354
+ plain elements, not a `<table>`: there is no data to expose, and a table whose cells are all
355
+ empty is worse for assistive technology than no table at all.
356
+ - **Accessibility** — every skeleton is `aria-hidden`, because a screen reader gains nothing from
357
+ an announced grey box. Set `aria-busy="true"` on the region being filled and remove it together
358
+ with the placeholder.
359
+ - **Behaviour** — the pulse is wrapped in `prefers-reduced-motion: no-preference`; without motion
360
+ a legible static block stays behind.
361
+ <!-- /doc -->
362
+
363
+ <!-- doc:check-button -->
364
+ ### CheckButton
365
+
366
+ Two-state pressed button on a native `<button aria-pressed>`. The check indicator has a glyph in
367
+ both states — an empty box when off, a check when on — so an unpressed or disabled button still
368
+ reads as a two-state control.
369
+
370
+ - **Options** — `label: '' | [onLabel, offLabel]`, `checked: false`, `icon: true`,
371
+ `disabled: false`.
372
+ - **Methods** — `get()`, `set(checked, {silent})`, `toggle()`, `setLabel()`, `enable()`/`disable()`.
373
+ - **Events** — `change {checked}`, fired only on a real state change.
374
+ - **Keyboard** — Space/Enter.
375
+ <!-- /doc -->
376
+
377
+ <!-- doc:toggle -->
378
+ ### Toggle
379
+
380
+ Switch (`role="switch"`, `aria-checked`).
381
+
382
+ - **Options** — `checked: false`, `value: true`, `label: null`, `disabled: false`.
383
+ - **Methods** — `get()`, `set(checked, {silent})`, `toggle()`, `getValue()` (returns
384
+ `options.value` when on, `false` when off), `enable()`/`disable()`.
385
+ - **Events** — `change {checked, value}`.
386
+ - **Keyboard** — Space/Enter.
387
+ <!-- /doc -->
388
+
389
+ <!-- doc:search -->
390
+ ### Search
391
+
392
+ Search input with embedded search and clear buttons (`role="search"`).
393
+
394
+ - **Options** — `placeholder`, `value`, `clearable: true`, `debounce: 250`.
395
+ - **Methods** — `get()`, `set(value, {silent})`, `focus()`, `clear()`.
396
+ - **Events** — `input {value}` (debounced), `submit {value}` (Enter or the button), `clear`.
397
+ <!-- /doc -->
398
+
399
+ <!-- doc:select -->
400
+ ### Select — APG editable combobox
401
+
402
+ Unifies single-select, local filtering, and async loading.
403
+
404
+ - **Options** — `items: []`, `valueKey: 'ID'` (string key or `(item)=>id`), `labelKey: 'name'`
405
+ (string or `(item)=>string`), `renderItem`, `renderValue`, `value`, `disabled`, `placeholder`,
406
+ `clearable: false`, `filter: false | 'local' | async (query)=>items`, `searchKeys`,
407
+ `minQuery: 0`, `debounce: 200`, `listHeight: 280`, `groupKey`.
408
+ - **Getters** — `.value`, `.selected`.
409
+ - **Methods** — `set(id, {silent})`, `setItems()`, `reset()`, `open()`/`close()`,
410
+ `enable()`/`disable()`, `focus()`.
411
+ - **Events** — `change {value, item}` (`item` is null on clear), `open`, `close`, `query {query}`,
412
+ `loaded {items}`.
413
+ - **Preset** — `Select.priority(target, opts)`, a 5-level priority picker.
414
+ - **Keyboard** (APG combobox) — ArrowDown/Alt+ArrowDown open; arrows navigate and wrap; Home/End;
415
+ Enter selects; Esc closes; Tab closes; printable characters filter (editable) or run a typeahead
416
+ (readonly). `aria-activedescendant` tracks the active option.
417
+ <!-- /doc -->
418
+
419
+ <!-- doc:checklist -->
420
+ ### Checklist
421
+
422
+ Searchable multi-check list with optional async loading.
423
+
424
+ - **Options** — `items`, `valueKey: 'ID'`, `labelKey: 'name'`, `checkedKey: 'on'`, `search: true`,
425
+ `height: 280`, `defaultChecked: false`, `load: async ()=>items`.
426
+ - **Methods** — `setItems()`, `getValues()`, `setValues(ids)`, `checkAll()`/`uncheckAll()`,
427
+ `search(query)`, `reload()`.
428
+ - **Events** — `change {values}`, `loaded`.
429
+ <!-- /doc -->
430
+
431
+ <!-- doc:number-field -->
432
+ ### NumberField
433
+
434
+ Numeric input with decrement/increment buttons, following the APG **spinbutton** pattern. The step
435
+ buttons are `tabindex="-1"`/`aria-hidden` pointer affordances — the input itself is the spinbutton.
436
+ Field type: `number`.
437
+
438
+ - **Options** — `value` (number|null), `min`, `max`, `step: 1`, `largeStep: 10` (PageUp/PageDown
439
+ multiplier), `precision` (decimals; derived from `step` when null), `wrap: false` (stepping past
440
+ a bound jumps to the other one), `placeholder`, `unit` (suffix rendered inside the control),
441
+ `group: false` (thousands separators while idle), `locale`, `disabled`, `readonly`, `required`,
442
+ `name`.
443
+ - **Methods** — `get()`, `set(value, {silent})` (parses strings, snaps to the step grid, clamps to
444
+ the range; `''`/null clear it), `stepUp(n)`, `stepDown(n)`, `setRange(min, max)`,
445
+ `setReadonly()`, `reset()`, `focus()`, `getInput()`, `enable()`/`disable()`.
446
+ - **Events** — `change {value}` (committed), `input {value}` (per keystroke, possibly unsnapped).
447
+ - **Keyboard** — ↑/↓ one step, PageUp/PageDown `largeStep` steps, Home/End jump to `min`/`max` when
448
+ set (otherwise they keep their caret meaning), Enter commits, wheel steps while focused.
449
+ - **Also exported** — `parseNumber(raw)` (accepts `.` and `,` as the decimal separator, ignores
450
+ grouping, returns null for junk) and `snapNumber(value, {min, max, step, precision})`.
451
+ <!-- /doc -->
452
+
453
+ <!-- doc:rating -->
454
+ ### Rating
455
+
456
+ Star rating built as an APG **radio group**: one radio per selectable step, a single tab stop,
457
+ arrow-key selection. Field type: `rating`.
458
+
459
+ - **Options** — `value: 0` (0 = unrated), `max: 5`, `allowHalf: false` (renders two radios per
460
+ symbol so every reachable value has a nameable control), `clearable: true` (re-selecting the
461
+ current value clears it), `readonly`, `disabled`, `label: 'Rating'`, `icon: 'star'`,
462
+ `labels: []` (per-step accessible names, lowest first), `showValue: false`, `count` (rating
463
+ count shown beside the value), `size: 'sm'|'md'|'lg'`.
464
+ - **Methods** — `get()`, `set(value, {silent})`, `clear()`, `reset()`, `setCount()`,
465
+ `setReadonly()`, `focus()`, `enable()`/`disable()`.
466
+ - **Events** — `change {value}`, `hover {value|null}` (pointer or focus preview; null on leave).
467
+ - **Keyboard** — ←/↓ and →/↑ step, Home selects the first step, End the last, Delete/Backspace
468
+ clear.
469
+ <!-- /doc -->
470
+
471
+ <!-- doc:slider -->
472
+ ### Slider
473
+
474
+ A bounded numeric value set by dragging — a discount rate, a threshold, a weighting. Built on a
475
+ native `<input type="range">`, which is where the whole keyboard map and the announcement come
476
+ from; everything Zx adds is chrome around it. Field type: `slider`.
477
+
478
+ - **Options** — `value: 0`, `min: 0`, `max: 100`, `step: 1`, `label: ''`, `hideLabel: false`,
479
+ `showValue: true`, `showBounds: false` (draws `min` and `max` either side of the track),
480
+ `showInput: false` (adds a number box for entering a value precisely instead of aiming at it),
481
+ `unit: ''`, `marks: null` (numbers, or `{value, label}` objects), `disabled: false`,
482
+ `readonly: false`, `size: 'md'|'sm'`, `formatValue: (value) => string`.
483
+ - **Methods** — `get()`, `set(value, {silent})`, `setRange(min, max, step?)`, `setLabel()`,
484
+ `setReadonly()`, `focus()`, `enable()`/`disable()`. `value` is also a getter/setter.
485
+ - **Events** — `input {value}` continuously while dragging, `change {value}` once the value is
486
+ committed (pointer released, or a key pressed).
487
+ - **Behaviour** — values snap to the step grid anchored at `min`, so 5…95 by 10 gives 5, 15, 25 —
488
+ not 10, 20, 30. Bounds win over the grid, so a `max` that is not a whole number of steps above
489
+ `min` stays reachable. A fractional step snaps to the decimals it carries: 0.1 gives 0.3, never
490
+ 0.30000000000000004. `stepPrecision(step)` is exported for the same arithmetic elsewhere;
491
+ snapping itself reuses `snapNumber` from NumberField.
492
+ - **Keyboard** — arrows step by `step`, Page Up/Page Down jump, Home and End go to the bounds —
493
+ all native. A read-only slider stays focusable and announced but refuses every edit, pointer and
494
+ keyboard alike; a disabled one leaves the tab order entirely.
495
+ <!-- /doc -->
496
+
497
+ <!-- doc:copy -->
498
+ ### copyButton() / CopyInput
499
+
500
+ Copying a value, and saying so. A clipboard write is silent, so both of these confirm it in place
501
+ — and stay quiet when the browser refuses. Both go through `copyToClipboard()`, which falls back
502
+ to a hidden textarea when the async Clipboard API is unavailable or denied.
503
+
504
+ #### copyButton()
505
+
506
+ - **Signature** — `copyButton({text, label, title, feedback, feedbackDuration, size, kind, oncopy})`
507
+ → `HTMLButtonElement`.
508
+ - **Options** — `text: ''` — a string, or **a function returning one**, which is what a button
509
+ beside a live value needs so the copy is never stale; `label: ''` (empty renders an icon-only
510
+ ghost button), `title: 'Copy'`, `feedback: 'Copied'`, `feedbackDuration: 2000`,
511
+ `size: 'md'|'sm'`, `kind: 'default'|'primary'|'ghost'`.
512
+ - **Events** — `oncopy(text, copied)` is called for every attempt; `copied` is `false` when the
513
+ clipboard refused the write, and the button then stays untouched rather than claiming a copy
514
+ that did not happen.
515
+ - **Accessibility** — on success the glyph becomes a tick and a visually hidden `role="status"`
516
+ speaks the confirmation, then both revert. The icon-only form carries the `title` as its
517
+ accessible name.
518
+
519
+ #### CopyInput
520
+
521
+ - **Options** — `value: ''`, `label: ''`, `hideLabel: false`, `feedback: 'Copied'`,
522
+ `buttonTitle: 'Copy'`, `size: 'md'|'sm'`.
523
+ - **Methods** — `get()`, `set(value)`, `focus()` (focuses and selects the whole value).
524
+ - **Events** — `copy {value, copied}`.
525
+ - **Behaviour** — the field is `readonly`, not `disabled`, so the text stays selectable and
526
+ reachable by keyboard; focusing it selects the whole value, which is what someone reaching for
527
+ Ctrl+C expects.
528
+ <!-- /doc -->
529
+
530
+ <!-- doc:date-picker -->
531
+ ### DatePicker / MonthPicker / TimePicker
532
+
533
+ Inline, embeddable pickers.
534
+
535
+ #### DatePicker — APG date grid
536
+
537
+ - **Options** — `value: Date|null`, `min`, `max`, `weekStart: 1`, `showWeekNumbers: false`,
538
+ `time: false`.
539
+ - **Methods** — `get()`, `set()`, `focus()`.
540
+ - **Events** — `change {date}`, `monthchange {year, month}`.
541
+ - **Keyboard** — arrows move a day, PageUp/PageDown a month, Shift+PageUp/PageDown a year,
542
+ Home/End, Enter/Space select. Day cells carry an `aria-label`.
543
+
544
+ #### MonthPicker
545
+
546
+ A 12-month grid. `get()`/`set()` work on the first of the month; event `change {date}`.
547
+
548
+ #### TimePicker
549
+
550
+ Two spinbuttons (three with `seconds`), plus an optional clock face behind a toggle button: pick an
551
+ hour on the outer (1–12) or inner (13–23, 00) ring and the dial moves on to the minutes. Every mark
552
+ is a radio in an APG radio group; the spinbuttons stay the primary control.
553
+
554
+ - **Options** — `value: null`, `seconds: false`, `step: 5` (minute increment for the arrow keys),
555
+ `clock: true`.
556
+ - **Methods** — `get()`, `set()`, `openClock(unit)`, `closeClock()`, `toggleClock()`.
557
+ - **Events** — `change {time}`.
558
+ <!-- /doc -->
559
+
560
+ <!-- doc:datebox -->
561
+ ### Datebox / DateTimeBox
562
+
563
+ Text date input with a calendar popover. `DateTimeBox(target, opts)` is a Datebox with
564
+ `time: true`, which also brings the TimePicker (and its clock face) into the popover. Format
565
+ tokens are the date-utility ones: `%d %m %Y %H %M`.
566
+
567
+ - **Options** — `value: Date | number(unix) | string`, `format: '%d.%m.%Y'`, `time: false`, `min`,
568
+ `max`, `placeholder`, `clearable: true`, `disabled`.
569
+ - **Methods** — `get(unit='date')` (`'seconds'` → unix), `set(value, {silent})` (accepts a Date;
570
+ pass Date objects, not display strings), `open()`/`close()`, `enable()`/`disable()`, `focus()`.
571
+ - **Events** — `change {date}`, `invalid {text}`, `open`, `close`.
572
+ <!-- /doc -->
573
+
574
+ <!-- doc:date-range -->
575
+ ### DateRangePicker / DateRangeBox
576
+
577
+ An inline two-month calendar for picking a start and an end day, plus the form-usable text input that wraps it. The first click opens a range and the second closes it; while an end is pending, hovering and arrow-key focus preview where the range would land, and clicking on or before the pending start restarts it there instead of producing an inverted range. Both grids behave as one roving-focus unit, so arrowing right off 31 August continues on 1 September. Range maths is exported separately as `normalizeRange`, `rangeNights`, `rangeStateOf`, and `clampRange`, so the rules can be reused and tested without a DOM.
578
+
579
+ #### DateRangePicker
580
+
581
+ - **Options** — `start: null`, `end: null` (Dates), `min: null`, `max: null`, `months: 2` (a container query drops to one month in narrow hosts), `weekStart: 1`, `showWeekNumbers: false`, `minNights: 0`, `maxNights: null`, `presets: []` (an array of `{name, label, range: () => ({start, end})}`, or `true` for the built-in Today, Yesterday, Last 7 days, Last 30 days, This month, Last month, This quarter, This year), `disabled: false`.
582
+ - **Methods** — `get()` → `{start, end}` copies, `set({start, end}, {silent})` (clamped to the bounds, scrolls the first month to the start), `clear({silent})`, `focus()`, `enable()`, `disable()`, `destroy()`.
583
+ - **Events** — `change {start, end}` once a range is complete and on clear, `select {start, end}` on every click including the half-open state where `end` is still null, `monthchange {year, month}` when the first visible month moves.
584
+ - **Keyboard** — arrows move a day, PageUp/PageDown a month, Shift+PageUp/PageDown a year, Home/End the ends of the week, Enter or Space selects, Escape abandons a half-picked range and restores the last complete one. Days outside `min`/`max` — and, once a start is pending, days that would break `minNights`/`maxNights` — stay keyboard-focusable but carry `aria-disabled="true"`. Committed days carry `data-range="start" | "middle" | "end"` and previewed days `data-range="preview"`; the picker is a `role="group"` holding one `role="grid"` per month, and the selection is announced through a visually hidden `aria-live="polite"` region ("1 – 14 August 2026, 13 nights").
585
+ - **Exported helpers** — `normalizeRange(start, end)` orders a pair at local midnight (invalid values become null), `rangeNights(start, end)` counts nights from calendar days so DST never shifts the total, `rangeStateOf(day, start, end)` → `'start' | 'middle' | 'end' | null`, `clampRange(range, {min, max, minNights, maxNights})` pulls a range inside its bounds.
586
+
587
+ #### DateRangeBox
588
+
589
+ - **Options** — everything DateRangePicker takes, plus `format: '%d.%m.%Y'`, `separator: ' – '`, `placeholder: null` (derived from the format when null), `clearable: true`. Typed text is parsed as `"<start><separator><end>"`; a lone date reads as a one-day range and a trailing separator keeps the range half open.
590
+ - **Methods** — `get(unit = 'date')` (`'seconds'` returns `{start, end}` as Unix seconds), `set(range, {silent})` accepting `{start, end}` of Dates, Unix-seconds numbers, or formatted strings, `clear({silent})`, `open()`, `close()`, `enable()`, `disable()`, `focus()`, `destroy()`.
591
+ - **Events** — `change {start, end}`, `invalid {text}` when the typed value cannot be parsed (the text stays editable and the field takes `data-state="invalid"` plus `aria-invalid`), `open`, `close`.
592
+ <!-- /doc -->
593
+
594
+ <!-- doc:timebox -->
595
+ ### Timebox
596
+
597
+ Duration input (H:MM[:SS], optional sign) — a length of time, not a time of day. For a time of day
598
+ use `TimePicker` or `DateTimeBox`.
599
+
600
+ - **Options** — `value: 0`, `unit: 'minutes'|'seconds'|'hours'`, `seconds: false`, `signed: false`,
601
+ `disabled`.
602
+ - **Methods** — `get(unit)`, `set(value, unit?, {silent})`, `enable()`/`disable()`.
603
+ - **Events** — `change {value}`, in `options.unit`.
604
+ - **Also exported** — the pure helpers `splitTime` and `joinTime`.
605
+ <!-- /doc -->
606
+
607
+ <!-- doc:message -->
608
+ ### Message — toasts, inline, progress
609
+
610
+ Floating toasts come from statics; `new Message(target)` gives an inline, non-floating area with
611
+ the same `.show()`. The toast region is `role="status"`/`aria-live` (`role="alert"` for errors).
612
+
613
+ - **Statics** — `Message.info/success/warning/error(msg, opts)`,
614
+ `Message.show(msg, { kind, timeout=4000, closable })` → `{ close() }`,
615
+ `Message.progress(text)` → `{ update(pct, text?), done(), fail(text?) }`.
616
+ - **Per-message options** — `kind: 'info'`, `timeout`, `closable: true`, `maxVisible: 5` (extras
617
+ queue, and hovering pauses dismissal).
618
+ <!-- /doc -->
619
+
620
+ <!-- doc:modal -->
621
+ ### Modal
622
+
623
+ Thin overlay on the native `<dialog>` element. The backdrop uses `--zx-color-bg-backdrop`, and
624
+ focus returns to the opener on close.
625
+
626
+ - **Options** — `content`, `width: 'auto'`, `closable: true`, `lightDismiss: false`,
627
+ `destroyOnClose: false`.
628
+ - **Methods** — `open()`, `close(result?)`, `setContent()`, `isOpen()`.
629
+ - **Events** — `open`, `close {result}`, `cancel` (Esc; preventable with `event.preventDefault()`).
630
+ <!-- /doc -->
631
+
632
+ <!-- doc:dialog -->
633
+ ### Dialog (extends Modal)
634
+
635
+ Structured modal: a header with title and close button, a body, and footer buttons.
636
+
637
+ - **Options** — `title`, `size: 'sm'|'md'|'lg'|number`,
638
+ `buttons: [{ label, kind, action: 'close'|'cancel'|fn(dialog), autofocus }]`, `closable: true`.
639
+ - **Methods** — `setTitle()`, `setContent()`, `setButtons()`,
640
+ `addView(key, { content, buttons? })`, `showView(key)`.
641
+ - **Statics returning Promises** — `Dialog.alert({ title, message })`,
642
+ `Dialog.confirm({ title, message, danger })` → boolean,
643
+ `Dialog.prompt({ title, message, value })` → string|null.
644
+ <!-- /doc -->
645
+
646
+ <!-- doc:dropdown -->
647
+ ### Dropdown
648
+
649
+ Generic anchored popover in the top layer, constructed as `new Dropdown(anchor, options)`. It
650
+ light-dismisses on an outside click or Esc, and mirrors `aria-expanded` onto the anchor.
651
+
652
+ - **Options** — `content`,
653
+ `placement: 'bottom-start'|'bottom-end'|'top-start'|'top-end'|'bottom'|'top'`, `offset: 4`,
654
+ `matchWidth: false`, `openOn: 'click'|'manual'`, `closeOnSelect: false`.
655
+ - **Methods** — `open()`, `close()`, `toggle()`, `isOpen()`, `setContent()`, `getPanel()`.
656
+ - **Events** — `open`, `close`.
657
+ <!-- /doc -->
658
+
659
+ <!-- doc:menu-button -->
660
+ ### MenuButton — APG menu button
661
+
662
+ A button that opens a `role="menu"`.
663
+
664
+ - **Options** — `label`, `icon`, `kind`,
665
+ `items: [{ label, icon?, value?, disabled?, danger?, onselect? } | '-']`, `placement`.
666
+ - **Methods** — `setItems()`, `open()`, `close()`, `setLabel()`.
667
+ - **Events** — `select {value, item}`, `open`, `close`.
668
+ - **Keyboard** — ArrowDown/Enter/Space open and focus the first item; ArrowUp opens and focuses the
669
+ last; arrows cycle; Home/End; typeahead; Enter/Space select; Esc/Tab close.
670
+ <!-- /doc -->
671
+
672
+ <!-- doc:context-menu -->
673
+ ### ContextMenu
674
+
675
+ Right-click menu for a region or a row, implementing the APG menu pattern. Attaches to an existing
676
+ element and never changes it: the menu lives in the top layer, anchored to a zero-sized element
677
+ parked at the pointer, so it flips near a viewport edge like every other floating panel.
678
+
679
+ - **Constructor** — `new ContextMenu(target, options)` where `target` is an element or selector.
680
+ Unlike most components the target is not enhanced or replaced; `destroy()` removes only the
681
+ anchor the menu created.
682
+ - **Options** — `items: []` — the same `{label, icon, value, disabled, danger, onselect}` shape
683
+ MenuButton takes, with `'-'` for a separator, **or a function** `(context) => items` called on
684
+ every opening so a row menu can disable the actions that row does not allow; returning an empty
685
+ array cancels the opening and leaves the platform menu alone. `selector: null` restricts the
686
+ menu to matching descendants and reports the matched element as the context — a table passes
687
+ `'tbody tr'` here and needs one instance, not one per row.
688
+ - **Methods** — `setItems(items)`, `openAt(x, y, context?)` (viewport coordinates),
689
+ `openAtElement(element)`, `close()`, `isOpen()`, `getContext()`.
690
+ - **Events** — `select {value, item, context}`, `open {context}`, `close`. Subscribe with
691
+ `menu.on('select', …)` or the `onselect` option: the component's root is the anchor on
692
+ `<body>`, so the bubbling `zx-select` DOM event is dispatched there and not through the
693
+ region the menu belongs to — delegation on the target will not see it.
694
+ - **Keyboard** — the Menu key and Shift+F10 open the menu at the focused element, which is the
695
+ half most pointer-only context menus miss. Arrows move between items, typing jumps to one by its
696
+ first letters, Enter and Space activate, Escape and Tab close — and closing returns focus to
697
+ wherever it came from.
698
+ <!-- /doc -->
699
+
700
+ <!-- doc:tooltip -->
701
+ ### Tooltip
702
+
703
+ A description bubble anchored to another element. The panel is a manual popover positioned by the kernel's `position()`, so it escapes clipping and stacking contexts and flips near a viewport edge; it carries `role="tooltip"` and a generated id, and the anchor gets `aria-describedby` pointing at it only while it is open — closing puts the anchor's original value back, including no value at all. The bubble is never interactive: `pointer-events: none` keeps it from stealing the hover it depends on, and nothing inside it can take focus. Hover opens it after `delay`; keyboard focus opens it immediately, because a keyboard user cannot hover a moment longer; a touch pointer never opens it, since a bubble with no "move away" gesture would simply stick. Only one is ever visible at a time, which falls out of the dismissal rules rather than a registry.
704
+
705
+ - **Options** — `content` (string, Node, or a function re-evaluated on every open), `placement` (`'top'` default, plus `bottom`, `top-start`, `top-end`, `bottom-start`, `bottom-end`), `offset` (6), `delay` (400), `hideDelay` (80), `maxWidth` (260; a number is pixels, a string is any CSS length), `trigger` (`'both'` default, or `'hover'`, `'focus'`, `'manual'` — `manual` installs no listeners at all and leaves everything to the methods), `disabled` (false).
706
+ - **Methods** — `show()`, `hide()`, `toggle()`, `isOpen()`, `setContent(content)`, `enable()`, `disable()`, `isDisabled()`, `getAnchor()`, `destroy()`. Convenience factories: `tooltip(anchor, contentOrOptions)` takes either a content value or a full option object, and `describe(anchor, text)` is the one-liner replacement for a `title` attribute. Both return the instance.
707
+ - **Events** — `open`, `close`. Both fire only on a real transition, so a `show()` on an open tooltip is silent.
708
+ - **Keyboard** — Focusing the anchor opens the tooltip at once; moving focus away closes it. `Escape` closes it without moving focus, satisfying WCAG 1.4.13. A `pointerdown` anywhere closes it, and focus that arrives with a click does not re-open it.
709
+ <!-- /doc -->
710
+
711
+ <!-- doc:split-view -->
712
+ ### SplitView
713
+
714
+ Two panes with a divider the user owns. The layout is a three-track CSS grid driven by one custom property, `--zx-split-size`, so a drag writes a single style value per frame and the browser does the rest. That property holds whatever was asked for: `38%` stays a percentage and keeps tracking the container until someone drags, at which point it becomes pixels and stays put. The divider follows the WAI-ARIA window splitter pattern — a focusable `separator` with live pixel `aria-valuenow`/`aria-valuemin`/`aria-valuemax` — and a `ResizeObserver` re-clamps the size when the container shrinks, bailing on a container that measures zero (a hidden tab, a detached node) rather than flattening a good size to nothing. A split view given an existing element with children adopts the first two as its panes and hands them back on `destroy()`.
715
+
716
+ - **Options** — `orientation` (`'horizontal'` default: panes side by side behind a vertical divider; `'vertical'` stacks them), `start` / `end` (Node, Component, string, or null), `size` (`'38%'`; a number is pixels, a string is any CSS length), `min` (160), `max` (null — "whatever the container allows"), `collapsible` (`false`, `'start'`, or `'end'`), `collapsed` (false, true, or a pane name), `snap` (0 — a radius in pixels within which a settled drag lands exactly on the initial size, the minimum, or the maximum), `storageKey` (null; storage failures degrade to memory rather than throwing), `disabled` (false), `label` (`'Resize panes'`).
717
+ - **Methods** — `setSize(value)`, `getSize()` (pixels), `getRatio()`, `setStart(content)`, `setEnd(content)`, `collapse(which)`, `expand()`, `isCollapsed()` (returns the pane name or false), `enable()`, `disable()`, `isDisabled()`, `destroy()`. The setters are programmatic and emit nothing.
718
+ - **Events** — `resize` `{size, ratio}`, throttled to at most once per animation frame during a drag; `resizeend` `{size, ratio}` once an interaction settles (a drag ends, a key moves the divider, a double-click resets it); `collapse` `{pane}`; `expand`. A press that never moved is a click, not a resize, and reports nothing.
719
+ - **Keyboard** — With the divider focused: `←` / `→` (horizontal) or `↑` / `↓` (vertical) move it 16px, `Shift` with an arrow moves 64px, `Home` goes to the minimum, `End` to the maximum, and `Enter` or `Space` folds a collapsible pane away and brings it back. Any key that moves the divider expands a collapsed pane first. Double-clicking the divider resets it to the initial `size`.
720
+ <!-- /doc -->
721
+
722
+ <!-- doc:table -->
723
+ ### Table
724
+
725
+ Sortable, selectable, sticky-header data table — one component covering both the legacy
726
+ `gx.ui.Table` and `SimpleTable`. `fr` widths fill the container while px/auto widths scroll
727
+ horizontally, and multi-select adds a tri-state header checkbox plus Shift+click range select.
728
+
729
+ - **Options** — `columns: [{ id, label, sortable?, width ('120px'|'2fr'|'auto'), align,
730
+ render?: (row,i)=>Node|string, sortValue?, headerTitle? }]`, `data`, `rowId: 'ID'`,
731
+ `sort: { id, dir }`, `sortMode: 'local'|'server'`, `selectable: false|'single'|'multi'`,
732
+ `stickyHeader: true`, `height`, `emptyText`, `rowClass`, `zebra: true`.
733
+ - **Methods** — `setData()`, `addData()`, `updateRow(id,row)`, `removeRow(id)`, `getRow(id)`,
734
+ `getData()`, `empty()`, `setSort(id,dir)`, `getSelection()`, `setSelection(ids)`,
735
+ `clearSelection()`, `setLoading(bool)` (busy/skeleton state — dims rows, shows an indeterminate
736
+ top bar, sets `aria-busy`, and is cleared by the next `setData`).
737
+ - **Events** — `rowclick {row, id, index, event}`, `rowdblclick`, `sort {id, dir}`,
738
+ `selectionchange {rows, ids}`, `datachange {rows}`.
739
+ - **Editing** — `editMode: false|'cell'|'row'` (default `false`, and completely inert until set).
740
+ `'cell'` edits one cell, `'row'` opens every editable cell of the row and commits them as a unit.
741
+ Editors are sized into the cell without changing row height, and an editing cell carries
742
+ `data-editing="true"` (`data-invalid="true"` plus an `aria-live` message when a validator rejects).
743
+ - **Editable columns** — extra column keys: `editable: false|true|'text'|'number'|'select'|'date'|
744
+ 'checkbox'|'textarea'|((row)=>boolean)` (`true` means `'text'`; a function is evaluated per row, so
745
+ individual rows stay read-only), `options` for `'select'` (`[{value,label}]`, a `{value: label}` map
746
+ whose keys are always strings, or `(row)=>items`), `editorProps` (forwarded to the underlying
747
+ NumberField/Select/Datebox/Toggle or to the generated `<input>`/`<textarea>`),
748
+ `editor: (row, api)=>Node` for a fully custom editor (`api` is `{value, commit(value), cancel(),
749
+ row, column}`; it wins over `editable`, but a per-row `editable` function returning `false` still
750
+ marks the cell read-only), `parse: (raw,row)=>value` and `format: (value,row)=>string` (`format`
751
+ seeds the text of a `'text'`/`'textarea'` editor, `parse` converts every editor's raw output back),
752
+ and `validate: (value,row)=>true|string` (a returned string marks the cell invalid, shows the
753
+ message, and refuses the commit).
754
+ - **Editing methods** — `startEdit(rowId, columnId)`, `commitEdit()`, `cancelEdit()`, `isEditing()`,
755
+ `getEditing()` → `{id, columnId}|null`.
756
+ - **Editing events** — `editstart {row, id, column, columnId, value}`;
757
+ `editcommit {row, id, column, columnId, value, previous, changes}` — **cancelable**:
758
+ `preventDefault()` on the component event (`table.on('editcommit', …)` or the `oneditcommit` option,
759
+ not the mirrored `zx-editcommit` DOM event) keeps the editor open so a server round-trip can reject
760
+ the change (`changes` is the `{columnId: value}` map and holds every changed cell in row mode);
761
+ `editcancel {row, id, column, columnId}`;
762
+ `editinvalid {row, id, column, columnId, value, message}`. A committed change is written
763
+ back through `updateRow()`, so `datachange` fires exactly as for a programmatic row update — a
764
+ commit with no changes emits `editcommit` with an empty `changes` map and leaves the data alone.
765
+ Sorting, filtering, or `setData()` while editing cancels the edit cleanly and emits `editcancel`.
766
+ - **Editing keyboard** — Enter or F2 on a focused editable cell starts editing (editable cells share
767
+ one roving tab stop), double-click starts editing without emitting `rowdblclick`, Enter commits and
768
+ returns focus to the cell (Shift+Enter stays a newline in a `'textarea'`, Space toggles a
769
+ `'checkbox'`), Escape cancels and returns focus to the cell, Tab commits and moves to the next
770
+ editable cell (Shift+Tab the previous), wrapping across rows, and tabbing past the last editable
771
+ cell commits and leaves the table normally. Clicking outside the editor commits rather than
772
+ discarding what was typed.
773
+ <!-- /doc -->
774
+
775
+ <!-- doc:data-filter -->
776
+ ### DataFilter
777
+
778
+ Declarative client-side filter bar producing a filtered array, commonly wired to a Table with
779
+ `onfilter: (e) => table.setData(e.detail.rows)`.
780
+
781
+ - **Options** — `filters: [{ type: 'select'|'text'|'custom', id, label, field(s)|get, options?,
782
+ predicate?, emptyLabel?, placeholder? }]`, `data`, `autoApply: true`, `clearLabel`.
783
+ - **Methods** — `setData()`, `apply()` → rows, `clear()`, `getState()`, `setState()`,
784
+ `addFilter()`.
785
+ - **Events** — `filter {rows, state}`.
786
+ <!-- /doc -->
787
+
788
+ <!-- doc:tree -->
789
+ ### TreeView
790
+
791
+ Hierarchical tree implementing the APG **tree** pattern. Rows render as a flat list carrying
792
+ `aria-level`/`aria-setsize`/`aria-posinset` (the flattened variant the APG allows) rather than
793
+ nested `role="group"` containers. The component clones `items`, so lazily loaded children never
794
+ mutate the caller's data.
795
+
796
+ - **Options** — `items` (nested), `valueKey: 'ID'`, `labelKey: 'name'`, `childrenKey: 'children'`,
797
+ `expanded: []`, `selection: 'single'|'multi'|false`, `selected: []`, `checkboxes: false`
798
+ (tri-state), `checked: []`, `icons: true`, `renderLabel`, `load: (node)=>Promise<children>`
799
+ (lazy children; mark a branch with `hasChildren: true`), `filter`, `emptyText`, `height`.
800
+ - **Methods** — `setItems()`, `getItems()`, `getNode(id)`, `getPath(id)`, `setFilter(query)` (keeps
801
+ matching nodes' ancestors and opens the surviving branches),
802
+ `expand(id)`/`collapse(id)`/`toggle(id)` (async — they await the loader),
803
+ `expandAll()`/`collapseAll()`, `reveal(id)`, `select(id, {additive})`,
804
+ `getSelection()`/`setSelection()`/`clearSelection()`, `check(id, checked)`,
805
+ `getChecked({leavesOnly})`, `setChecked()`, `focusNode(id)`.
806
+ - **Events** — `select {node, id, ids}`, `expand`, `collapse`, `activate {node, id}` (Enter or
807
+ double-click), `check {ids, node}`.
808
+ - **Keyboard** — ↑/↓ move, → expands then descends, ← collapses then ascends, Home/End, Enter
809
+ activates, Space selects or toggles the checkbox, `*` expands every sibling, typing jumps to the
810
+ next match.
811
+ <!-- /doc -->
812
+
813
+ <!-- doc:finder -->
814
+ ### Finder
815
+
816
+ Miller-columns hierarchy browser: each column lists the children of the row selected in the column
817
+ to its left, like the macOS Finder's column view. The columns are linked listboxes sharing **one**
818
+ tab stop. The constructor applies `path` silently, so read `getNodes()` for the first paint of any
819
+ breadcrumb.
820
+
821
+ - **Options** — `items` (the same nested shape as `TreeView`), `valueKey`, `labelKey`,
822
+ `childrenKey`, `path: []` (initial selection, root first), `load: (node)=>Promise<children>`,
823
+ `preview: (node)=>Node|null` (renders a pane after the last column for a selected leaf),
824
+ `rootLabel`, `columnWidth: 220`, `height: 320`, `icons: true`, `renderItem`, `emptyText`.
825
+ - **Methods** — `getPath()`, `getNodes()`, `getSelection()`, `setPath(ids, {silent, focus})`
826
+ (async — loads every branch along the way), `reveal(id)`, `setItems()`, `focus()`.
827
+ - **Events** — `change {path, nodes, node}`, `activate {node, id}`.
828
+ - **Keyboard** — ↑/↓ move within the active column, → steps into the selected branch and lands on
829
+ its first row, ← returns to the parent column and truncates the path, Home/End, Enter opens a
830
+ leaf, typing jumps to the next match.
831
+ <!-- /doc -->
832
+
833
+ <!-- doc:form -->
834
+ ### Form / Fieldset / Field
835
+
836
+ #### Form
837
+
838
+ - **Options** — `fieldsets: []`, `actions: [button descriptors]`, `novalidate: true`.
839
+ - **Methods** (proxied across the fieldsets) — `getValues()`, `setValues()`, `getField()`,
840
+ `setValue()`, `getValue()`, `reset()`, `setHighlights()`, `clearHighlights()`, `addFieldset()`,
841
+ `setActions()`, `submit()`.
842
+ - **Events** — `submit {values}` (preventable; required/int/float validation runs first),
843
+ `invalid {errors}`, `change {id, value}`.
844
+
845
+ #### Fieldset
846
+
847
+ - **Options** — `title`, `columns: 1|2|3`, `fields: { id: Field-options }`.
848
+ - **Methods** — `addField(id, opts)`, `getField`/`hasField`/`getFields`,
849
+ `getValues`/`setValues`/`getValue`/`setValue`, `reset`, `clear`, `focus(id)`,
850
+ `setHighlights`/`clearHighlights`.
851
+
852
+ #### Field
853
+
854
+ - **Options** — `id`, `type: 'text'`, `label`, `description`, `value`, `placeholder`, `required`,
855
+ `disabled`, `options`, `layout: 'stack'|'inline'`, `props: {}`.
856
+ - **Built-in types** — `text`, `password`, `textarea`, `checkbox`, `int`, `float`, `select`,
857
+ `optionlist`, `hidden`, `html`.
858
+ - **Methods** — `getValue`/`setValue`, `focus`, `reset`, `setDisabled`,
859
+ `setHighlight(msg, kind)`, `clearHighlight`, `getInput`, `own(child)`, plus the statics
860
+ `Field.register(type, adapter)` and `Field.has(type)`.
861
+ - **Events** — `change {value}`, `invalid {message}`.
862
+ <!-- /doc -->
863
+
864
+ <!-- doc:form-widgets -->
865
+ ### Field widget types
866
+
867
+ Beyond the built-in field types, these widget types wrap whole components, used as
868
+ `{ type, label, props: {…component options…} }`. They are registered by
869
+ `registerFieldAdapters()`, which `src/index.js` calls by default.
870
+
871
+ - `zxselect` (Select), `checklist` (Checklist), `tagpicker` (TagPicker), `number` (NumberField),
872
+ `rating` (Rating).
873
+ - `date`/`month`/`datetime` (Datebox/MonthPicker — pass a `Date` value), `time` (Timebox).
874
+ - `valuelist` (ValueList), `multivalueeditor` (MultiValueEditor), `upload` (FieldUpload),
875
+ `toggle` (Toggle).
876
+ <!-- /doc -->
877
+
878
+ <!-- doc:value-list -->
879
+ ### ValueList
880
+
881
+ Tag/chip input. Enter adds a value, Backspace on an empty input removes the last one, and dragging
882
+ or Ctrl+Arrow reorders.
883
+
884
+ - **Options** — `values: []`, `placeholder`, `deletable: true`, `sortable: true`, `unique: true`,
885
+ `validate: (str)=>bool|string`.
886
+ - **Methods** — `getValues()`, `setValues()`, `addValue()`, `removeValue()`, `focus()`,
887
+ `enable()`/`disable()`.
888
+ - **Events** — `change {values}`, `add {value}`, `remove {value}`.
889
+ <!-- /doc -->
890
+
891
+ <!-- doc:multi-value-editor -->
892
+ ### MultiValueEditor
893
+
894
+ Ordered value editor with explicit rows (add / remove / move).
895
+
896
+ - **Options** — `values`, `options` (allowed values, which turns the rows into selects),
897
+ `addLabel`.
898
+ - **Methods** — `getValues()`, `setValues()`.
899
+ - **Events** — `change {values}`.
900
+ <!-- /doc -->
901
+
902
+ <!-- doc:field-upload -->
903
+ ### FieldUpload
904
+
905
+ Click/drop file upload with progress. The drop zone is keyboard-accessible and progress comes from
906
+ `XMLHttpRequest`.
907
+
908
+ - **Options** — `url`, `paramName: 'upload'`, `params`, `headers`, `multiple: false`, `accept`,
909
+ `maxSize`, `autoUpload: true`, `preview: true`, `http`.
910
+ - **Methods** — `upload(files?)`, `abort()`, `clear()`, `setDisabled()`.
911
+ - **Events** — `select {files}`, `progress {percent}`, `success {response}`, `error {error}`,
912
+ `abort`.
913
+ <!-- /doc -->
914
+
915
+ <!-- doc:tag-picker -->
916
+ ### TagPicker
917
+
918
+ Multi-select combobox that renders its selection as removable tags inside the control (APG combobox
919
+ plus multi-selectable listbox). Use it when the catalogue is too large to show in full: `Checklist`
920
+ shows a fixed set, and `ValueList` takes free text with no catalogue. Unknown IDs passed to
921
+ `setValues` still render as tags, so a stored selection survives a catalogue that has not loaded
922
+ yet. Field type: `tagpicker`.
923
+
924
+ - **Options** — `items`, `values: []`, `valueKey: 'ID'`, `labelKey: 'name'`, `searchKeys`,
925
+ `filter: 'local'|(query)=>Promise<items>`, `minQuery: 0`, `debounce: 200`, `allowCreate: false`,
926
+ `max` (maximum tags; further options become `aria-disabled`), `closeOnSelect: false`,
927
+ `placeholder`, `listHeight: 260`, `disabled`, `readonly`, `renderItem`, `renderTag`.
928
+ - **Methods** — `getValues()`, `getItems()`, `setValues(values, {silent})`, `addValue()`,
929
+ `removeValue()`, `clear()`, `reset()`, `setItems()`, `isFull()`, `open()`/`close()`, `focus()`,
930
+ `setReadonly()`, `enable()`/`disable()`.
931
+ - **Events** — `change {values, items}`, `add {value, item}`, `remove {value, item}`,
932
+ `create {value, item}`, `query {query}`, `open`, `close`.
933
+ - **Keyboard** — ↓ opens and moves, ↑ moves, Enter toggles the active option (or creates one from
934
+ the query), Escape closes, Backspace on an empty input removes the last tag, Home/End jump
935
+ within the list.
936
+ <!-- /doc -->
937
+
938
+ <!-- doc:permission -->
939
+ ### Permission
940
+
941
+ Private / public / group record-permission selector.
942
+
943
+ - **Options** — `value: true|false|groupId`, `groups: []`, `groupsValueKey: 'ID'`,
944
+ `groupsLabelKey: 'name'`.
945
+ - **Methods** — `get()` → `'private'|'public'|groupId`, `set(value)`.
946
+ - **Events** — `change {value}`.
947
+ <!-- /doc -->
948
+
949
+ <!-- doc:groupbox -->
950
+ ### Groupbox
951
+
952
+ Collapsible titled section built on the native `<details>` element.
953
+
954
+ - **Options** — `title`, `open: true`.
955
+ - **Methods** — `open()`, `close()`, `toggle()`, `isOpen()`, `setTitle()`, `setContent()`.
956
+ - **Events** — `open`, `close`.
957
+ <!-- /doc -->
958
+
959
+ <!-- doc:panel -->
960
+ ### Panel / MasterPanel
961
+
962
+ #### Panel
963
+
964
+ Framed, optionally collapsible section. Its header is a row rather than one big button: the chevron
965
+ and title form the collapse control and `buttons` sit at the trailing edge beside it, so clicking
966
+ an action never collapses the panel. The footer bar appears as soon as it has `footer` content,
967
+ `footerButtons`, or both, and its buttons are right-aligned.
968
+
969
+ - **Options** — `title`, `content`, `open: true`, `collapsible: true`, `buttons: []` (elements or
970
+ `button()` descriptors, right-aligned in the header), `footer`, `footerButtons: []`
971
+ (right-aligned in the footer).
972
+ - **Methods** — `setTitle()`, `setContent()`, `setButtons()`, `setFooter()`, `setFooterButtons()`,
973
+ `open()`/`close()`/`toggle()`, `isOpen()`.
974
+ - **Events** — `open`, `close`.
975
+
976
+ #### MasterPanel
977
+
978
+ Full-height application panel with a fixed header (title plus action buttons) and an optional
979
+ fixed footer, while the body scrolls.
980
+
981
+ - **Options** — `title`, `content`, `buttons: []`,
982
+ `module: <ZeyOS module name → accent token>`, `footer`.
983
+ - **Methods** — `setTitle()`, `setContent()`, `setButtons()`, `setFooter()`.
984
+ <!-- /doc -->
985
+
986
+ <!-- doc:layout -->
987
+ ### stack() / grid() / aspect()
988
+
989
+ The small layout pieces that sit under Panel, SplitView, and MasterPanel: consistent spacing, a
990
+ grid that reflows on its own width, and a fixed-ratio box. Zx ships no page grid on purpose — an
991
+ ERP screen is a shell of panels, and those components already own that layer. All three are
992
+ factories returning elements, and all three are plain classes that work just as well in static
993
+ markup with no JavaScript.
994
+
995
+ - **Factories** — `stack(options, ...children)`, `grid(options, ...children)`,
996
+ `aspect(options, ...children)`; the classes are `.zx-stack`, `.zx-grid`, `.zx-aspect`.
997
+ - **stack options** — `direction: 'column'|'row'`, `gap: 4` (1–8 picks the matching `--zx-space-*`
998
+ step; any other value is used as a CSS length), `align: 'start'|'center'|'end'|'stretch'|
999
+ 'baseline'`, `justify: 'start'|'center'|'end'|'between'|'around'`, `wrap: false`,
1000
+ `inline: false`, `class: ''`.
1001
+ - **grid options** — `columns: null` (the count at full width; null fills each row with as many
1002
+ `min`-wide tracks as fit), `min: '16rem'` (a number is read as pixels), `gap: 4`,
1003
+ `align: 'start'|'center'|'end'|'stretch'`, `class: ''`.
1004
+ - **aspect options** — `ratio: '16 / 9'`, `fit: 'cover'|'contain'|'fill'`, `class: ''`.
1005
+ - **Behaviour** — the grid reflows intrinsically: each track asks for its share of the row but
1006
+ never goes below `min`, so `auto-fit` drops one column at a time as the container narrows. No
1007
+ media query is involved, which is why a grid behaves the same in a full-width page, a split
1008
+ pane, and a modal — the usual reason a "responsive" grid still breaks inside a narrow panel.
1009
+ - **Breakpoints** — `breakpoints` (`sm: 480`, `md: 768`, `lg: 1024`, `xl: 1280`),
1010
+ `breakpointOf(width)` → `'xs'|'sm'|'md'|'lg'|'xl'`, `matchBreakpoint(name, width)` (the script
1011
+ equivalent of a `min-width` query), and `onBreakpoint(handler, {target})` → `{current, destroy}`,
1012
+ which fires immediately and then on every crossing. `target` defaults to the window but takes an
1013
+ **element**, watched with a `ResizeObserver` — inside a split pane the viewport width says
1014
+ nothing about the space actually available. The scale lives in JavaScript rather than as tokens
1015
+ because a CSS custom property cannot be used inside a media query.
1016
+ <!-- /doc -->
1017
+
1018
+ <!-- doc:tabbox -->
1019
+ ### Tabbox — APG tabs
1020
+
1021
+ Four appearances, all square-cornered. `divided` (the default) sets flat blocks on a muted track,
1022
+ split by hairline dividers, above a bordered panel, with the active block lifted onto the panel
1023
+ surface under an accent rule. `bracket` outlines folder tabs that fuse into that same panel, the
1024
+ active tab bridging the seam. `line` underlines the active tab across a full-width rule, for page-
1025
+ and section-level navigation. `segmented` renders a compact group on a muted fill with the active
1026
+ tab raised onto a control surface, so the row reads as one control inside a toolbar or card header.
1027
+
1028
+ `divided` and `bracket` draw the panel box themselves; `line` and `segmented` leave the panel bare
1029
+ for the surrounding layout to frame. The boxed variants read `--zx-tabbox-radius` (default `0px`)
1030
+ from the component root, so `el.style.setProperty('--zx-tabbox-radius', 'var(--zx-radius-lg)')`
1031
+ restores rounded corners without forking the stylesheet.
1032
+
1033
+ - **Options** — `tabs: [{ name, title, content, icon?, closable?, disabled? }]`, `active`,
1034
+ `variant: 'divided'|'bracket'|'line'|'segmented'`, `keepAlive: true`.
1035
+ - **Methods** — `addTab()`, `removeTab()`, `openTab(name)`, `getActive()`, `setTitle()`,
1036
+ `setBadge(name, text|null)`, `enableTab()`/`disableTab()`.
1037
+ - **Events** — `change {name, previous}` (preventable), `close {name}`.
1038
+ - **Keyboard** — ArrowLeft/Right and Home/End move focus, Enter/Space activate, Delete closes a
1039
+ closable tab.
1040
+ <!-- /doc -->
1041
+
1042
+ <!-- doc:stepper -->
1043
+ ### Stepper
1044
+
1045
+ Linear progress through a multi-step flow: a numbered `<ol>` where each step is upcoming, active,
1046
+ complete, or errored. It promotes the markup the checkout and record wizards used to hand-roll.
1047
+ Advancing past a step marks it complete automatically, and only steps the `clickable` policy allows
1048
+ are rendered as real buttons — the rest are not focusable at all.
1049
+
1050
+ - **Options** — `steps: [{name, title, description?, optional?, disabled?}]`, `active` (defaults to
1051
+ the first enabled step), `orientation: 'horizontal'|'vertical'`, `clickable:
1052
+ 'completed'|'all'|false` (default `'completed'`, so users can go back but not skip ahead),
1053
+ `showNumbers: true`, `counter: false` (adds a "Step 2 of 4" line).
1054
+ - **Methods** — `setSteps()`, `goTo(name)`, `next()`, `previous()`, `getActive()`, `getIndex()`,
1055
+ `complete(name)`/`uncomplete(name)`, `setError(name, boolean)`, `setDisabled(name, boolean)`,
1056
+ `getState()` → `{active, index, completed, errored}`, `destroy()`.
1057
+ - **Events** — `change {name, previous, index}`, **preventable** with `event.preventDefault()` so a
1058
+ step can refuse to be left until its form validates.
1059
+ - **Semantics** — the active step carries `aria-current="step"`; every step carries
1060
+ `data-state="upcoming"|"active"|"complete"|"error"`, and complete and errored steps swap the
1061
+ number for a `check` or `warning` glyph.
1062
+ <!-- /doc -->
1063
+
1064
+ <!-- doc:breadcrumb -->
1065
+ ### Breadcrumb
1066
+
1067
+ The trail above a hierarchy view. It pairs directly with `Finder` and `TreeView`: feed
1068
+ `getNodes()` from a Finder's `change` event into `setItems()` and the two stay in step. When
1069
+ `maxVisible` is exceeded the middle collapses behind an ellipsis `MenuButton`, always keeping the
1070
+ first and last items.
1071
+
1072
+ - **Options** — `items: [{name, label, icon?, href?}]`, `maxVisible: 0` (0 shows all),
1073
+ `separator: 'chevron'|'slash'`, `rootIcon: null`.
1074
+ - **Methods** — `setItems()`, `getItems()`, `push(item)`, `pop()`, `truncateTo(name)`, `destroy()`.
1075
+ - **Events** — `select {name, item, index}`. An item with an `href` renders as a real `<a>` and its
1076
+ default is left alone, so ordinary navigation and middle-click still work; without one it renders
1077
+ as a `<button>` and only the event fires.
1078
+ - **Semantics** — `<nav aria-label="Breadcrumb"><ol>`; the last item is plain text carrying
1079
+ `aria-current="page"` and is deliberately not interactive; separators are `aria-hidden`.
1080
+ <!-- /doc -->
1081
+
1082
+ <!-- doc:pagination -->
1083
+ ### Pagination
1084
+
1085
+ The pager for a server-backed list, shaped to drop straight onto `Table` with
1086
+ `sortMode: 'server'` or onto the `zeyosTable` binding — `getState()` hands back the `offset` and
1087
+ `pageSize` a query needs. `page` is 1-based and always clamped into `[1, pages]`; a `total` of 0 is
1088
+ treated as one empty page rather than zero pages.
1089
+
1090
+ - **Options** — `page: 1`, `pageSize: 25`, `total: 0`, `pageSizes: [25, 50, 100]`, `siblings: 1`,
1091
+ `boundaries: 1`, `showPageSize: true`, `showSummary: true` (renders "26–50 of 312"),
1092
+ `mode: 'pages'|'loadmore'`, `disabled: false`.
1093
+ - **Methods** — `setPage(n)`, `setTotal(n)`, `setPageSize(n)`, `setState({page, pageSize, total},
1094
+ {silent})`, `getState()` → `{page, pageSize, total, pages, offset}`, `enable()`/`disable()`,
1095
+ `destroy()`.
1096
+ - **Events** — `change {page, pageSize, offset, total, pages}`, fired for a page change, a
1097
+ page-size change (which resets to page 1), and Load more.
1098
+ - **Also exported** — `paginationRange({page, pages, siblings, boundaries})` → `Array<number|'…'>`,
1099
+ the pure page-window calculation, usable on its own.
1100
+ - **Semantics** — `<nav aria-label="Pagination">`; the current page carries `aria-current="page"`;
1101
+ previous/next are labelled icon buttons that go `disabled` at the ends; the ellipses are
1102
+ `aria-hidden` spans, not buttons.
1103
+ <!-- /doc -->
1104
+
1105
+ <!-- doc:navigation-bar -->
1106
+ ### NavigationBar
1107
+
1108
+ Application navigation bar: brand, items, and right-aligned actions. Items overflow into a "More"
1109
+ menu on narrow widths.
1110
+
1111
+ - **Options** — `title`, `items: [{ name, title, badge? }]`, `active`, `actions: []`.
1112
+ - **Methods** — `setTitle()`, `setItems()`, `setActive()`, `setBadge()`, `setActions()`.
1113
+ - **Events** — `change {name}`.
1114
+ <!-- /doc -->
1115
+
1116
+ <!-- doc:kernel -->
1117
+ ### Core helpers
1118
+
1119
+ - **Component** — the base class: `on`/`off`/`once`/`emit`, `listen`, `toElement`, `msg`,
1120
+ `destroy`, and the static `Component.from(el)`.
1121
+ - **DOM** — `h(tag, props, ...children)`, `h.raw(html)`, `htmlEscape`, `resolveElement`.
1122
+ - **Icons** — `icon(name, {size, label})` and `icons`; see the Icons section — bundled inline SVG
1123
+ by default, Font Awesome after an opt-in.
1124
+ - **Positioning** — `position(anchor, floating, {placement, offset, flip, matchWidth})` →
1125
+ `{update, destroy}`. It anchors a manual popover with CSS anchor positioning where the browser
1126
+ supports it and a scroll/resize-tracked fallback where it does not. Options are resolved once,
1127
+ so changing a placement means `destroy()` and a fresh `position()` call.
1128
+ - **i18n** — `setTranslator`, `setLanguage`, `getLanguage`, `translate`, `printf`.
1129
+ - **Dates** — `formatDate(d, fmt)`, `parseDate(s, fmt)` (tokens
1130
+ `%d %m %Y %y %H %M %S %a %A %b %B %s`), `clampDate`, `isSameDay`, `addDays`, `addMonths`,
1131
+ `getWeekStart`.
1132
+ - **Keyboard** — `focusTrap`, `rovingTabindex`, `typeahead`.
1133
+ - **Utils** — `debounce`, `uid`, `deepMerge`, `isElement`, `clamp`, `toArray`.
1134
+ <!-- /doc -->
1135
+
1136
+ <!-- doc:icons -->
1137
+ ### Icons — bundled glyphs or Font Awesome
1138
+
1139
+ `icon(name, {size, label, class, style, family, fixedWidth})` returns an element: an inline `<svg>`
1140
+ from the bundled set, or an `<i>` carrying Font Awesome classes. Both get `class="zx-icon"`, so
1141
+ component CSS styles either. Zx renders the bundled glyphs by default and **never loads anything on
1142
+ its own** — Font Awesome is an opt-in an application makes.
1143
+
1144
+ ```js
1145
+ import { loadFontAwesome, useFontAwesome, registerIcons } from '/assets/zx.esm.js';
1146
+
1147
+ await loadFontAwesome({ kit: 'ae8320b210', style: 'duotone' }); // injects the kit, switches over
1148
+ useFontAwesome(); // page already carries Font Awesome; no network
1149
+ registerIcons({ rocket: ['0 0 512 512', 'M…'] }); // extend the bundled inline set
1150
+ ```
1151
+
1152
+ - `loadFontAwesome(kit | {kit, style, family, fixedWidth, activate})` — injects the kit script once
1153
+ per URL (a kit token expands to `https://kit.fontawesome.com/<token>.js`), adopts a kit the page
1154
+ already embeds, then switches the renderer. Rejects if the script fails; kits only load on the
1155
+ domains configured in the Font Awesome account.
1156
+ - `useFontAwesome(opts)` / `useBuiltinIcons()` / `configureIcons(opts)` / `getIconConfig()` —
1157
+ switch or read the renderer without touching the network.
1158
+ - `iconNames()` lists every inline glyph; `registerIcons(map)` adds `[viewBox, path]` entries.
1159
+
1160
+ **Names decide their own renderer.** A bare name (`'check'`) follows the active provider, and the
1161
+ built-in names are translated to their Font Awesome counterparts (`x` → `fa-xmark`, `search` →
1162
+ `fa-magnifying-glass`, `warning` → `fa-triangle-exclamation`, …), so component code never changes.
1163
+ Prefixes override: `'fa:user'`, `'fas:user'`/`'regular:user'`/`'duotone:user'`/`'thin:user'`,
1164
+ `'kit:zeyos-notes'` (custom kit icons), `'builtin:check'` (force the inline SVG). A literal class
1165
+ list — `'fa-sharp fa-solid fa-user'` — is used verbatim. Unknown *built-in* names throw
1166
+ `RangeError`; unknown names under Font Awesome are passed through to the kit.
1167
+
1168
+ Font Awesome elements are sized with `font-size` and occupy 1em; bundled SVGs use width/height
1169
+ attributes. Both honour `label` (`null` ⇒ `aria-hidden`, otherwise `role="img"` + `aria-label`).
1170
+ <!-- /doc -->
1171
+
1172
+ <!-- doc:helpers -->
1173
+ ### Helper functions
1174
+
1175
+ Standalone exports with no DOM ownership and no lifecycle, importable individually from the same
1176
+ entry point as the components. See also the Kernel section for `Component`, `h`, `icon`, and
1177
+ `position` — the substrate the components themselves are built on.
1178
+
1179
+ - **Dates** — `formatDate(date, fmt)`, `parseDate(str, fmt)` (returns `null` when the string does
1180
+ not match, so it doubles as a validator), `clampDate(date, min, max)`, `isSameDay(a, b)`,
1181
+ `addDays(date, n)`, `addMonths(date, n)`, `getWeekStart(lang?)` → `0` (Sunday) or `1` (Monday).
1182
+ strftime-style tokens: `%d %m %Y %y %H %M %S %a %A %b %B %s`.
1183
+ - **Numbers, money and time** — `formatNumber(value, {locale, decimals, minDecimals, maxDecimals,
1184
+ group=true})`, `formatCurrency(value, currency, {locale, decimals})`, `formatPercent(value,
1185
+ {locale, decimals=0})` (takes a fraction, so `0.42` → `42%`), `formatFileSize(bytes, {locale,
1186
+ decimals=1, standard='iec'})` (`'iec'` → KiB/MiB/GiB in steps of 1024, `'si'` → kB/MB/GB in steps
1187
+ of 1000; `0` → `'0 B'`, trailing zeros dropped), `formatRelativeTime(date, {now=new Date(),
1188
+ locale, numeric='auto'})` (picks the largest sensible unit from second to year; accepts a `Date`,
1189
+ Unix seconds, or an ISO string). `locale` defaults to `getLanguage()` throughout, so
1190
+ `setLanguage()` drives all five. Nothing throws: `null`, `undefined` and `NaN` all format as
1191
+ `''`, an invalid currency code falls back to plain number formatting, and every `Intl` instance
1192
+ is cached because tables call these once per cell.
1193
+ - **Utilities** — `debounce(fn, ms)` (trailing edge, preserves `this`), `throttle(fn, ms,
1194
+ {leading=true, trailing=true})` (its rate-limiting counterpart: runs at most once per interval
1195
+ and replays the latest arguments on the trailing edge), `uid(prefix='zx')` (unique
1196
+ per page load, safe as a DOM id), `deepMerge(a, b)` (never mutates either input; replaces arrays
1197
+ rather than concatenating), `clamp(n, min, max)`, `toArray(value)` (null → `[]`, string → single
1198
+ entry, iterable/array-like → array), `isElement(value)` (cross-realm safe).
1199
+ - **Collections** — `groupBy(items, key)` → `Record<string, T[]>` ordered by first appearance;
1200
+ `sortBy(items, ...keys)` returns a **new** array and never mutates its input, taking a property
1201
+ name, a `'-name'` prefix or a `{key, dir}` object for descending, or an accessor function,
1202
+ comparing digit runs numerically and always sorting `null`, `undefined` and `NaN` last in either
1203
+ direction; `uniqueBy(items, key?)` keeps the first occurrence.
1204
+ - **Escaping** — `htmlEscape(str)`, and `h.raw(html)` as the only sanctioned `innerHTML` path, for
1205
+ component-generated markup only. `escapeRegExp(value)` makes user input safe as a literal
1206
+ pattern, and `highlightMatch(text, query, {className='zx-mark'})` → `DocumentFragment` wraps
1207
+ every case-insensitive occurrence in a `<mark>` built from text nodes, with no `innerHTML`
1208
+ involved — the pairing every filtering list wants.
1209
+ - **Storage** — `storage(namespace, {area='local'})` → `{get, set, remove, keys, clear}`, a
1210
+ namespaced JSON-encoded view of `localStorage` (or `sessionStorage` with `{area: 'session'}`).
1211
+ Keys are written as `zx:<namespace>:<key>`, so features share an origin without colliding and
1212
+ `clear()` empties only its own namespace; `keys()` reports namespace-relative names.
1213
+ `get(key, fallback)` returns the fallback both for a missing key and for unparseable JSON, and
1214
+ `set()` drops values JSON cannot represent rather than throwing. When the area is missing or
1215
+ refuses a write — private browsing, a full quota, cookies disabled — the view degrades silently
1216
+ to an in-memory map for the life of the page, so callers never need a `try`/`catch`.
1217
+ - **Export** — `toCsv(rows, columns, {delimiter=',', header=true, newline='\r\n'})` serialises to
1218
+ RFC 4180: fields containing the delimiter, a quote, CR or LF are quoted with inner quotes
1219
+ doubled, `Date`s become ISO strings, `null`/`undefined` become empty fields, and text starting
1220
+ with `=`, `+`, `-` or `@` is prefixed with a tab so spreadsheets treat it as text rather than a
1221
+ formula (numbers are exempt, so negative amounts survive). `columns` accepts plain id strings or
1222
+ `{id, label, value(row)}` descriptors. `downloadBlob(filename, data, {type})` saves through a
1223
+ temporary object URL, prepending a UTF-8 BOM for `text/csv` so Excel reads it as UTF-8;
1224
+ `copyToClipboard(text)` → `Promise<boolean>` uses the async Clipboard API and falls back to a
1225
+ hidden textarea, never rejecting.
1226
+ - **Translation** — `setTranslator(fn)`, `setLanguage(lang)`, `getLanguage()`,
1227
+ `translate(key, args)`, `printf(str, args)` (`%s` placeholders). Every component routes its
1228
+ built-in strings through `translate()`, so one translator covers the library, and
1229
+ `setLanguage()` also drives locale-dependent behaviour such as the first day of the week.
1230
+ - **Keyboard** — `focusTrap(container)` → `{activate, release}` confines Tab to a container;
1231
+ `rovingTabindex(container, itemSelector, {orientation, wrap})` → `{focusFirst, focusLast,
1232
+ destroy}` keeps one tab stop in a group; `typeahead(getItems, onMatch)` →
1233
+ `(event|string) => void` resolves buffered printable keystrokes to an item (500 ms buffer,
1234
+ repeated-letter cycling).
1235
+ <!-- /doc -->
1236
+
1237
+ <!-- doc:truncate -->
1238
+ ### truncate()
1239
+
1240
+ Clamps text to a number of lines and gives the cut-off text back on hover. The tooltip half is
1241
+ what makes it more than a CSS class: a cell that silently drops the end of a value is a data-loss
1242
+ bug wearing a layout costume, while a tooltip that is always there is noise.
1243
+
1244
+ - **Signature** — `truncate(target, {lines, title})` → `{update, isTruncated, destroy}`.
1245
+ - **Options** — `lines: 1` (1–10; one line ends in an ellipsis, more clamp the block),
1246
+ `title: true` — sets a native `title` **only while the text is actually cut off**, re-measured
1247
+ through a `ResizeObserver` on every resize, and restores any previous title on `destroy()`.
1248
+ - **Controller** — `update()` re-measures and returns the current state, `isTruncated()` reports
1249
+ the last measurement, `destroy()` removes the class, the observer, and any title it set.
1250
+ - **Exported helper** — `isTruncated(element)` compares scroll and client sizes on both axes (a
1251
+ single line runs out of width, a clamped block runs out of height) with a one-pixel tolerance
1252
+ for the sub-pixel difference fractional zoom produces.
1253
+ - **Class** — `.zx-truncate` alone does the clamping from static markup with no JavaScript;
1254
+ `data-lines` plus `--zx-truncate-lines` select the multi-line form.
1255
+ <!-- /doc -->
1256
+
1257
+ <!-- doc:gx-compat -->
1258
+ ### gx compatibility layer
1259
+
1260
+ Opt-in re-implementation of the legacy MooTools-era `gx` API on top of Zx, so existing ZeyOS
1261
+ screens keep running while their code is modernised file by file. It lives in its own bundle, is
1262
+ never imported by `src/index.js`, and adds nothing to an application that does not load it.
1263
+
1264
+ - **Loading** — classic: `<script src="/assets/zx-compat.global.js">` → `window.gx`; module:
1265
+ `import { gx } from '/assets/zx-compat.esm.js'` (or `src/compat/index.js` in-repo).
1266
+ `gx.compat.installGlobals()` additionally installs the legacy free functions `__()`, `_()`,
1267
+ `String.htmlSpecialChars`, and the element `store`/`retrieve` shim. `gx.install(host)` assigns
1268
+ the namespace without the optional globals.
1269
+ - **`gx.zeyos`** — Select/SelectFilter/SelectDyn/SelectPrio, Table, Tabbox, Panel, MasterPanel,
1270
+ Groupbox, Search, Datebox, DatePicker, MonthPicker, TimePicker, Timebox, Toggle, Msgbox, Popup,
1271
+ Dialog, Dropdown, Checklist, Permission, Client, Request, Factory.
1272
+ - **`gx.bootstrap`** — Form, Fieldset, Field, the Select variants, Checklist, Table, Tabbox,
1273
+ NavigationBar, MenuButton, Message, Popup/PopupAlert/PopupConfirm, CheckButton, DataFilter,
1274
+ ValueList, MultiValueEditor, FieldUpload, Timebox.
1275
+ - **`gx.util`** — `formatNum`, `formatTime`, `getMinutes`, `getNumber`, `printf`, `parseResult`,
1276
+ `Parse`, `isArray`/`isObject`/`isFunction`/`isString`/`isNumber`/`isElement`/`isNode`,
1277
+ `Console`.
1278
+ - **`gx.ui`** — Container, SimpleTable, Timebox, and the inert visual-effect stubs
1279
+ Blend/Collapse/Hud/Toggling/HGroup/Templates. **`gx.core`** — Settings. **`gx.compat`** —
1280
+ installGlobals, installElementStorage, parse, GxWrapper.
1281
+ - **Migrating off it** — legacy classes collapse onto fewer Zx components: all four Select
1282
+ variants → one `Select` with a `filter` option; `gx.ui.Table` + `SimpleTable` → one `Table`;
1283
+ `Msgbox`/`bootstrap.Message` → `Message`; `Popup`/`PopupAlert`/`PopupConfirm` → `Modal`/`Dialog`
1284
+ with Promise-returning statics; `gx.zeyos.Client`/`Request` → `@zeyos/client`. Anything that
1285
+ depended on MooTools prototype extensions is gone. `MIGRATION.md` has the full map and the
1286
+ deliberate behaviour changes; `website/compat.html` runs legacy snippets against Zx as a smoke
1287
+ test.
1288
+ <!-- /doc -->
1289
+
1290
+ <!-- doc:elements -->
1291
+ ### Custom elements — `defineElements()`
1292
+
1293
+ Call `defineElements()` once to register `<zx-*>` light-DOM wrappers with attribute↔option
1294
+ reflection and ElementInternals form association, so they participate in a native `<form>` and its
1295
+ `FormData`. Events bubble as `zx-*` on the host element, and removing an element from the DOM
1296
+ destroys its component.
1297
+
1298
+ Registered elements: `<zx-toggle>`, `<zx-check-button>`, `<zx-select items='[…]' required>`,
1299
+ `<zx-checklist>`, `<zx-datebox>`, `<zx-timebox>`, `<zx-search>`, `<zx-groupbox>`, `<zx-tabbox>`,
1300
+ `<zx-table>`, `<zx-dialog>`.
1301
+ <!-- /doc -->
1302
+
1303
+ <!-- doc:tokens -->
1304
+ ### Design tokens
1305
+
1306
+ Two tiers of CSS custom properties. Dark mode and density are pure token/attribute swaps
1307
+ (`[data-zx-theme="dark"]`, `[data-zx-density="compact"]`), and `tests/lint-tokens.js` forbids raw
1308
+ colour literals and tier-1 references in component CSS.
1309
+
1310
+ - **Tier 1** — the global palette in `styles/tokens/global.css`, never referenced by components:
1311
+ `--zx-gray-0…950`, `--zx-green-*`, `--zx-red/amber/blue-*`, `--zx-space-1…8`,
1312
+ `--zx-radius-sm/md/lg/full`, the type scale, shadows, and motion.
1313
+ - **Tier 2** — the semantic set, the only tokens components may use: `--zx-color-*`,
1314
+ `--zx-control-*`, `--zx-focus-ring`, `--zx-overlay-shadow`.
1315
+ <!-- /doc -->