api reference
Form API Reference
createForm: controls laid out in nested rows and columns with percentage widths, number formats and units, date, date-time and time pickers, a file drop area, disabled and read-only states, rules, and binding to a grid or the data router.
API reference › The form module
All 26 pages Everything on one page → Developer guide →
The form module
modules/form is an opt-in form builder: named items laid out in nested rows and columns, in its own bundle that grows grid core by nothing. It is a sold Lattice feature. The main owner pain it removes is the same one the shell removes - asynchronous redraw: when createForm(el, config) returns, every item is already in the document and already measurable, so there is no awaitRedraw() and no deferred paint. A host that reads getItem(name).el on the very next line measures a real element in the same synchronous task.
import { createForm } from '@toclocoinc/lattice-grid/modules/form';
const form = createForm(document.querySelector('#form'), {
rows: [
{ fieldset: 'Account', items: [
{ type: 'input', name: 'name', label: 'Name', value: 'Ada' },
{ type: 'number', name: 'age', label: 'Age', min: 0, max: 120 },
{ type: 'checkbox', name: 'active', label: 'Active' },
] },
{ cols: [
{ spacer: true },
{ type: 'button', name: 'save', label: 'Save' },
] },
],
});
form.getItem('name').setValue('Grace'); // a silent write, no event
form.getValue(); // { name: 'Grace', age: null, active: false }
form.on('beforeChange', (e) => { if (e.name === 'age' && e.value > 120) e.preventDefault(); });
form.setDensity('comfortable'); // control heights follow the grid's row height
form.destroy();
Rows, columns and blocks. A form is a tree of rows and cols, each nesting the other to any depth, plus items to lay controls side by side. A rows block stacks its children vertically and stretches them to its width; a cols block lays its children side by side, sharing the width equally by default (flex: 1 1 0, min-width: 0 - a child’s own width, flex or css still wins, and a percentage width is a share of the row’s width after its gaps, so three 33.3% items sit on one line) and stacking them into one column once the container drops below config.colsBreakpoint (default 480px, via a container query). A container is the default block shape (a flex box along its axis); a fieldset is a native <fieldset>/<legend> with its children in a body below; a spacer is flexible space and a separator a rule - or, given a label (a literal such as 'or', or a key resolved through config.messages), a muted label centred between two rules (“ - - or - - ”), the label also its accessible name - both plain blocks. A text item renders a read-only display carrying its value - a label/value pair, not an editable control - and the basic controls are input (subtype text, email, password or url), number (step/min/max), textarea, checkbox, button, rating (a row of icons, whole or half steps, click or arrow), stage (a pipeline of chevron steps), chips (a row of pill buttons for one or several options) and combo (a searchable combobox, described below).
One item, one handle. Every item is a wrapper holding an optional <label> (associated to its control by for/id), the control, and an optional help line. Per item: name, label, labelPosition (top/left), labelWidth, width, placeholder, helpMessage, hidden, disabled, readOnly and value. getItem(name) returns the handle - { el, name, getValue, setValue, show, hide, enable, disable, focus, blur, isVisible } - or undefined for an unknown name, and forEach(fn) hands every handle to fn in document order. setValue(values) writes values silently by name (unknown keys are ignored), getValue() snapshots every named value-holding item as { name: value } (a button holds no value and is not included), and clear() resets each user-editable item to its empty value - '' for text, null for a number, a stage or a single chips, [] for a multi chips, false for a checkbox, 0 for a rating - without firing an event.
Events. A field fires input as the user types (the value is live, not yet committed) and change once the edit commits; a checkbox fires change on toggle; a button fires click. Every interactive item fires focus and blur, and an otp fires complete once every box is filled. A change is gated by a cancellable beforeChange - a handler calls event.preventDefault(reason?) or returns false (or a Promise of either) to refuse the commit, which then reverts the control to its last committed value - and the emitted events are subscribed with form.on(name, fn) (or '*' for every past-tense event) and detached with form.off(name, fn).
Validation. An item declares required (an empty value fails), a validation rule - a built-in name (email, number, integer, url), a whole-value RegExp, or a function (value, item) => boolean | string | void that returns true/undefined for pass, false for a fail, or a string that is the failure message - and the message options errorMessage, successMessage and preMessage. form.validate() checks every rule-bearing item and returns true/false, marking each invalid item with aria-invalid and an associated message (and each pass with its successMessage); item.validate() checks one item. A validationMode of change re-checks an item once its edit commits, blur re-checks it as it loses focus, and submit (the default) checks it only on validate(). The default rule messages live in the module's catalogue - overridable through config.messages - and a validated event reports every failure as { name, rule, message }. The control registry exposes a per-control validate(el, item, value) hook that later controls (file, combo, custom) plug into.
Density. A density level - compact, standard (default) or comfortable - drives every control's height, padding, label gap and fieldset spacing from the grid's own --lattice-* tokens (a control stands one --lattice-row-height tall, so a form beside a grid follows that grid's density), each with a literal fallback so a form on a bare page still draws. form.setDensity(level) changes it live with one attribute write and no re-render, and an unknown level is ignored. The module imports no grid and no core source, ships in its own bundle and UMD global, and destroy() empties the element, removes only what the module added, restores the host's own inline style, and drops every item record, listener and handler.
Spacing and label gap. config.spacing sets the vertical gap between stacked items - a px number or a CSS length - and config.labelGap the gap between an item's label and its field; both default from the density scale, so leaving them unset changes nothing. They are the theme tokens --lattice-form-item-gap and --lattice-form-label-gap, so a CSS theme can set the default a form without an explicit spacing falls back to, and a rows container or fieldset that sets its own spacing overrides it for its own items only. form.setSpacing(value) applies the gap live with one property write and no re-render (null clears the override). Horizontal cols containers keep their own gap, and help/validation lines sit inside the item wrapper, so an item showing a validation message keeps the same gap to the next item as one without.
Disabled and read-only, defined once. Both states are one contract applied identically to every control. disabled leaves the tab order (unless focusableWhenDisabled), shows no hover or active response, sets cursor: not-allowed, carries aria-disabled (or the native disabled attribute), is excluded from validate(), and is styled from the --lattice-disabled-foreground/--lattice-disabled-background/--lattice-disabled-border tokens with text at or above 3:1. readOnly stays focusable and in the tab order, keeps its value readable, selectable and copied, refuses every change path before it happens - typing, click, paste, drop, wheel and the keyboard do nothing, pop-ups open view-only or not at all, and a checkbox, radio, toggle, radiogroup or checkboxgroup guards pointerdown/click/Space/Enter so the value never flickers - carries aria-readonly, is still validated and still appears in getValue()/submit, and is styled from the --lattice-readonly-foreground/--lattice-readonly-background/--lattice-readonly-border tokens (subtle fill, cursor: default, the lock icon). Both are set per item (disabled, readOnly, readOnlyWhen) and form-wide with form.setDisabled(bool) and form.setReadOnly(bool).
Data binding. config.bind binds the form to a record instead of a static config. bind: { grid, rowKey } binds a grid row: when no layout is declared, one item per column is generated from the grid's own columns - their titles, types and editors - so a boolean column becomes a checkbox and a number column a number; the form follows the grid's density by default; save() writes the dirty items once through the grid's edit path (permissions, read-only, parse, validation and the beforeEdit gate honoured); and revert() restores the bound values. External edits to the row update a clean item in place and leave a dirty one alone, flagging it with isConflicted(). bind: { router, route, key } follows a data-router record with the same update-unless-dirty rule, filling in from the route's keyed diffs (the router exposes no eager read), and save() reports false there is no write path. A plain object of values initialises the form and is written back by save(). isDirty(name?) reports an unsaved user edit on one item or the whole form, and editing is the only thing that marks an item dirty - setValue(), revert() and bound-record updates are silent. The binding is duck-typed against the grid's public API, so the module still imports no grid and no core source.
Custom controls. registerControl(type, definition) makes a control type a first-class item: the definition's create(el, item, api) builds the control's DOM into el, getValue(el)/setValue(el, value) round-trip the value through getValue()/setValue(), and an optional validate(value, item, el) refuses a bad commit (return true to accept, false or a message to refuse, a Promise is awaited). The registered control gets the same wrapper, label, item API, events and density as a built-in, its api.onChange()/api.onInput() commit and report through the same path a native edit takes, and a later registration for the same type replaces the earlier one.
Declarative rules. An item's visibleWhen, readOnlyWhen or requiredWhen is a rule re-evaluated on every committed change: a constant, a function of the whole { name: value } snapshot, or a condition on one named item's value with equals, in, empty or fn. A visibleWhen that is false hides the item, a readOnlyWhen that is true makes its control read-only, and a requiredWhen that is true marks it required - its commit path then refuses an empty value. A rule composes on the item's own hidden/readOnly baseline, so it never fights the item API's hide()/show()/enable()/disable().
Wizards. A steps array turns the form into a wizard: each { id, label, rows } step is one body, only the active one visible, under a step indicator whose stepOrientation is horizontal (default) or vertical. form.next() validates the current step's items before advancing, form.back() goes back without validating, and form.goToStep(index) moves to a step validating the ones passed over on the way forward; the indicator marks each step pending, active, complete or in error, and never disagrees with the form. On the last step next() reads "Finish" and validates in place.
Files and rich text. A file item is an uploader field: drop, paste or pick, with accept (extensions or MIME patterns), maxSize and maxFiles enforced at admission, and a host upload(file, { onProgress, signal }) callback that does the actual upload. The file list shows per-file progress and status with retry and remove, and the value is the accepted file entries - a rejected file stays visible with a message but never enters the value. A richtext item is a sanitised HTML editor: toolbar presets (basic/html/rich or an item-name array) over bold, italic, underline, strikethrough, colour, case, paragraph styles, lists, alignment, links, images, source view and undo/redo, with keyboard shortcuts and paste cleanup. Its whitelist sanitiser is the security boundary, shared with the grid's rich-text editor as one implementation: scripts, event handlers, srcdoc, id/name clobbering and javascript:/vbscript:/data: URLs never survive pasting, dropping or setValue, and every _blank link carries rel="noopener noreferrer". Both honour density, theme tokens, readOnly/disabled and the item API, and clear to [] (file) or '' (richtext). The same rich-text editor is available as a grid pop-up editor for long text with edit: { editor: 'richtext' }.
Passwords and one-time codes. A password input carries a reveal button inside the field: an eye that toggles the input between the native password and text types, keyboard-operable like any button, carrying aria-pressed and a name from the catalogue (“Show password”/“Hide password”). It returns to hidden when focus leaves the field and when the form validates or submits, and reveal: false renders a bare password input with no button - the value, events and validation are unchanged. An otp item is a one-time-code entry of single-character boxes: length (default 6) sets how many, mode (numeric default or alphanumeric) drives each box’s inputmode/pattern and the characters it admits, the first box advertises autocomplete="one-time-code", and mask hides each glyph behind the password disc. Typing advances, Backspace steps back and clears, the arrow keys move, and pasting fills every box (spaces and dashes stripped). getValue() returns the joined string, setValue() fills the boxes, and a complete event fires once every box is filled. Screen readers hear one labelled group, each box announced as “Digit n of length”.
Buttons and button rows. A button item is an acting control, not a value: it takes an icon (a name from the grid’s icon set, an image URL or data: URI, or an element) with iconPosition (start default / end), sitting a density-scaled gap (about 0.5em) from the text and vertically centred with it, a variant (primary, secondary default, tonal, outline, text or danger - theme-token colours, light and dark), fullWidth to span the form, a size (sm/md/lg), a textTransform (none default / uppercase) and a buttonType (button, submit or reset). A submit button runs form.validate() first and only then fires a submit event on the form’s element with the values in event.detail.values when valid; a reset button calls form.clear(). loading: true (or item.setLoading(bool)) swaps the icon for a spinner, keeps the width, sets aria-busy and makes the button ignore clicks; a href turns it into a link styled as a button. An icon with no label is icon-only: it requires ariaLabel and renders square. A { type: 'buttons', align, items } row lays a group of buttons out along the width with align start (default), end, center, stretch or space-between, and each child takes the same options as a button item.
A button is an action item, and its states are named tokens. Because a button acts rather than holds a value, the view draws no field <label>, required asterisk, help line or hint line for it - its label is its own text, shown once - and in a labelPosition: 'left' form it lines up with the fields’ input column (unless fullWidth spans the row). Every variant defines the same state matrix in light and dark, each state’s colours read from an overridable --lattice-button-<variant>-<state> token: rest; hover (one step toward the foreground - a shade in light, a tint in dark); active (a deeper step); focus-visible (a 2px accent ring with a 2px offset, never removed); disabled (muted text, border and fill with text at or above 3:1 against its fill, cursor: not-allowed, no hover or pressed change, aria-disabled, and still in the tab order only when focusableWhenDisabled: true keeps it focusable instead of natively disabled); loading (the spinner from loading); and toggled (a distinct pressed look). toggle: true makes the button two-state: it flips aria-pressed and fires change with the new boolean (starting pressed when value is true). The swatches below show each variant’s fill per state, light and dark.
| Variant | Rest | Hover | Active | Toggled | Disabled |
|---|---|---|---|---|---|
| primary | |||||
| secondary | |||||
| tonal | |||||
| outline | |||||
| text | |||||
| danger |
| Member | Description |
|---|---|
| createForm(el, config?) | Create a form. config is { rows | cols, density, spacing, labelGap, labelPosition, labelWidth, requiredHint, colsBreakpoint, bind, css, messages, steps, stepOrientation }; rows stacks the top-level blocks vertically, cols lays them left-to-right sharing the width equally (used when rows is absent), spacing is the vertical gap between items (a px number or a CSS length, defaulting to the density's gap) and labelGap the gap between an item's label and its field (defaulting to half the density's space), labelPosition (top default / left / hidden) and labelWidth are the defaults every item inherits (an item’s own value wins), requiredHint is the placeholder marker a required field with no visible label shows (true appends " *", a string resolves through the catalogue, false shows nothing), colsBreakpoint is the container width below which a cols container stacks into one column (default 480px), bind binds the form to a record ({ grid, rowKey }, { router, route, key } or a plain values object), messages supplies a t(key, params) catalogue for the separator's accessible label and the validation defaults, and a steps array turns the form into a wizard with a horizontal (default) or vertical stepOrientation. Returns { el, config, getItem, getValue, setValue, clear, validate, forEach, on, off, setDensity, setSpacing, setReadOnly, setDisabled, isDirty, isConflicted, save, revert, steps, activeStep, next, back, goToStep, destroy }. |
| registerControl(type, definition) | Register a custom control type as a first-class item. definition is { create(el, item, api), getValue(el), setValue(el, value), validate?(value, item, el), destroy?(el) }; the control gets the same wrapper, label, item API, events and density as a built-in. A later registration for the same type replaces the earlier one. |
| item | Each item: type (a built-in input/number/textarea/checkbox/button/text/combo/rating/stage or a registered custom type), name, label, labelPosition (top/left), labelWidth, width, placeholder, helpMessage, hidden, disabled, readOnly, value, required, validation, errorMessage, successMessage, preMessage, validationMode, css, and the rules visibleWhen, readOnlyWhen, requiredWhen. input takes subtype (a password subtype also reveal); number takes step/min/max. rating takes max, allowHalf, allowClear, icon or icons ({ filled, empty }), labels, showValue and size (sm/md/lg); stage takes stages, current, completed, clickable and showIcons; chips takes options ({ value, label, icon?, disabled? }), multiple, allowEmpty, wrap and max; otp takes length, mode (numeric/alphanumeric) and mask. select, radiogroup and checkboxgroup take options; a radiogroup/checkboxgroup also takes orientation (vertical/horizontal) or columns (an n-column grid read across then down). button takes icon (a name, URL/data: URI or element), iconPosition (start/end), variant (primary/secondary/tonal/outline/text/danger), fullWidth, size (sm/md/lg), textTransform (none/uppercase), buttonType (button/submit/reset), loading, href, ariaLabel (required for an icon-only button), toggle (a two-state button that flips aria-pressed and fires change with the new boolean) and focusableWhenDisabled (keep a disabled button focusable and in the tab order, reading aria-disabled instead of being natively disabled); a { type: 'buttons', align, items } row lays buttons out by align (start/end/center/stretch/space-between), each child taking the same options. |
| getItem(name) | The item handle { el, name, getValue, setValue, show, hide, enable, disable, focus, blur, isVisible, validate, setLoading } - el is the item's wrapper element. undefined for an unknown name. |
| getValue() | Every named value-holding item as a plain { name: value } object, in document order. A button is not included. |
| setValue(values) | Write values into the matching named items, silently (no event fires). Keys that name no item are ignored. |
| clear() | Reset every user-editable item to its empty value - '' for a text field, null for a number, a stage or a single chips, [] for a multi chips, false for a checkbox, 0 for a rating. A read-only text display is left as it is. No event fires. |
| validate() | Check every item that declares required, validation or a control hook, paint each verdict (aria-invalid and an associated message on the failures, the item's successMessage on the passes) and report all failures through one validated event. Returns true only when no item failed. |
| forEach(fn) | Walk every item in document order, handing each handle to fn. |
| on(name, fn) | Subscribe to a form event - change, input, beforeChange (cancellable), click (buttons), focus, blur, complete (an otp filled) or validated - or to '*' for every past-tense event; returns an unsubscribe. A beforeChange handler cancels by calling preventDefault(reason?) or returning false (or a Promise of either). |
| off(name, fn) | Remove a handler registered with on. |
| setDensity(density) | Change the form's density live - compact, standard or comfortable - re-theming every control's height, padding, label gap and fieldset spacing with one attribute write and no re-render. An unknown value is ignored. |
| setSpacing(spacing) | Change the vertical gap between items live - a px number or a CSS length - re-theming every stacked container (rows, a fieldset body and the form root) with one property write and no re-render. null removes the override so the gap returns to the density default (or the --lattice-form-item-gap theme token). Horizontal cols containers keep their own gap. |
| setReadOnly(readOnly) | Set every item read-only (or back to editable) in one call. A read-only item stays focusable and in the tab order, keeps its value readable, selectable and copied, refuses every change path before it happens, carries aria-readonly, is still validated and still appears in getValue(), and is styled from the read-only tokens (subtle fill, cursor: default, the lock icon). Composes with each item's own readOnly and readOnlyWhen. |
| setDisabled(disabled) | Set every item disabled (or back to enabled) in one call. A disabled item leaves the tab order (unless focusableWhenDisabled), has no hover/active response, shows cursor: not-allowed, carries aria-disabled or the native disabled attribute, is excluded from validate(), and is styled from the disabled tokens with text at or above 3:1. |
| isDirty(name?) | Whether an item (or, with no name, the whole form) holds a value the user edited and has not yet saved or reverted. On an unbound form nothing is ever dirty. |
| isConflicted(name?) | Whether an item (or the whole form) is in conflict: the user has an unsaved edit, and an external change to the bound record arrived for the same item, which the form therefore did not overwrite. |
| save() | Save the dirty items. For a grid binding the writes go through the grid's own edit path in one call (permissions, read-only, parse, validation and the beforeEdit gate honoured); for a values binding they are written back into the bound object. Returns true when saved (or nothing to save), or a Promise<boolean> when the write is asynchronous. A router-bound form has no write path and returns false. |
| revert() | Restore every dirty item to its bound/committed value and clear the dirty and conflict flags. |
| next() | Advance a wizard one step, validating the current step's items first. Resolves true when the wizard moved (or the final step validated), false when the current step is invalid or there is no next step. Not a wizard: resolves false. |
| back() | Go back one step - always allowed, never validated. Resolves false when already on the first step. |
| goToStep(index) | Move a wizard to a step by index. Forward moves validate the steps passed over (the first invalid one stops the move and is marked in error); backward moves are always allowed. Resolves false when an intermediate step is invalid or the index is out of range. |
| destroy() | Empty the element, remove only what the module added (class, density attribute, own inline style), and drop every item record, listener and handler. The host still owns the element. |
Values, a committed edit, a veto and density, executed
A form is created over the in-tree DOM test double: the configured value is read, silent writes and bulk
setValue land without an event, a half-step rating and a selected stage round-trip
through getValue()/setValue(), one committed edit fires change exactly once, a
beforeChange handler vetoes the next edit and the control reverts, clear() empties the fields,
and setDensity() re-themes the root - with the layout rects and the real Chrome control heights asserted
in test/form-browser.test.js.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, {
colsBreakpoint: 400, // a cols block stacks below 400px
css: { gap: '2px' }, // the form root's own css
cols: [ // top-level columns, not rows
{ rows: [
{ type: 'input', name: 'name', label: 'Name', value: 'Ada', placeholder: 'Full name', helpMessage: 'As it appears' },
] },
{ fieldset: 'Account', items: [
{ type: 'input', subtype: 'email', name: 'email', label: 'Email', labelPosition: 'left', labelWidth: '6em', placeholder: 'you@example.com', value: 'a@b.c' },
{ type: 'number', name: 'age', label: 'Age', min: 0, max: 120, step: 1 },
{ type: 'checkbox', name: 'active', label: 'Active' },
{ type: 'rating', name: 'score', label: 'Score', max: 5, value: 4.5, allowHalf: true, allowClear: true, icons: { filled: 'star', empty: 'heart' }, labels: ['Poor', 'Fair', 'Good', 'Great', 'Best'], showValue: true, size: 'md' }, // icon: 'star' is the single-glyph shorthand
{ type: 'stage', name: 'phase', label: 'Phase', stages: ['Draft', 'Review', 'Approved'], current: 'Review', completed: 'Draft', clickable: true, showIcons: true },
{ type: 'button', name: 'save', label: 'Save' },
{ type: 'input', name: 'locked', label: 'Locked', readOnly: true, value: 'fixed', width: '12em', css: { opacity: '0.7' } },
{ type: 'input', name: 'off', label: 'Off', disabled: true, value: 'grey' },
{ text: 'read-only display', name: 'note', label: 'Note' },
] },
],
});
const name = form.getItem('name');
const started = name.getValue(); // 'Ada' - the configured value
name.setValue('Bob'); // a silent write, no event
form.setValue({ age: 36 }); // bulk silent write
const snapshot = form.getValue(); // { name: 'Bob', age: 36, active: false, … }
const score = form.getItem('score').getValue(); // 4.5 - the configured half rating
const phase = form.getItem('phase').getValue(); // 'Review' - the current stage
form.setValue({ score: 3, phase: 'Approved' }); // bulk silent writes land too
const rating = form.getItem('score').getValue(); // 3
const stage = form.getItem('phase').getValue(); // 'Approved'
let walked = 0;
form.forEach(() => { walked += 1; }); // 10 items, the button too
let changes = 0;
const onChange = () => { changes += 1; };
form.on('change', onChange); // subscribe to committed edits
const input = name.el.querySelector('input');
input.value = 'Zed';
input.dispatchEvent(new TestEvent('change', { bubbles: true }));
const committed = form.getValue().name; // 'Zed' - one change event
form.on('beforeChange', (e) => { e.preventDefault(); }); // veto every commit
input.value = 'Nope';
input.dispatchEvent(new TestEvent('change', { bubbles: true }));
const vetoed = form.getValue().name; // still 'Zed'
const reverted = input.value; // the control reverted to 'Zed'
form.setDensity('comfortable'); // control heights follow the grid's row height
const density = root.getAttribute('data-density'); // 'comfortable'
const note = form.getItem('note').getValue(); // the read-only text display
const emailType = form.getItem('email').el.querySelector('input').type;
const lockedReadOnly = form.getItem('locked').el.querySelector('input').readOnly;
const offDisabled = form.getItem('off').el.querySelector('input').disabled;
form.clear(); // empty the editable fields
const after = form.getValue(); // name '', age null, active false
form.off('change', onChange); // detach the handler
form.destroy();
return [started, snapshot.name, snapshot.age, walked, changes, committed, vetoed, reverted, density, after.active, note, emailType, lockedReadOnly, offDisabled, score, phase, rating, stage].join('|');
Spacing and label gap, executed
The vertical gap between items and the gap between a label and its field are form-level options, read as inline custom properties a CSS theme can also set; setSpacing re-themes live - with the real Chrome rects asserted in test/form-spacing-2183-browser.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, {
spacing: 24, // a px number, 24px between items
labelGap: 2, // a px number, 2px label to field
rows: [
{ type: 'input', name: 'a', label: 'A' },
{ type: 'input', name: 'b', label: 'B' },
],
});
const spacing = root.style.getPropertyValue('--lattice-form-item-gap'); // '24px'
const labelGap = root.style.getPropertyValue('--lattice-form-label-gap'); // '2px'
form.setSpacing(16); // live re-theme, no re-render
const live = root.style.getPropertyValue('--lattice-form-item-gap'); // '16px'
const stillLabel = root.style.getPropertyValue('--lattice-form-label-gap'); // '2px'
form.setSpacing('8px'); // a CSS length works too
const css = root.style.getPropertyValue('--lattice-form-item-gap'); // '8px'
form.setSpacing(null); // null clears the override
const cleared = root.style.getPropertyValue('--lattice-form-item-gap'); // '' (density default)
form.setSpacing('1.5rem'); // any CSS length
const rem = root.style.getPropertyValue('--lattice-form-item-gap'); // '1.5rem'
form.destroy();
return [spacing, labelGap, live, stillLabel, css, cleared, rem].join('|');
Percentage widths in a cols row, executed
A percentage width in a cols row is a share of the row’s width after its gaps, so three 33.3% items each subtract two-thirds of the gap and sit on one line - with the real-Chrome geometry asserted in test/form-width-2202-browser.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, {
cols: [ // three columns side by side
{ type: 'input', name: 'opportunity', label: 'Opportunity name', width: '33.3%' },
{ type: 'input', name: 'account', label: 'Account', width: '33.3%' },
{ type: 'input', name: 'contacts', label: 'Contacts', width: '33.3%' },
],
});
// Each basis is 33.3% minus that item's share of the row's two gaps, so the
// three items total 100% of the row after the gaps and stay on one line.
const flexOf = (name) => form.getItem(name).el.style.getPropertyValue('--lattice-item-flex');
const widths = [flexOf('opportunity'), flexOf('account'), flexOf('contacts')].join('|');
form.destroy();
return widths;
Disabled and read-only, executed
The two states are one contract across every control, set per item or form-wide - with the real Chrome focus, cursor, token and value assertions over all 16 control types in test/form-disabled-readonly-2198-browser.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, {
rows: [
{ type: 'input', name: 'name', label: 'Name', value: 'Ada' },
{ type: 'checkbox', name: 'active', label: 'Active', value: true },
{ type: 'rating', name: 'score', label: 'Score', max: 5, value: 3 },
{ type: 'input', name: 'need', label: 'Need', required: true },
],
});
form.setReadOnly(true); // the whole form is read-only
const nameRO = form.getItem('name').el.querySelector('input').readOnly; // true
const activeRO = form.getItem('active').el.querySelector('input').getAttribute('aria-readonly'); // 'true'
const scoreRO = form.getItem('score').el.querySelector('.lat-form__rating').getAttribute('aria-readonly'); // 'true'
const valueName = form.getValue().name; // 'Ada' - the value survives
form.setReadOnly(false); // editable again
const nameROafter = form.getItem('name').el.querySelector('input').readOnly; // false
form.setDisabled(true); // the whole form is disabled
const nameDisabled = form.getItem('name').el.querySelector('input').disabled; // true
const activeDisabled = form.getItem('active').el.querySelector('input').disabled; // true
const scoreDisabled = form.getItem('score').el.querySelector('.lat-form__rating').getAttribute('aria-disabled'); // 'true'
const validateDisabled = form.validate(); // true - the empty required field is skipped while disabled
form.setDisabled(false);
const validateEnabled = form.validate(); // false - the empty required field fails again
form.destroy();
return [nameRO, activeRO, scoreRO, valueName, nameROafter, nameDisabled, activeDisabled, scoreDisabled, validateDisabled, validateEnabled].join('|');
Label position, width and hidden labels, executed
labelPosition and labelWidth set at the form (or a container or fieldset) are the defaults every item inherits, an item’s own value winning. labelPosition: 'hidden' keeps the label as the accessible name but draws it off-page with no asterisk; a field with no label text falls back to aria-label (the placeholder), and a required field carries aria-required with the requiredHint marker on its placeholder. Real-Chrome geometry is asserted in test/form-label-options-2193-browser.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, {
labelPosition: 'left', labelWidth: 140, // every label defaults left at 140px
requiredHint: true, // append " *" to a required placeholder
rows: [
{ type: 'input', name: 'left', label: 'Name' },
{ type: 'input', name: 'top', label: 'Title', labelPosition: 'top' }, // item wins
{ fieldset: 'Hidden', labelPosition: 'hidden', rows: [
{ type: 'input', name: 'email', label: 'Email', placeholder: 'you@example.com', required: true },
{ type: 'input', name: 'code', placeholder: 'Enter code', required: true }, // no label text
] },
],
});
const leftPos = form.getItem('left').el.getAttribute('data-label-position'); // 'left'
const leftFlex = form.getItem('left').el.querySelector('.lat-form__label').style.flex; // '0 0 140px'
const topPos = form.getItem('top').el.getAttribute('data-label-position'); // 'top'
const emailLabel = form.getItem('email').el.querySelector('.lat-form__label').textContent; // 'Email'
const emailInput = form.getItem('email').el.querySelector('input');
const emailPlaceholder = emailInput.placeholder; // 'you@example.com *'
const emailRequired = emailInput.getAttribute('aria-required'); // 'true'
const codeAria = form.getItem('code').el.querySelector('input').getAttribute('aria-label'); // 'Enter code'
form.destroy();
return [leftPos, leftFlex, topPos, emailLabel, emailPlaceholder, emailRequired, codeAria].join('|');
Help tooltips, read-only locks, field addons and panels, executed
Parity chrome: a tooltip draws a “?” trigger after the label, linked to the control by aria-describedby; a read-only field shows a muted lock inside its right edge (lockIcon: false hides it); an addons list draws round icon buttons (disabled with the field) and an avatar addon that follows the value; and a panel block is a bordered surface with a title, subtitle, divider and header actions. Real-Chrome geometry, light/dark and the 1280x800 screenshot are asserted in the form's browser test.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
let panelAction = 0;
let addonValue = null;
const form = createForm(root, {
rows: [
{ type: 'input', name: 'name', label: 'Name', tooltip: 'Your full name', value: 'Ada' },
{ type: 'input', name: 'locked', label: 'Locked', readOnly: true, value: 'fixed' },
{ type: 'input', name: 'nofrl', label: 'No lock', readOnly: true, lockIcon: false, value: 'open' },
{ type: 'input', name: 'amount', label: 'Amount', value: '42',
addons: [{ icon: 'pencil', ariaLabel: 'Edit', onClick: (v) => { addonValue = v; } }] },
{ type: 'select', name: 'owner', label: 'Owner', value: 'ada',
options: [{ value: 'ada', label: 'Ada Lovelace' }, { value: 'grace', label: 'Grace Hopper' }],
addons: [{ avatar: true }] },
{ type: 'input', name: 'off', label: 'Off', disabled: true, value: 'grey',
addons: [{ icon: 'link', ariaLabel: 'Open' }] },
{ cols: [
{ panel: { title: 'Status', subtitle: 'Delivery state',
actions: [{ icon: 'pencil', ariaLabel: 'Edit status', onClick: () => { panelAction += 1; } }], collapsible: true },
items: [ { type: 'input', name: 'state', label: 'State', value: 'Draft' } ] },
{ panel: { title: 'Financials', subtitle: 'Budget' },
items: [ { type: 'number', name: 'budget', label: 'Budget', value: 120 } ] },
] },
],
});
const nameEl = form.getItem('name').el;
const tipTrigger = nameEl.querySelector('.lat-form__tooltip-trigger');
const tip = nameEl.querySelector('.lat-form__tooltip');
const nameInput = nameEl.querySelector('input');
const tooltipText = tip.textContent; // 'Your full name'
const tooltipLinked = nameInput.getAttribute('aria-describedby') === tip.id; // true
const triggerLabel = tipTrigger.getAttribute('aria-label'); // 'Help'
const locked = form.getItem('locked').el;
const lockEl = locked.querySelector('.lat-form__lock');
const lockVisible = lockEl !== null && lockEl.hidden === false; // true
const lockTitle = lockEl.title; // 'Read-only'
const noLock = form.getItem('nofrl').el.querySelector('.lat-form__lock') === null; // true
const addonBtn = form.getItem('amount').el.querySelector('.lat-form__addon');
const addonTag = addonBtn.tagName.toLowerCase(); // 'button'
const addonIcon = addonBtn.querySelector('svg') !== null; // true
addonBtn.dispatchEvent(new TestEvent('click', { bubbles: true }));
const clickedValue = String(addonValue); // '42'
const avatar = form.getItem('owner').el.querySelector('.lat-combo__avatar');
const avatarInitials = avatar ? avatar.textContent : ''; // 'AL'
const offAddon = form.getItem('off').el.querySelector('.lat-form__addon');
const addonDisabled = offAddon.disabled === true; // true - disabled with the field
const panels = root.querySelectorAll('.lat-form__panel');
const panelCount = String(panels.length); // '2'
const panelTitle = panels[0].querySelector('.lat-form__panel-title').textContent; // 'Status'
const panelSubtitle = panels[0].querySelector('.lat-form__panel-subtitle').textContent; // 'Delivery state'
const panelHeader = panels[0].querySelector('.lat-form__panel-header') !== null; // true - the divider line
const panelToggle = panels[0].querySelector('.lat-form__panel-collapse');
const collapsible = panelToggle !== null && panelToggle.getAttribute('aria-expanded') === 'true'; // true
const panelBtn = panels[0].querySelector('.lat-form__panel-action');
panelBtn.dispatchEvent(new TestEvent('click', { bubbles: true }));
const panelClicked = panelAction === 1; // true
form.destroy();
return [tooltipText, tooltipLinked, triggerLabel, lockVisible, lockTitle, noLock, clickedValue, addonTag, addonIcon, avatarInitials, addonDisabled, panelTitle, panelSubtitle, panelHeader, panelClicked, panelCount, collapsible].join('|');
The button and the buttons row, executed
A button renders its variant, size, icon position, full width and text transform; an icon may be a built-in glyph name or a data: URI; an icon-only button carries its ariaLabel and a href renders a link styled as a button. A submit button validates first and only then fires submit with the values; reset clears; loading/setLoading swaps the icon for a spinner and ignores clicks; and a buttons row lays its children out by align - with the real Chrome geometry and the light/dark colours asserted in test/form-button-2194-browser.test.js.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, {
rows: [
{ type: 'input', subtype: 'email', name: 'email', label: 'Email', required: true },
{ type: 'input', subtype: 'password', name: 'password', label: 'Password' },
{ type: 'button', name: 'signin', label: 'Sign in', variant: 'primary', fullWidth: true, buttonType: 'submit', icon: 'check' },
{ type: 'button', name: 'google', label: 'Continue with Google', variant: 'secondary', fullWidth: true, iconPosition: 'end', icon: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>' },
{ type: 'button', name: 'clear', label: 'Clear', variant: 'outline', fullWidth: true, buttonType: 'reset', textTransform: 'uppercase' },
{ type: 'button', name: 'help', icon: 'info', ariaLabel: 'Help' },
{ type: 'button', name: 'docs', label: 'Docs', href: 'https://example.com/docs' },
{ type: 'button', name: 'pin', label: 'Pin', variant: 'tonal', toggle: true, value: true },
{ type: 'button', name: 'ghost', label: 'Keep', disabled: true, focusableWhenDisabled: true },
{ type: 'buttons', align: 'space-between', items: [
{ name: 'cancel', label: 'Cancel', variant: 'text' },
{ name: 'save', label: 'Save', variant: 'primary' },
] },
],
});
const signin = form.getItem('signin').el.querySelector('.lat-button');
const google = form.getItem('google').el.querySelector('.lat-button');
const clear = form.getItem('clear').el.querySelector('.lat-button');
const help = form.getItem('help').el.querySelector('.lat-button');
const docs = form.getItem('docs').el.querySelector('.lat-button');
const variant = signin.getAttribute('data-variant');
const full = signin.getAttribute('data-full');
const iconPosition = google.querySelector('img') ? 'img' : 'none';
const googleImg = google.querySelector('img') !== null;
const caps = clear.getAttribute('data-text-transform');
const iconOnly = help.getAttribute('data-icon-only');
const helpLabel = help.getAttribute('aria-label');
const linkTag = docs.tagName.toLowerCase();
let submits = 0;
let values = null;
form.el.addEventListener('submit', (e) => { submits += 1; values = e.detail.values; });
signin.dispatchEvent(new TestEvent('click', { bubbles: true }));
const blocked = submits;
form.setValue({ email: 'ada@example.com', password: 's3cret' });
signin.dispatchEvent(new TestEvent('click', { bubbles: true }));
const submitted = submits;
const submittedValues = values.email + '/' + values.password;
clear.dispatchEvent(new TestEvent('click', { bubbles: true }));
const cleared = form.getValue().email;
form.getItem('signin').setLoading(true); // loading swaps the icon for a spinner and ignores clicks
const busy = signin.getAttribute('aria-busy');
const spinner = signin.querySelector('.lat-button__spinner') !== null;
form.getItem('signin').setLoading(false);
const row = root.querySelector('.lat-button-row');
const rowAlign = row.getAttribute('data-align');
const rowButtons = row.querySelectorAll('.lat-button').length;
const pin = form.getItem('pin').el.querySelector('.lat-button');
const pinStart = pin.getAttribute('aria-pressed');
const toggles = [];
form.on('change', (e) => toggles.push(e.value));
pin.dispatchEvent(new TestEvent('click', { bubbles: true }));
const pinAfter = pin.getAttribute('aria-pressed');
const toggleValue = toggles.length === 1 ? String(toggles[0]) : '';
const ghost = form.getItem('ghost').el.querySelector('.lat-button');
const ghostDisabled = ghost.disabled;
const ghostAriaDisabled = ghost.getAttribute('aria-disabled');
form.destroy();
return [variant, full, iconPosition, googleImg, caps, iconOnly, helpLabel, linkTag, blocked, submitted, submittedValues, cleared, busy, spinner, rowAlign, rowButtons, pinStart, pinAfter, toggleValue, ghostDisabled, ghostAriaDisabled].join('|');
The combo control
The same module also ships createCombo(el, config), the standalone form of the form's type: 'combo' item: a searchable combobox with dynamic server values, dependent lists, multi-select and multi-line options. It is a load-driven paging list - the search is debounced, a newer load aborts the older one, pages load on scroll and are cached, and reload() drops the cache - or a static options list when there is no server. Options are a bare value or { value, label, description, icon, avatar, color, dot, disabled }; a description adds a second line, an icon is a glyph name from the grid's icon set drawn as the real SVG (or a DOM node) and an avatar is an image URL or initials, each drawn round on the left of the list rows, the multi-select chips and the single-select shown value, in a virtualised list of variable-height rows; color tints the initials avatar and the icon (an SVG takes it as its fill or stroke, an element as its colour), and dot draws a small filled circle in that colour instead of any icon or avatar, with no letter; a disabled option shows but refuses a click. A multiple combo keeps its value as an array in selection order and draws chips that wrap, cap at maxHeight and scroll, with a +N summary and a selectAll action over the loaded set. chipPlacement moves those chips out of the control - 'above' or 'below' puts them in their own wrapping row beside the field, so the input stays a single clean search line and the list still opens directly under it. resolveLabels(values) supplies labels for a preselected value outside the first page, newOptions turns the box into free entry (with a create row while typing), optionTemplate replaces the row renderer and selectedTemplate the chips and shown value - either may return a DOM node or an HTML string, which is sanitised before insertion - and a form item's dependsOn clears and reloads it when a named parent item changes.
| Member | Description |
|---|---|
| createCombo(el, config?) | Create a standalone combobox. config is the ComboConfig options below. Returns { el, root, input, getValue, setValue, reload, clear, open, close, focus, blur, setDisabled, setReadOnly, destroy }. |
| item | A type: 'combo' item takes the same options: load, options, multiple, value, resolveLabels, newOptions, optionTemplate, selectedTemplate, selectAll, debounce, pageSize, maxHeight, chipPlacement and dependsOn. |
| load({ search, page, signal }) | The paging loader, handed the debounced search, the zero-based page and an AbortSignal, and answering { items, total, hasMore } or a bare array. Absent when options is used. |
| getValue() / setValue(value) | Read the committed value (a single value, or an array in multiple mode), or write one silently - no event fires. |
| reload() / clear() | reload() drops the page cache and reloads page zero for the current search; clear() empties the selection and fires change. |
| open() / close() / focus() / blur() | Drive the popup and keyboard focus; setDisabled()/setReadOnly() gate editing, and destroy() tears the combo down. |
Dynamic values, chips, icons, avatars, templates and a dependent item, executed
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createCombo, createForm } = await import('../packages/modules/form/index.js');
const { document, root } = createTestDom();
// A paged, searchable, multi-select combo with resolved labels and free entry.
const peopleHost = document.createElement('div');
root.appendChild(peopleHost);
const people = createCombo(peopleHost, {
multiple: true,
selectAll: true,
debounce: 50,
pageSize: 10,
maxHeight: 64,
value: ['n0', 'zz9'],
resolveLabels: (values) => values.map((v) => ({ value: v, label: 'R:' + v })),
newOptions: true,
load: ({ search, page }) => {
const start = page * 10;
const items = [];
for (let i = start; i < start + 10 && i < 30; i++) items.push({ value: 'n' + i, label: 'Person ' + i });
return { items, total: 30, hasMore: start + 10 < 30 };
},
});
people.open();
const multi = people.getValue().join(','); // 'n0,zz9' - in selection order
// A static, custom-rendered combo.
const sizedHost = document.createElement('div');
root.appendChild(sizedHost);
const sized = createCombo(sizedHost, {
options: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
optionTemplate: (opt) => 'T:' + opt.label,
});
sized.open();
const renderedRow = Array.from(sized.root.querySelectorAll('.lat-combo__option')).find((r) => r.textContent === 'T:Alpha');
const rendered = renderedRow.textContent; // 'T:Alpha' - the optionTemplate applied
// A static combo with a real 'user' SVG icon, an initials avatar and a
// selectedTemplate over the shown value.
const decoHost = document.createElement('div');
root.appendChild(decoHost);
const deco = createCombo(decoHost, {
options: [
{ value: 'u', label: 'User', icon: 'user' },
{ value: 'b', label: 'Beta', avatar: 'BE', color: '#e81123' },
],
selectedTemplate: (opt) => 'S:' + opt.label,
});
deco.open();
const decoRows = Array.from(deco.root.querySelectorAll('.lat-combo__option'));
const hasIcon = decoRows.some((r) => r.querySelector('.lat-combo__icon') !== null); // true - the 'user' SVG
const hasAvatar = decoRows.some((r) => r.querySelector('.lat-combo__avatar') !== null); // true - round initials
deco.setValue('u');
const shown = deco.root.querySelector('.lat-combo__value').textContent; // 'S:User' - the selectedTemplate
deco.destroy();
// A form item whose combo depends on its parent.
const formHost = document.createElement('div');
root.appendChild(formHost);
const form = createForm(formHost, {
rows: [
{ type: 'input', name: 'country', label: 'Country', value: 'US' },
{ type: 'combo', name: 'city', label: 'City', value: 'NYC', dependsOn: ['country'],
load: () => ({ items: [{ value: 'NYC', label: 'New York' }], total: 1, hasMore: false }) },
],
});
const city = form.getItem('city').getValue(); // 'NYC' - the configured value
people.setDisabled(true);
people.setDisabled(false);
people.setReadOnly(true);
people.clear();
const cleared = people.getValue().length; // 0 - cleared
people.setValue('n1'); // a silent write
people.reload(); // keep the value, drop the pages
const kept = people.getValue().join(',');
people.close();
people.focus();
people.blur();
people.destroy();
sized.destroy();
// Chip placement: the chips wrap above the control in their own labelled row.
const aboveHost = document.createElement('div');
root.appendChild(aboveHost);
const above = createCombo(aboveHost, {
multiple: true,
chipPlacement: 'above',
options: [{ value: 'p', label: 'Pill' }, { value: 'q', label: 'Quill' }],
});
above.setValue(['p']);
const row = above.root.querySelector('.lat-combo__chip-row');
const chipRowAbove = above.root.getAttribute('data-chip-placement') === 'above'
&& row && !row.hidden && row.getAttribute('aria-label') === 'Selected: 1'
&& above.root.children[0] === row; // the row sits above the control
const aboveValue = above.getValue().join(','); // 'p' - the chip row leaves the value untouched
above.destroy();
form.destroy();
return [multi, rendered, hasIcon, hasAvatar, shown, city, cleared, kept, aboveValue, chipRowAbove].join('|');
Coloured status dots and icon tints, executed
A dot: '<colour>' shorthand draws a small filled circle before an option's
label with no letter, and an option's color tints its icon too. The RAG select below shows green, amber
and red dots - the amber one takes a hairline ring so it keeps 3:1 against a light field - while the owner
select tints a circleFilled icon green.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, { rows: [
{ type: 'select', name: 'rag', label: 'RAG status', options: [
{ value: 'ok', label: 'On track', dot: '#2e7d32' },
{ value: 'warn', label: 'Watch', dot: '#ff8f00' },
{ value: 'fail', label: 'Blocked', dot: '#c62828' },
], value: 'ok' },
{ type: 'select', name: 'owner', label: 'Owner', options: [
{ value: 'ada', label: 'Ada', icon: 'circleFilled', color: '#2e7d32' },
], value: 'ada' },
] });
const rag = form.getItem('rag').el.querySelector('.lat-select');
const rows = [...rag.querySelectorAll('.lat-combo__option')];
const rowDots = rows.map((r) => r.querySelector('.lat-combo__dot'));
const okRow = rows.find((r) => r.getAttribute('data-value') === 'ok');
const warnRow = rows.find((r) => r.getAttribute('data-value') === 'warn');
const dot = okRow ? okRow.querySelector('.lat-combo__dot') : null;
const ringed = warnRow ? warnRow.querySelector('.lat-combo__dot').getAttribute('data-ring') === 'true' : false;
const ragValue = rag.querySelector('.lat-select__value');
const triggerDot = ragValue ? ragValue.querySelector('.lat-combo__dot') : null;
const owner = form.getItem('owner').el.querySelector('.lat-select');
const ownerValue = owner.querySelector('.lat-select__value');
const icon = ownerValue ? ownerValue.querySelector('.lat-combo__icon') : null;
const out = [
rowDots.filter(Boolean).length, // 3 - one dot per option row
dot ? dot.textContent : '', // '' - no letter
dot ? dot.style.background : '', // '#2e7d32' - the dot's fill
icon ? icon.style.color : '', // '#2e7d32' - the tinted icon
triggerDot !== null, // the trigger shows the dot
ringed, // the amber dot takes a ring
].join('|');
form.destroy();
return out;
Binding, dirty tracking, save, revert and conflict, executed
A form bound to a grid row generates one item per column with the column's editor, follows the grid's density, marks an edited item dirty, saves once through the grid's edit path and reverts an unsaved edit; a form bound to a data router fills in from the route's keyed diffs, a clean item follows an update and a dirty item is left in place and flagged conflicted.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const { root } = createTestDom();
// A grid row drives the form: no layout is declared, so one item per column is generated.
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'name', title: 'Name', type: 'text', edit: true },
{ field: 'age', title: 'Age', type: 'number', edit: true },
{ field: 'active', title: 'Active', type: 'boolean', edit: true },
],
rows: [{ id: 'a', name: 'Ada', age: 36, active: true }],
});
const form = createForm(root, { bind: { grid, rowKey: 'a' } });
const shown = [form.getValue().name, form.getValue().age, form.getValue().active].join(','); // Ada,36,true
// A committed edit marks the item dirty; programmatic writes stay silent.
const name = form.getItem('name').el.querySelector('input');
name.value = 'Grace';
name.dispatchEvent(new TestEvent('change', { bubbles: true }));
const dirtyName = form.isDirty('name'); // true
// save() writes the dirty items once through the grid's own edit path.
const saved = form.save(); // true
const written = grid.rows.byKey('a').data.name; // 'Grace'
const cleanAfterSave = form.isDirty('name'); // false
// revert() restores an unsaved edit to the bound value.
const age = form.getItem('age').el.querySelector('input');
age.value = '40';
age.dispatchEvent(new TestEvent('change', { bubbles: true }));
form.revert();
const reverted = form.getValue().age; // 36
// A router-bound form fills in from the route's keyed diffs.
const router = createDataRouter({ key: 'kind', rowKey: 'id' });
const { root: root2 } = createTestDom();
const routed = createForm(root2, {
bind: { router, route: 'person', key: 'a' },
rows: [
{ type: 'input', name: 'name', label: 'Name' },
{ type: 'number', name: 'age', label: 'Age' },
],
});
router.load([{ id: 'a', kind: 'person', name: 'Ada', age: 36 }]);
const routedName = routed.getItem('name').el.querySelector('input');
routedName.value = 'Grace';
routedName.dispatchEvent(new TestEvent('change', { bubbles: true }));
// An update changes the clean item and flags the dirty one instead of overwriting it.
router.load([{ id: 'a', kind: 'person', name: 'Srv', age: 40 }]);
const cleanAge = routed.getValue().age; // 40 - clean item followed
const keptName = routed.getValue().name; // 'Grace' - dirty item kept
const conflicted = routed.isConflicted('name'); // true
grid.destroy();
form.destroy();
routed.destroy();
return [shown, dirtyName, saved, written, cleanAfterSave, reverted, cleanAge, keptName, conflicted].join('|');
A custom control, declarative rules and a wizard, executed
registerControl makes a type a first-class item that round-trips its value; the
visibleWhen/readOnlyWhen/requiredWhen rules re-evaluate as values commit; and a
three-step wizard's next()/back()/goToStep() gate each forward move on the step's
validation - with the real Chrome assertions in test/form-browser.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { registerControl, createForm } = await import('../packages/modules/form/index.js');
// A custom control type is a first-class item: it round-trips through the form API.
registerControl('star', {
create(el, item, api) {
const input = el.ownerDocument.createElement('input');
input.className = 'star-input';
el.appendChild(input);
},
getValue(el) { return el.querySelector('.star-input').value; },
setValue(el, value) { el.querySelector('.star-input').value = value == null ? '' : String(value); },
});
const { root } = createTestDom();
const form = createForm(root, {
stepOrientation: 'vertical', // the indicator stacks
steps: [
{ id: 'one', label: 'One', rows: [
{ type: 'input', name: 'plan', label: 'Plan', requiredWhen: true },
{ type: 'input', name: 'code', label: 'Code' },
] },
{ id: 'two', label: 'Two', rows: [
{ type: 'star', name: 'rating', label: 'Rating', value: '3' },
{ type: 'input', name: 'note', label: 'Note', visibleWhen: { name: 'rating', equals: '5' } },
{ type: 'input', name: 'review', label: 'Review', readOnlyWhen: { name: 'rating', in: ['4', '5'] } },
] },
{ id: 'three', label: 'Three', rows: [
{ type: 'input', name: 'summary', label: 'Summary' },
] },
],
});
const vertical = root.querySelector('.lat-form__stepper--vertical') !== null;
const blocked = await form.next(); // 'plan' is required and empty
form.getItem('plan').setValue('Basic'); // a silent write re-runs the rules
const advanced = await form.next(); // step one now validates
const active = form.activeStep; // 1
const custom = form.getItem('rating').getValue(); // '3' - the registered control round-trips
form.setValue({ rating: '5' }); // re-evaluates the rules
const noteHidden = form.getItem('note').el.hidden; // rating 5 → visible
const reviewReadOnly = form.getItem('review').el.querySelector('input').readOnly; // rating 5 → read-only
const wentBack = await form.back(); // back to step one, no validation
const jumped = await form.goToStep(2); // forward again, validating step one on the way
const finish = await form.next(); // on the last step, validates in place
form.destroy();
return [blocked, advanced, active, custom, noteHidden, reviewReadOnly, wentBack, jumped, finish, vertical].join('|');
Required, a regex, a function, modes and the validated event, executed
An empty required item fails form.validate() with its catalogue message (overridden here to French) and
aria-invalid; a regex rule and a function rule each pass and fail through item.validate(); a
validationMode: 'change' item re-checks itself on a committed edit; and a final form.validate()
passes once every item is valid - with the real Chrome geometry and aria-invalid asserted in
test/form-browser.test.js.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
let validated = 0;
const form = createForm(root, {
messages: { t: (key) => (key === 'form.validation.required' ? 'Pflichtfeld' : key) },
rows: [
{ type: 'input', name: 'name', label: 'Name', required: true },
{ type: 'input', name: 'code', label: 'Code', validation: /^[A-Z]{3}$/, errorMessage: 'Three capitals' },
{ type: 'input', name: 'age', label: 'Age', validation: (v) => v === '' || Number(v) >= 18 || 'Must be 18+', validationMode: 'change' },
{ type: 'input', name: 'site', label: 'Site', validation: 'url', successMessage: 'Reachable', preMessage: 'https://…' },
],
});
form.on('validated', () => { validated += 1; });
const emptyFails = form.validate(); // false - the required name is empty
const nameInput = form.getItem('name').el.querySelector('input');
const nameInvalid = nameInput.getAttribute('aria-invalid'); // 'true'
const nameMessage = form.getItem('name').el.querySelector('.lat-form__validation').textContent; // 'Pflichtfeld'
form.setValue({ code: 'abc' });
const codeFails = form.getItem('code').validate(); // false - the regex rule
form.setValue({ code: 'XYZ' });
const codePasses = form.getItem('code').validate(); // true
const ageInput = form.getItem('age').el.querySelector('input');
ageInput.value = '3';
ageInput.dispatchEvent(new TestEvent('change', { bubbles: true }));
const ageInvalid = ageInput.getAttribute('aria-invalid'); // 'true' - change mode
form.setValue({ name: 'Ada', age: 30, site: 'https://example.com' });
const allValid = form.validate(); // true
const success = form.getItem('site').el.querySelector('.lat-form__validation').textContent; // 'Reachable'
return [emptyFails, nameInvalid, nameMessage, codeFails, codePasses, ageInvalid, allValid, success, validated].join('|');
A server form enhanced in place, with the htmx adapter, executed
enhanceForm turns a server-rendered <form> into a Lattice form in
place - each named field keeps its name, value, label and validation, and the submit controls survive the
mount so hx-post still posts - and the htmx adapter's autoInitForm,
registerForm, destroyFormWithin, saveFormStateWithin and
restoreFormStateWithin drive it declaratively from data-lattice-form, the same
save/restore pattern as data-lattice-shell. The field mapping, submit preservation and
validate-before-post gate are driven against the real createForm in test/form-enhance.test.js,
and the adapter wiring in test/htmx-form.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { enhanceForm } = await import('../packages/modules/form/index.js');
const H = await import('../packages/modules/htmx/index.js');
const { root, document: doc } = createTestDom();
// A server-rendered form: its fields and submit button are plain markup, so
// with JavaScript off it still posts exactly as the server rendered it.
const server = doc.createElement('form');
server.setAttribute(H.FORM_ATTR, '');
server.setAttribute('hx-post', '/contact');
const label = doc.createElement('label');
label.setAttribute('for', 'email');
label.textContent = 'Email';
const email = doc.createElement('input');
email.id = 'email';
email.name = 'email';
email.type = 'email';
email.required = true;
const submit = doc.createElement('button');
submit.type = 'submit';
server.appendChild(label);
server.appendChild(email);
server.appendChild(submit);
server.elements = [email, submit];
root.appendChild(server);
// The page's form module registers the enhancer once; the htmx adapter resolves
// it at build time rather than inlining the form engine into its bundle.
H.registerForm(enhanceForm);
const built = H.autoInitForm(root); // one form enhanced in place
const published = server.__latticeForm === built[0]; // the form published itself on the host
built[0].setValue({ email: 'a@b.c' }); // a silent write, no event
const saved = H.saveFormStateWithin(server); // values → the history attribute
built[0].setValue({ email: 'changed@x.y' }); // a later edit, as a swap would leave
const restoredCount = H.restoreFormStateWithin(server, H.autoInitForm);
const restored = built[0].getValue().email; // 'a@b.c' - the saved values win
const destroyed = H.destroyFormWithin(server); // scoped teardown on a swap
const gone = server.__latticeForm === null; // the host no longer publishes a form
return [H.FORM_ATTR, built.length, published, saved, restoredCount, restored, destroyed, gone].join('|');
Choice, toggle and date controls, executed
A select renders its own trigger and drop-down listbox on the combo's listbox and commits when an option is chosen - or keeps the native element with native: true;
a radiogroup and a checkboxgroup commit on their options' change - laid out vertically by
default, on one wrapping row with orientation: 'horizontal' (the radiogroup carries a matching
aria-orientation and ArrowLeft/ArrowRight move between radios), or in an n-column grid with
columns: n; a toggle is a
boolean switch; and a datepicker stores an ISO wall-clock value, reads a Date in its
timeZone like the grid's date columns, and refuses a commit outside min/max - with the keyboard and calendar-click path asserted in real Chrome in
test/form-browser.test.js. Its options take the same adornments as the combo's - a real
icon, a round avatar, a color that tints them, and a dot
colour shorthand - and both the list rows and the
shown value accept optionTemplate/selectedTemplate renderers (a DOM node or an
HTML string, sanitised before insertion).
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, {
rows: [
{ type: 'select', name: 'size', label: 'Size', options: ['s', 'm', 'l'], value: 'm' },
{ type: 'select', name: 'nativeSize', label: 'Native size', native: true, options: ['s', 'm', 'l'], value: 'm' },
{ type: 'radiogroup', name: 'colour', label: 'Colour', options: ['red', 'green'], value: 'green' },
{ type: 'checkboxgroup', name: 'extras', label: 'Extras', options: [{ value: 'roof', label: 'Roof' }, { value: 'tow', label: 'Tow bar' }], value: ['tow'] },
{ type: 'toggle', name: 'auto', label: 'Automatic', value: true },
{ type: 'datepicker', name: 'day', label: 'Day', locale: 'en-GB', min: '2026-01-01', max: '2026-12-31', value: '2026-06-15' },
{ type: 'datepicker', name: 'slot', label: 'Slot', mode: 'date-time', timeZone: 'UTC', value: new Date('2026-06-15T09:30:00Z') },
{ type: 'datepicker', name: 'clock', label: 'Clock', mode: 'time', value: '07:45' },
],
});
const started = [form.getValue().size, form.getValue().colour, form.getValue().extras.join(','), form.getValue().auto, form.getValue().day, form.getValue().slot, form.getValue().clock].join('|');
let changes = 0;
form.on('change', () => { changes += 1; });
const sizeOption = form.getItem('size').el.querySelector('[data-value="l"]');
sizeOption.dispatchEvent(new TestEvent('click', { bubbles: true }));
const toggle = form.getItem('auto').el.querySelector('input');
toggle.checked = false;
toggle.dispatchEvent(new TestEvent('change', { bubbles: true }));
const radio = form.getItem('colour').el.querySelectorAll('input')[0];
radio.checked = true;
radio.dispatchEvent(new TestEvent('change', { bubbles: true }));
const boxes = form.getItem('extras').el.querySelectorAll('input');
boxes[0].checked = true;
boxes[1].checked = true;
boxes[0].dispatchEvent(new TestEvent('change', { bubbles: true }));
const committed = [form.getValue().size, form.getValue().colour, form.getValue().extras.join(','), form.getValue().auto, changes].join('|');
const dayItem = form.getItem('day');
dayItem.el.querySelector('.lat-form__datepicker-toggle').dispatchEvent(new TestEvent('click', { bubbles: true }));
dayItem.el.querySelector('.lat-form__datepicker-day').dispatchEvent(new TestEvent('click', { bubbles: true }));
const clicked = form.getValue().day;
const dayInput = form.getItem('day').el.querySelector('input');
dayInput.value = '2025-01-01';
dayInput.dispatchEvent(new TestEvent('change', { bubbles: true }));
const refused = form.getValue().day;
dayInput.value = '2026-07-04';
dayInput.dispatchEvent(new TestEvent('change', { bubbles: true }));
const accepted = form.getValue().day;
form.getItem('clock').setValue('23:59');
const clock = form.getItem('clock').getValue();
const nativeSelect = form.getItem('nativeSize').el.querySelector('select');
const nativeRendered = nativeSelect !== null;
nativeSelect.value = 'l';
nativeSelect.dispatchEvent(new TestEvent('change', { bubbles: true }));
const nativeValue = form.getValue().nativeSize;
const native = [nativeRendered, nativeValue].join('|');
form.destroy();
return [started, committed, clicked, refused, accepted, clock, native].join('|');
A formatted number field, executed
A number item edits the raw number and shows the grid's own
NumberFormat while blurred - currency, percent and fixed decimals - so getValue() stays a
number while the field reads £20,000.00. spinner: true draws two step buttons, and
locale resolves the typed-input separators, so a comma-decimal locale parses its own convention. The
focus/blur flip and the ArrowUp/ArrowDown stepping are asserted in real Chrome in
test/form-browser.test.js.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, { rows: [
{ type: 'number', name: 'price', label: 'Price', format: { style: 'currency', currency: 'GBP' }, locale: 'en-GB', value: 20000 },
{ type: 'number', name: 'rate', label: 'Rate', format: { style: 'percent' }, locale: 'en-US', value: 0.25 },
{ type: 'number', name: 'qty', label: 'Qty', format: { decimals: 2 }, locale: 'en-GB', value: 5 },
{ type: 'number', name: 'eur', label: 'EUR', format: { style: 'currency', currency: 'EUR' }, locale: 'de-DE', value: 20000 },
{ type: 'number', name: 'steps', label: 'Steps', spinner: true, step: 5, min: 0, max: 100, value: 50 },
] });
const priceShown = form.getItem('price').el.querySelector('input').value; // '£20,000.00' blurred
const priceValue = form.getValue().price; // 20000, still a number
const rateShown = form.getItem('rate').el.querySelector('input').value; // '25%'
const rateValue = form.getValue().rate; // 0.25, the stored fraction
const qtyShown = form.getItem('qty').el.querySelector('input').value; // '5.00' fixed decimals
const eurGrouped = form.getItem('eur').el.querySelector('input').value.includes('20.000,00'); // true, de-DE separators
const spinners = form.getItem('steps').el.querySelectorAll('.lat-form__number-step').length; // 2
form.setValue({ price: 1500.5 });
const typedShown = form.getItem('price').el.querySelector('input').value; // '£1,500.50' reformats on write
const qtyInput = form.getItem('qty').el.querySelector('input');
qtyInput.focus(); // focused: the field edits the raw number
qtyInput.value = '2,500';
qtyInput.dispatchEvent(new TestEvent('change', { bubbles: true }));
const pastedValue = form.getValue().qty; // 2500, parsed in the locale convention
form.destroy();
return [priceShown, priceValue, rateShown, rateValue, qtyShown, eurGrouped, spinners, typedShown, pastedValue].join('|');
Unit-aware number fields, executed
A number item takes the grid's own unit configuration - system and
unit, plus the display options - and stores a plain number in the configured base unit. Blurred it shows
the quantity with its unit; typing any unit of the same system converts on commit (145 psi into a bar
field stores about 9.997), and an unknown unit is refused while the previous value is kept. A
scale makes a temperature field, whose conversion is affine (212 °F into a Celsius field
stores 100); unitPicker: true draws a compact selector that changes only the display unit.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, { rows: [
{ type: 'number', name: 'pressure', label: 'Pressure', system: 'pressure', unit: 'bar', value: 10 },
{ type: 'number', name: 'capacity', label: 'Capacity', system: 'data', unit: 'GB', locale: 'en-US', value: 10 },
{ type: 'number', name: 'inlet', label: 'Inlet', scale: 'C', value: 100 },
{ type: 'number', name: 'gauge', label: 'Gauge', system: 'pressure', unit: 'bar', unitPicker: true, locale: 'en-US', value: 10 },
] });
const pressure = form.getItem('pressure');
const pInput = pressure.el.querySelector('input');
const shown = pInput.value; // '10 bar'
pInput.dispatchEvent(new TestEvent('focus'));
pInput.value = '145 psi';
pInput.dispatchEvent(new TestEvent('change', { bubbles: true }));
const psiStored = form.getValue().pressure.toFixed(3); // '9.997'
pInput.dispatchEvent(new TestEvent('blur'));
const cInput = form.getItem('capacity').el.querySelector('input');
cInput.dispatchEvent(new TestEvent('focus'));
cInput.value = '10,000M';
cInput.dispatchEvent(new TestEvent('change', { bubbles: true }));
const dataStored = form.getValue().capacity; // 10
cInput.dispatchEvent(new TestEvent('blur'));
pInput.dispatchEvent(new TestEvent('focus'));
pInput.value = '20 kg';
pInput.dispatchEvent(new TestEvent('change', { bubbles: true }));
const kept = form.getValue().pressure.toFixed(3); // '9.997', the old value
const refused = pressure.el.getAttribute('data-invalid'); // 'true'
pInput.dispatchEvent(new TestEvent('blur'));
const tInput = form.getItem('inlet').el.querySelector('input');
tInput.dispatchEvent(new TestEvent('focus'));
tInput.value = '212 °F';
tInput.dispatchEvent(new TestEvent('change', { bubbles: true }));
const celsius = Math.round(form.getValue().inlet); // 100
tInput.dispatchEvent(new TestEvent('blur'));
const gauge = form.getItem('gauge');
const gInput = gauge.el.querySelector('input');
const picker = gauge.el.querySelectorAll('select')[0];
picker.value = 'psi';
picker.dispatchEvent(new TestEvent('change', { bubbles: true }));
const picked = gInput.value; // '145.04 psi'
const unchanged = form.getValue().gauge; // 10, unchanged
form.destroy();
return [shown, psiStored, dataStored, kept, refused, celsius, picked, unchanged].join('|');
Choice-group layout, executed
A radiogroup and a checkboxgroup stack their options vertically by default;
orientation: 'horizontal' lays them on one wrapping row and carries a matching aria-orientation,
and columns: n lays them in an n-column grid read across then down - with the real-Chrome rows, wrapping,
keyboard and density gaps asserted in test/form-choice-layout-2177-browser.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, { rows: [
{ type: 'radiogroup', name: 'billing', label: 'Billing', orientation: 'horizontal',
options: ['annual', 'monthly'], value: 'annual' },
{ type: 'checkboxgroup', name: 'grid', label: 'Grid', columns: 2,
options: ['a', 'b', 'c', 'd'] },
] });
const radioGroup = form.getItem('billing').el.querySelector('.lat-form__radiogroup');
const gridGroup = form.getItem('grid').el.querySelector('.lat-form__checkboxgroup');
const orientation = radioGroup.getAttribute('aria-orientation'); // 'horizontal'
const horizontal = radioGroup.classList.contains('lat-form__radiogroup--horizontal'); // true
const columns = gridGroup.classList.contains('lat-form__checkboxgroup--columns'); // true
const count = gridGroup.style.getPropertyValue('--lat-form-columns'); // '2'
form.destroy();
return [orientation, horizontal, columns, count].join('|');
A file field and a sanitised rich-text editor, executed
A file item admits no files until they are dropped or picked, round-trips its accepted
entries through getValue()/setValue(), and clears to an empty array; a richtext item
round-trips its sanitised HTML value. The in-tree DOM test double has no HTML parser, so the sanitiser degrades to a
text-only pass there - the real whitelist behaviour (scripts, event handlers and javascript: URLs dropped)
is asserted in real Chrome in test/form-browser.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, { rows: [
{ type: 'file', name: 'docs', label: 'Documents', accept: ['.pdf'], maxSize: 1048576, maxFiles: 2, multiple: true, dropText: 'Drop here', hint: 'PDFs up to 1 MB', upload: (file, opts) => null },
{ type: 'richtext', name: 'body', label: 'Body', toolbar: 'rich', value: '<p>Hello <b>world</b></p>', minHeight: 120, maxHeight: 300, linkTarget: '_blank', colors: ['#1f2937'], headings: [['p', 'Paragraph'], ['h1', 'Heading 1']], allowedTags: null, maxImageSize: 512000, onImage: null, sanitizePaste: true },
] });
const fileEmpty = form.getValue().docs.length; // 0 - nothing admitted yet
const fileZone = !!form.getItem('docs').el.querySelector('.lat-uploader__zone');
form.setValue({ docs: [{ id: 'f1', name: 'a.pdf', size: 2048, type: 'application/pdf', status: 'done', progress: 100 }] });
const docName = form.getItem('docs').getValue()[0].name; // 'a.pdf'
const body = form.getItem('body').getValue(); // 'Hello world' - the test double strips tags
const editor = !!form.getItem('body').el.querySelector('.lat-form__richtext-editor');
form.clear();
const cleared = form.getValue().docs.length; // 0 - a file clears to []
form.destroy();
return [fileEmpty, fileZone, docName, body, editor, cleared].join('|');
A file drop area with a custom icon and browse word, executed
A file item’s icon is a glyph name from the file module’s own sprite set, an SVG
markup string, an element, or false for none; browseText replaces the browse link’s word, and the
drop area’s aria-label follows it. The custom cloud SVG, title, hint and word below are asserted in real Chrome in
test/form-browser.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, { rows: [
{ type: 'file', name: 'contract', label: 'Contract', icon: '<svg viewBox="0 0 16 16"><path d="M4 12a3 3 0 0 1 0-6 4 4 0 0 1 7.7 1.2A2.5 2.5 0 0 1 13 12z" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>', dropText: 'Drop the signed contract', hint: 'PDF only, 10 MB max', browseText: 'choose a file' },
] });
const control = form.getItem('contract').el.querySelector('.lat-uploader');
const cloud = !!(control.querySelector('.lat-uploader__icon') && control.querySelector('.lat-uploader__icon').getAttribute('viewBox') === '0 0 16 16'); // true - the cloud SVG
const browse = control.querySelector('.lat-uploader__browse').textContent; // 'choose a file'
const title = control.querySelector('.lat-uploader__title-rest').textContent; // 'Drop the signed contract'
const hint = control.querySelector('.lat-uploader__hint').textContent; // 'PDF only, 10 MB max'
const aria = control.querySelector('.lat-uploader__zone').getAttribute('aria-label'); // 'Drop the signed contract choose a file'
form.destroy();
return [cloud, browse, title, hint, aria].join('|');
A single-select and a multi-select chips row, executed
A chips item is a row of pill buttons: single mode behaves like a radio group (the first
option is selected by default, a click selects another), multi mode returns the selected values in option order and
caps the count at max, and allowEmpty with required fails form.validate()
when nothing is picked - with the real Chrome selection geometry and the light/dark computed colours asserted in
test/form-browser.test.js.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, { rows: [
{ type: 'chips', name: 'lang', label: 'Language', options: [{ value: 'js', label: 'JavaScript' }, { value: 'ts', label: 'TypeScript' }, { value: 'py', label: 'Python', disabled: true }] },
{ type: 'chips', name: 'tags', label: 'Tags', multiple: true, max: 2, wrap: false, options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }, { value: 'c', label: 'C' }] },
{ type: 'chips', name: 'pick', label: 'Pick one', allowEmpty: true, required: true, options: [{ value: 'x', label: 'X' }, { value: 'y', label: 'Y' }] },
] });
const started = form.getItem('lang').getValue(); // 'js' - single mode defaults to the first option
const chips = form.getItem('lang').el.querySelectorAll('.lat-form__chip');
chips[1].dispatchEvent(new TestEvent('click', { bubbles: true }));
const clicked = form.getItem('lang').getValue(); // 'ts' - a click selects one chip
form.setValue({ tags: ['c', 'a'] }); // written in any order
const ordered = form.getItem('tags').getValue().join(','); // 'a,c' - read back in option order
const capped = form.getItem('tags').getValue().length; // 2 - the max cap holds
const requiredFails = form.validate(); // false - allowEmpty + required, nothing picked
return [started, clicked, ordered, capped, requiredFails].join('|');
Password reveal and one-time-code entry, executed
A password input's reveal button renders inside the field with its catalogue name and
aria-pressed, and reveal: false drops it; an otp item renders length
single-character boxes of the given mode (optionally masked) and round-trips the joined code through
getValue()/setValue(). The toggle by click and a real keyboard press, the reset on blur,
typing/Backspace/paste, complete firing once and the real Chrome geometry are asserted in
test/form-password-otp-browser.test.js.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createForm } = await import('../packages/modules/form/index.js');
const { root } = createTestDom();
const form = createForm(root, { rows: [
{ type: 'input', name: 'secret', label: 'Secret', subtype: 'password', value: 'hunter2', reveal: true },
{ type: 'input', name: 'quiet', label: 'Quiet', subtype: 'password', value: 'hunter2', reveal: false },
{ type: 'otp', name: 'code', label: 'Code', length: 6, mode: 'numeric' },
{ type: 'otp', name: 'word', label: 'Word', length: 4, mode: 'alphanumeric', mask: true },
] });
// Password reveal: an eye inside the field, named from the catalogue; reveal:false drops it.
const eye = form.getItem('secret').el.querySelector('.lat-form__password-btn');
const masked = form.getItem('secret').el.querySelector('input').type; // 'password'
const pressed = eye.getAttribute('aria-pressed'); // 'false'
const eyeName = eye.getAttribute('aria-label'); // 'Show password'
const noEye = form.getItem('quiet').el.querySelector('.lat-form__password-btn') === null; // true
// OTP: length boxes, one numeric inputmode, each digit announced.
const code = form.getItem('code').el.querySelector('.lat-form__otp');
const boxCount = code.querySelectorAll('.lat-form__otp-input').length; // 6
const inputmode = code.querySelector('.lat-form__otp-input').getAttribute('inputmode'); // 'numeric'
const pattern = code.querySelector('.lat-form__otp-input').getAttribute('pattern'); // '[0-9]'
const third = code.querySelectorAll('.lat-form__otp-input')[2].getAttribute('aria-label'); // 'Digit 3 of 6'
const wordMask = form.getItem('word').el.querySelector('.lat-form__otp-input').classList.contains('lat-form__otp-input--masked'); // true
// Round-trip: setValue fills the boxes (separators stripped), getValue joins them.
form.setValue({ code: '123 456', word: 'ab-7' });
const joined = form.getValue().code; // '123456'
const wordJoined = form.getValue().word; // 'ab7'
form.destroy();
return [masked, pressed, eyeName, noEye, boxCount, inputmode, pattern, third, wordMask, joined, wordJoined].join('|');
Type reference
Generated from the type declarations, so it always matches the release. Each surface lists its properties, its methods and the events it raises as three tables; an option or value type lists its members once.
The form module
FormChipOption
One selectable option of a `chips` item.
| Property | Type | Description |
|---|---|---|
| value | unknown | The option's value, returned by `getValue()` when selected. |
| label | string | The text shown on the chip. |
| icon | string | An SVG path `d` string drawn as the chip's inline icon. (optional) |
| disabled | boolean | Start disabled: not clickable, skipped by the keyboard. (optional) |
FormOption
One choice of a `select`, `radiogroup` or `checkboxgroup` item. A `select` option may also carry an icon, an avatar, a second line, a colour, a status dot and a disabled flag,,.
| Property | Type | Description |
|---|---|---|
| value | string | number | The option's committed value. |
| label | string | The option's display label; the value itself when omitted. (optional) |
| icon | string | The option's icon, drawn on the left in a `select`; `color` tints it. (optional) |
| avatar | string | An image URL (drawn round, falling back to the label's initials), or initials text (drawn round on a colour from `color` or a hash of the label). (optional) |
| color | string | The background colour for the initials avatar (a stable colour derived from the label when omitted) and the tint of the option's icon,. (optional) |
| dot | string | A colour that draws a small filled circle before the label instead of an icon or avatar, with no letter. (optional) |
| description | string | The option's second line, shown under the label in a `select`. (optional) |
| disabled | boolean | Whether the option is shown but not selectable. (optional) |
FormOptionGroup
A group of `select` options under one heading.
| Property | Type | Description |
|---|---|---|
| label | string | The group heading. |
| options | (string | FormOption | FormOptionGroup)[] | The group's options, nested groups allowed. |
FormStep
One wizard step: a labelled group of rows, shown one at a time.
| Property | Type | Description |
|---|---|---|
| id | string | The step id, for the indicator's accessible label; defaults to `step-<n>`. (optional) |
| label | string | The step label shown in the indicator. (optional) |
| rows | FormBlock[] | The step's blocks, stacked vertically. |
| css | Record<string, string | number> | string | Extra CSS on the step body. (optional) |
FormControlApi
The object a custom control's `create` receives to talk back to the form.
| Property | Type | Description |
|---|---|---|
| name | string | null | The item's name, or `null` for an unnamed item. (read-only) |
| item | FormItemConfig | The item's config. (read-only) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| getValue | (): unknown | - | unknown | Read the committed value. |
| setValue | (value: unknown): void | value: unknown | void | Write a committed value, silently (no event fires). |
| onChange | (value?: unknown): void | value?: unknown | void | Commit the current value through `beforeChange`/validation/`change`. |
| onInput | (value?: unknown): void | value?: unknown | void | Report a live (uncommitted) value. |
FormControlDefinition
A custom control type's definition.
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| create | (el: HTMLElement, item: FormItemConfig, api: FormControlApi): void | el: HTMLElementitem: FormItemConfigapi: FormControlApi | void | Build the control's DOM into `el` and capture what the other hooks need. |
| getValue | (el: HTMLElement): unknown | el: HTMLElement | unknown | Read the control's current value from the DOM it built. |
| setValue | (el: HTMLElement, value: unknown): void | el: HTMLElementvalue: unknown | void | Write a committed value into the control's DOM. |
| validate | (value: unknown, item: FormItemConfig, el: HTMLElement): boolean | string | Promise<boolean | string> | value: unknownitem: FormItemConfigel: HTMLElement | boolean | string | Promise<boolean | string> | Refuse a commit: `true` accepts, `false`/a message refuses, a Promise is awaited. (optional) |
| destroy | (el: HTMLElement): void | el: HTMLElement | void | Tear the control down on `form.destroy()`. (optional) |
FormFieldAddon
A round icon button (or a value-following avatar) attached to the right of a field.
| Property | Type | Description |
|---|---|---|
| icon | string | Node | A glyph name from the grid's icon set, or a DOM node, drawn inside the button. (optional) |
| ariaLabel | string | The button's accessible name (required for an icon-only addon). (optional) |
| tooltip | string | The button's hover title; the `ariaLabel` when omitted. (optional) |
| href | string | A URL that turns the addon into a link instead of a button. (optional) |
| avatar | boolean | string | ((value: unknown, item: FormItemConfig) => string) | Draw a round avatar beside the field (instead of an icon button): an image URL, initials text, `true` to derive it from the selected option's `avatar`/`label`, or `(value, item) => string`. It follows the field's value. (optional) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| onClick | (value: unknown, item: FormItemConfig) => void | value: unknownitem: FormItemConfig | => void | Fired with the field's committed value and its item when the addon is clicked. (optional) |
FormPanelAction
A round icon button in a panel section's header.
| Property | Type | Description |
|---|---|---|
| icon | string | Node | A glyph name from the grid's icon set, or a DOM node, drawn inside the button. (optional) |
| ariaLabel | string | The button's accessible name (required for an icon-only action). (optional) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| onClick | () => void | - | => void | Fired when the action is clicked. (optional) |
FormPanelConfig
A panel section: its own surface holding child blocks, with a title, subtitle, divider and header actions.
| Property | Type | Description |
|---|---|---|
| title | string | The panel's heading, drawn with heading semantics. (optional) |
| subtitle | string | A muted line under the title. (optional) |
| actions | FormPanelAction[] | Round icon buttons in the panel's header. (optional) |
| collapsible | boolean | Draw a collapse/expand toggle in the header that hides and shows the panel body (default false). (optional) |
FormItemConfig
One form item: a control (`input`, `number`, `textarea`, `checkbox`, `button`, `combo`, `select`, `radiogroup`, `checkboxgroup`, `toggle`, `datepicker`, `file`, `richtext`, `rating`, `stage`, `chips`) or the read-only `text` display, with the per-item options.
| Property | Type | Description |
|---|---|---|
| type | string | The control type - a built-in or a type registered with `registerControl`. Defaults to `input`. (optional) |
| name | string | The item's name, for `getItem(name)` and `getValue()`/`setValue()`. (optional) |
| label | string | The label text, associated to the control for screen readers. (optional) |
| labelPosition | FormLabelPosition 'top' | 'left' | 'hidden' | Where the label sits: `top` (default), `left`, or `hidden` (visually hidden but still the accessible name). (optional) |
| labelWidth | number | string | The label column width when `labelPosition` is `left`. (optional) |
| width | number | string | The item's width: a pixel number, a CSS length string such as `'12em'`, or a percentage string such as `'33.3%'`. In a `cols` row a percentage is a share of the row's width *after* the gaps between its items, so items totalling 100% lay out on one line with the gaps intact and an unsized neighbour takes the remaining space; a px/rem width is a fixed flex basis. (optional) |
| placeholder | string | Placeholder text for a text-like control. (optional) |
| helpMessage | string | Help text shown under the control. (optional) |
| hidden | boolean | Start hidden. (optional) |
| disabled | boolean | Start disabled. (optional) |
| readOnly | boolean | Start read-only. (optional) |
| tooltip | string | Help text a "?" trigger after the label reveals on hover, focus, Enter and tap, linked to the control by `aria-describedby`. (optional) |
| lockIcon | boolean | Show the read-only lock inside a read-only field's right edge (default true); `false` hides it. (optional) |
| addons | FormFieldAddon[] | Round icon buttons (and avatar addons) attached to the right of the field, disabled with it. (optional) |
| value | unknown | The initial value. (optional) |
| required | boolean | An empty value fails validation. (optional) |
| requiredHint | boolean | string | The marker a required field with no visible label shows on its placeholder: `true` (the default) appends `" *"`, a string resolves through the message catalogue, and `false` shows nothing. (optional) |
| validation | FormValidationRule | A further rule: a built-in name, a RegExp, or a function `(value, item) => boolean | string | void`. (optional) |
| errorMessage | string | The text shown when the item fails, overriding the rule's default. (optional) |
| successMessage | string | The text shown when the item passes validation (after it has been validated). (optional) |
| preMessage | string | The hint shown before the first validation verdict. (optional) |
| validationMode | FormValidationMode 'change' | 'blur' | 'submit' | When the item re-checks itself: `change`, `blur`, or `submit` (default, on `validate()` only). (optional) |
| subtype | FormInputSubtype 'text' | 'email' | 'password' | 'url' | The native input subtype for an `input` item. (optional) |
| reveal | boolean | Show the reveal button inside a `password` input (default true); `false` turns it off. (optional) |
| length | number | The box count of an `otp` item (default 6). (optional) |
| mode | 'numeric' | 'alphanumeric' | FormPickerMode | The `otp` characters - `numeric` (default) or `alphanumeric` - or the `datepicker` mode: `date`, `date-time` or `time`. (optional) |
| mask | boolean | Hide each `otp` box's glyph with the password disc. (optional) |
| step | number | The number step, for a `number` item. (optional) |
| format | NumberFormat | string | The number format a `number` item displays when blurred, the same shape a grid number column takes - a `NumberFormat` spec (which may name `pattern`) or a bare pattern string like `'#,##0.00'`,. The value stays a number; only the display is formatted. (optional) |
| spinner | boolean | Draw two step buttons beside a `number` item's field (default false); the native spinner never appears either way. (optional) |
| system | string | Makes a `number` item a unit-aware field: which unit system it measures in - `'data'`, `'length'`, `'mass'`, `'pressure'` and the rest, plus anything registered with `registerUnitSystem`. (optional) |
| unit | string | The unit the `number` item's stored values are in, e.g. `'bar'`; defaults to the system's base unit. (optional) |
| binary | boolean | Use the power-of-two ladder for a `data` unit field. (optional) |
| decimals | number | Fixed decimal places for the unit display, setting both the minimum and maximum. (optional) |
| minDecimals | number | The fewest decimal places in a unit display. (optional) |
| maxDecimals | number | The most decimal places in a unit display. (optional) |
| display | string | Which unit the field renders in: a symbol from the system, or `'auto'` to pick the most readable rung. (optional) |
| group | boolean | Whether to group thousands in a unit display. (optional) |
| space | string | What sits between the number and the unit symbol. (optional) |
| placement | 'suffix' | 'prefix' | Whether the unit symbol goes after the number or before it. (optional) |
| compound | string[] | Render one stored number across an ordered subset of the system's units, e.g. `['ft', 'in']`. (optional) |
| significantFigures | number | Round the unit display to this many significant figures before the rung is chosen. (optional) |
| nullDisplay | string | Text for a null or non-finite unit value, empty by default. (optional) |
| scale | string | Makes a `number` item a temperature field, in this scale (`'C'`, `'F'`, `'K'` or `'R'`); conversion is affine, so `212 °F` into a Celsius field stores `100`. (optional) |
| unitPicker | boolean | Show a compact unit selector inside the field that changes only the display unit, never the committed value. (optional) |
| min | number | string | The minimum - a number for `number` and a multi `chips` selection count, an ISO string for `datepicker`. (optional) |
| max | number | string | The maximum - a number for `number` and a multi `chips` selection count, an ISO string for `datepicker`. (optional) |
| rows | number | The visible rows, for a `textarea` item. (optional) |
| options | (string | FormOption | FormOptionGroup | FormChipOption)[] | The choices of a `select`, `radiogroup` or `checkboxgroup` item: strings, `{ value, label }` objects, or `{ label, options }` groups; or the selectable options of a `chips` item (`{ value, label, icon?, disabled? }` entries). (optional) |
| orientation | 'vertical' | 'horizontal' | Lay a `radiogroup` or `checkboxgroup`'s options out vertically (the default) or on one wrapping horizontal line. (optional) |
| columns | number | Lay a `radiogroup` or `checkboxgroup`'s options out in this many columns, read across then down. (optional) |
| native | boolean | Keep the native `<select>` element rather than the rendered trigger and listbox. (optional) |
| locale | string | The locale the `datepicker` formats month/weekday names in, and the `number` item's format and typed-input separators resolve against; the viewer's when omitted. (optional) |
| timeZone | string | The timeZone the `datepicker` reads a Date/epoch value in, like the grid's date columns. (optional) |
| text | string | Shorthand for a read-only `text` display. (optional) |
| visibleWhen | FormRule | A rule that hides the item when it evaluates false. (optional) |
| readOnlyWhen | FormRule | A rule that makes the item read-only when it evaluates true. (optional) |
| requiredWhen | FormRule | A rule that makes the item required when it evaluates true. (optional) |
| css | Record<string, string | number> | string | Extra CSS on the item wrapper. (optional) |
| accept | string[] | Extensions or MIME patterns a `file` item accepts (empty = anything). (optional) |
| maxSize | number | The maximum file size in bytes a `file` item admits. (optional) |
| maxFiles | number | The maximum number of files a `file` item admits. (optional) |
| multiple | boolean | Allow picking several files at once for a `file` item (default true), toggling several options for a `chips` item (default false, single-select), or a multi-select `select` whose committed value is an array. (optional) |
| dropText | string | Drop-zone text for a `file` item. (optional) |
| hint | string | A hint line under the drop zone of a `file` item. (optional) |
| browseText | string | Replaces the browse link's word in a `file` item's drop area and its aria-label. (optional) |
| toolbar | string | string[] | The toolbar for a `richtext` item: a preset (`basic`/`html`/`rich`) or an item-name array. (optional) |
| minHeight | number | The minimum editor height in pixels for a `richtext` item. (optional) |
| maxHeight | number | The maximum editor height in pixels for a `richtext` item. (optional) |
| linkTarget | string | The link target for links created in a `richtext` item. (optional) |
| colors | string[] | The colour swatches for a `richtext` item's text-colour popover. (optional) |
| headings | Array<[string, string]> | The `[tag, label]` heading options for a `richtext` item. (optional) |
| allowedTags | Record<string, string[]> | An override whitelist of tags/attributes for a `richtext` item. It can only narrow the default whitelist: event handlers, `srcdoc`, `id`/`name`, the forbidden tags and `javascript:`/`vbscript:`/`data:` URLs are always stripped. (optional) |
| maxImageSize | number | The maximum image size in bytes a `richtext` item inserts. (optional) |
| sanitizePaste | boolean | Sanitise pasted HTML in a `richtext` item (default true). (optional) |
| allowHalf | boolean | Allow half steps for a `rating` item. (optional) |
| allowClear | boolean | Allow a second click to clear a `rating` item back to zero. (optional) |
| icon | string | Element | false | The icon of a `button` - a name from the grid's icon set, an image URL or data: URI, or an element - the glyph name of a `rating` item (`star` or `heart`), or the drop-area icon of a `file` item - a glyph name from the file module's sprite set, an SVG markup string, an element, or `false` for none. (optional) |
| icons | { filled?: string; empty?: string } | The filled and empty glyph names for a `rating` item. (optional) |
| labels | string[] | One label per rating icon, for the `title` and the announced value. (optional) |
| showValue | boolean | Show the numeric value beside a `rating` item. (optional) |
| size | 'sm' | 'md' | 'lg' | The size of a `rating` item or `button`: `sm`, `md` (default) or `lg`. (optional) |
| iconPosition | 'start' | 'end' | Where a `button`'s icon sits: `start` (default) or `end`. (optional) |
| variant | FormButtonVariant 'primary' | 'secondary' | 'tonal' | 'outline' | 'text' | 'danger' | A `button`'s visual weight: theme-token colours, light and dark. Defaults to `secondary`. (optional) |
| fullWidth | boolean | Make a `button` span the full form width. (optional) |
| textTransform | 'none' | 'uppercase' | A `button`'s text case: `none` (default) or `uppercase`. (optional) |
| buttonType | 'button' | 'submit' | 'reset' | A `button`'s native type: `button`, `submit` (validates first, then fires `submit` with the values only when valid) or `reset` (clears the form). (optional) |
| loading | boolean | A `button`'s loading state: swaps its icon for a spinner, keeps the width, sets `aria-busy` and ignores clicks. Toggle live with `item.setLoading(bool)`. (optional) |
| href | string | A URL that turns a `button` into a link styled as a button. (optional) |
| ariaLabel | string | The accessible name of an icon-only `button` (icon with no label, required). (optional) |
| toggle | boolean | Make a `button` a two-state toggle: it flips `aria-pressed` and fires `change` with the new boolean state, and reads as pressed. Starts pressed when `value` is `true`. (optional) |
| focusableWhenDisabled | boolean | Keep a disabled `button` focusable and in the tab order: it reads `aria-disabled` instead of being natively disabled, and still ignores clicks. (optional) |
| align | FormButtonsAlign 'start' | 'end' | 'center' | 'stretch' | 'space-between' | How a `buttons` row lays its children out along the width: `start` (default), `end`, `center`, `stretch` or `space-between`. (optional) |
| items | FormItemConfig[] | The buttons of a `buttons` row; each child takes the same options as a `button` item. (optional) |
| stages | string[] | The step labels of a `stage` item, in order. (optional) |
| current | number | string | The initial selection of a `stage` item: an index or a label. (optional) |
| completed | number | string | The last done step of a `stage` item: an index or a label. (optional) |
| clickable | boolean | Whether clicking a `stage` step selects it (default true). (optional) |
| showIcons | boolean | Whether a `stage` item shows its check glyphs (default true). (optional) |
| allowEmpty | boolean | Allow a single-mode `chips` item to be left with nothing selected. (optional) |
| wrap | boolean | Wrap a `chips` item's pills onto several lines (default), or keep them on one scrolling row when false. (optional) |
| [key: string] | unknown | Extra per-control options, kept for later controls. |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| upload | (file: File, opts: { onProgress: (pct: number) => void; signal?: AbortSignal }) => void | Promise<void> | file: Fileopts: { onProgress: (pct: number) => void; signal?: AbortSignal } | => void | Promise<void> | The host upload callback for a `file` item: `(file, { onProgress, signal }) => void | Promise<void>`. (optional) |
| onImage | (file: File) => Promise<string> | string | file: File | => Promise<string> | string | A host callback returning a source URL for an inserted image. (optional) |
FormBlock
One block of a form layout: a container (`rows`/`cols`/`items`/`container`), a `fieldset`, a `spacer`, a `separator`, a panel section, or an item.
| Property | Type | Description |
|---|---|---|
| rows | FormBlock[] | A vertical stack of child blocks. (optional) |
| cols | FormBlock[] | A horizontal stack of child blocks. (optional) |
| items | FormItemConfig[] | A side-by-side run of form items. (optional) |
| spacing | number | string | The vertical gap between this container's or fieldset's own items - a px number or a CSS length, overriding the form's `spacing` for those items only. (optional) |
| type | string | The control type, or `spacer`/`separator`. Defaults to `input`. (optional) |
| fieldset | string | FormBlock | A fieldset: `'Legend'` or `{ legend, rows, cols, items }`. (optional) |
| container | FormBlock | An explicit container, `{ rows | cols | items }`. (optional) |
| panel | FormPanelConfig | A panel section: its own surface holding this block's `rows`/`cols`/`items` children, with a title, subtitle, divider and header actions. (optional) |
| spacer | boolean | A flexible spacer. (optional) |
| separator | boolean | A horizontal rule; with `label`, the rule is split around the centred label. (optional) |
| legend | string | The fieldset legend, when written as a sibling of `fieldset`. (optional) |
| name | string | The item's name. (optional) |
| label | string | The item's label text, or a `separator`'s centred label (a literal or a catalogue key). (optional) |
| labelPosition | FormLabelPosition 'top' | 'left' | 'hidden' | Where the label sits: `top` (default), `left`, or `hidden`. On a container or fieldset this is the default its items inherit. (optional) |
| labelWidth | number | string | The label column width when `labelPosition` is `left`; on a container or fieldset this is the default its items inherit. (optional) |
| width | number | string | The item's width. (optional) |
| placeholder | string | Placeholder text. (optional) |
| helpMessage | string | Help text shown under the control. (optional) |
| hidden | boolean | Start hidden. (optional) |
| disabled | boolean | Start disabled. (optional) |
| readOnly | boolean | Start read-only. (optional) |
| value | unknown | The initial value. (optional) |
| subtype | FormInputSubtype 'text' | 'email' | 'password' | 'url' | The native input subtype. (optional) |
| visibleWhen | FormRule | A rule that hides the item when it evaluates false. (optional) |
| readOnlyWhen | FormRule | A rule that makes the item read-only when it evaluates true. (optional) |
| requiredWhen | FormRule | A rule that makes the item required when it evaluates true. (optional) |
| css | Record<string, string | number> | string | Extra CSS on the block. (optional) |
| [key: string] | unknown | Extra options. |
FormBindGridConfig
A binding to a grid row: `{ grid, rowKey }`.
| Property | Type | Description |
|---|---|---|
| grid | Grid | The grid whose row provides the record. |
| rowKey | string | number | The row key of the bound row. |
FormBindRouterConfig
A binding to a data-router record: `{ router, route, key }`.
| Property | Type | Description |
|---|---|---|
| router | import('lattice-grid/modules/data-router').DataRouter | The data router whose route's keyed diffs carry the record. |
| route | string | The route (predicate) to subscribe to. |
| key | string | number | The row key of the bound record. |
FormConfig
The form config: the layout, the density, the binding, and the message catalogue.
| Property | Type | Description |
|---|---|---|
| rows | FormBlock[] | The top-level blocks, stacked vertically (the form reads top to bottom). (optional) |
| cols | FormBlock[] | The top-level blocks, stacked horizontally (used when `rows` is absent). (optional) |
| density | FormDensity 'compact' | 'standard' | 'comfortable' | The density level, `compact`/`standard`/`comfortable`. Default `standard`. (optional) |
| spacing | number | string | The vertical gap between items - a px number or a CSS length. Defaults to the density's own gap; a CSS theme may set `--lattice-form-item-gap` instead. (optional) |
| labelGap | number | string | The gap between an item's label and its field - a px number or a CSS length. Defaults to half the density's space; a CSS theme may set `--lattice-form-label-gap` instead. (optional) |
| labelPosition | FormLabelPosition 'top' | 'left' | 'hidden' | The default label position every item inherits: `top`, `left` or `hidden`. An item's own `labelPosition` wins. (optional) |
| labelWidth | number | string | The default label column width every item inherits when its label is `left`; an item's own `labelWidth` wins. (optional) |
| requiredHint | boolean | string | The default marker a required field with no visible label shows on its placeholder: `true` appends `" *"`, a string resolves through the message catalogue, `false` shows nothing. An item's own `requiredHint` wins. (optional) |
| colsBreakpoint | number | The width, in pixels, below which a `cols` container stacks its children into one column. Default 480. (optional) |
| bind | FormBind | Bind the form to a record. A grid row generates one item per column and saves through the grid's edit path; a routed record follows the route's keyed diffs; a plain object is initialised from and written back to. (optional) |
| css | Record<string, string | number> | string | Extra CSS on the form root. (optional) |
| steps | FormStep[] | Wizard steps: one labelled group of rows shown at a time. (optional) |
| stepOrientation | FormStepOrientation 'horizontal' | 'vertical' | The step indicator's orientation: `horizontal` (default) or `vertical`. (optional) |
| messages | { t(key: string, params?: object): string } | A `t(key, params)` catalogue for the separator's accessible label. (optional) |
ComboOptionObject
A combo option written as an object: `{ value, label, description, icon, avatar, color, dot, disabled }`,.
| Property | Type | Description |
|---|---|---|
| value | unknown | The option's value. (optional) |
| label | string | The display label. (optional) |
| description | string | The second line of a multi-line option. (optional) |
| avatar | string | An image URL (drawn round, falling back to the label's initials), or initials text (drawn round on a colour from `color` or a hash of the label). (optional) |
| icon | IconName | Node | A glyph name from the icon registry (see {@link IconName}), or a DOM node, drawn on the left when there is no avatar or dot; `color` tints it. (optional) |
| color | string | The background colour for the initials avatar (a stable colour derived from the label when omitted) and the tint of the option's icon. (optional) |
| dot | string | A colour that draws a small filled circle before the label instead of an icon or avatar, with no letter. (optional) |
| disabled | boolean | Whether the option is shown but not selectable. (optional) |
ComboLoadQuery
What a combo `load` is handed: the search, the page, and an abort signal.
| Property | Type | Description |
|---|---|---|
| search | string | The current search text (empty for the first page). |
| page | number | The zero-based page to fetch. |
| signal | AbortSignal | Abort this load when a newer search supersedes it. |
| pageSize | number | The page size handed to `load`. |
ComboConfig
The `type: 'combo'` item options, and the standalone `createCombo` options.
| Property | Type | Description |
|---|---|---|
| options | ComboOption[] | A static option list, used when `load` is absent. (optional) |
| multiple | boolean | Multi-select mode: the value is an array and choices become chips. (optional) |
| value | unknown | The initial value (an array when `multiple`). (optional) |
| newOptions | boolean | { create(text: string): unknown } | Free entry: `true`, or `{ create(text) }` to build the new option. (optional) |
| selectAll | boolean | Show a select-all action over the loaded set (multi-select). (optional) |
| debounce | number | The search debounce, in milliseconds. Default 250. (optional) |
| pageSize | number | The page size handed to `load`. Default 20. (optional) |
| maxHeight | number | The chip area max height, in pixels. (optional) |
| chipPlacement | 'inside' | 'above' | 'below' | Where multi-select chips sit: 'inside' (default), 'above' or 'below' the control. (optional) |
| dependsOn | string[] | Parent item names that clear and reload this combo when they change. (optional) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| load | (query: ComboLoadQuery) => ComboLoadResult | Promise<ComboLoadResult> | query: ComboLoadQuery | => ComboLoadResult | Promise<ComboLoadResult> | The paging loader. Absent when `options` is used. (optional) |
| resolveLabels | (values: unknown[]) => ComboOption[] | Promise<ComboOption[]> | values: unknown[] | => ComboOption[] | Promise<ComboOption[]> | Labels for selected values that sit outside the loaded pages. (optional) |
| optionTemplate | (option: ComboOption) => unknown | option: ComboOption | => unknown | A custom option renderer, returning a string or a DOM node. A string is treated as HTML and sanitised before it is inserted. (optional) |
| selectedTemplate | (option: ComboOption) => unknown | option: ComboOption | => unknown | A custom renderer for the selected option - the multi-select chips and the single-select shown value - returning a string or a DOM node; a string is treated as HTML and sanitised. (optional) |
FormEventPayloads
What a handler receives, per form event.
| Property | Type | Description |
|---|---|---|
| beforeChange | FormBeforeChangeEvent | The commit about to happen, with `preventDefault` to refuse it. |
| change | FormValueEvent | The edit that was committed. |
| input | FormValueEvent | The live, uncommitted value. |
| click | FormClickEvent | The button click. |
| focus | FormFocusEvent | The item that took focus. |
| blur | FormFocusEvent | The item that lost focus. |
| validated | FormValidatedEvent | A validation run, item or whole form, with its failures. |
| complete | FormCompleteEvent | Every box of an `otp` item is now filled. |
FormEvent
The fields every form event carries.
| Property | Type | Description |
|---|---|---|
| type | string | Which event this is: one of the `FormEventName` names. |
| name | string | null | The item's name, or `null` for an unnamed item. |
| item | FormItemConfig | The item's config. |
FormValueEvent
A value event (`change`/`input`): the item and the value involved.
| Property | Type | Description |
|---|---|---|
| value | unknown | The value being reported (or committed). |
| previousValue | unknown | The previously committed value, on a `change` event. (optional) |
FormBeforeChangeEvent
A `beforeChange` payload. A handler refuses the commit by calling `preventDefault(reason?)` or returning `false` (or a Promise of either).
| Property | Type | Description |
|---|---|---|
| origin | 'user' | 'api' | Who asked for the change: `user` for an edit, `api` for a programmatic commit. |
| defaultPrevented | boolean | True once any handler cancelled the commit. (read-only) |
| reason | string | null | The reason given to `preventDefault`, or `null`. (read-only) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| preventDefault | (reason?: string): void | reason?: string | void | Cancel the commit. |
FormClickEvent
A `click` event payload: a button holds no value.
| Property | Type | Description |
|---|---|---|
| value | null | Always `null` - a button holds no value. |
FormValidationFailure
One failed item in a `validated` event's `failures` list.
| Property | Type | Description |
|---|---|---|
| name | string | null | The item's name. |
| rule | string | The rule that failed: `required`, a built-in name, `pattern` or `custom`. |
| message | string | The failure message shown on the item. |
FormValidatedEvent
A `validated` payload: the outcome of `form.validate()` or `item.validate()`. `name`/`item` name the one item for an item run, or are `null` when the whole form was validated; `failures` lists every invalid item in document order.
| Property | Type | Description |
|---|---|---|
| value | unknown | The value validated (an item run), or `undefined` for a whole-form run. |
| valid | boolean | True when nothing failed. |
| failures | FormValidationFailure[] | Every item that failed, in document order. |
FormItem
The item handle `getItem(name)` returns.
| Property | Type | Description |
|---|---|---|
| el | HTMLElement | The item's wrapper element. (read-only) |
| name | string | null | The item's name, or `null` for an unnamed item. (read-only) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| getValue | (): unknown | - | unknown | The committed value. |
| setValue | (value: unknown): FormItem | value: unknown | FormItem | Write a committed value, silently (no event fires). |
| show | (): FormItem | - | FormItem | Show a hidden item. |
| hide | (): FormItem | - | FormItem | Hide the item. |
| isVisible | (): boolean | - | boolean | Whether the item is currently visible (not hidden). |
| disable | (): FormItem | - | FormItem | Disable the item's control. |
| enable | (): FormItem | - | FormItem | Enable a disabled item. |
| focus | (): FormItem | - | FormItem | Focus the item's control. |
| blur | (): FormItem | - | FormItem | Blur the item's control. |
| validate | (): boolean | - | boolean | Validate this one item, paint its verdict and report it through `validated`. |
Form
The form instance {@link createForm} returns.
Properties
| Property | Type | Description |
|---|---|---|
| el | HTMLElement | null | The host element. (read-only) |
| config | FormConfig | The normalised config. (read-only) |
| steps | FormStep[] | The wizard's normalised steps, or an empty array when the form is not a wizard. (read-only) |
| activeStep | number | The index of the active step (0-based), or 0 when the form is not a wizard. (read-only) |
Methods
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| getItem | (name: string): FormItem | undefined | name: string | FormItem | undefined | The item handle for a name, or `undefined` when no item has that name. |
| getValue | (): Record<string, unknown> | - | Record<string, unknown> | Snapshot every named, value-holding item as `{ name: value }`. |
| setValue | (values: Record<string, unknown>): Form | values: Record<string, unknown> | Form | Write values into the matching named items, silently. |
| clear | (): Form | - | Form | Reset every user-editable item to its empty value. |
| validate | (): boolean | - | boolean | Validate every rule-bearing item, paint each verdict and report all failures through `validated`. |
| forEach | (fn: (item: FormItem) => void): void | fn: (item: FormItem) => void | void | Walk every item in document order, handing each handle to `fn`. |
| on | (name: FormEventName | '*', fn: (event: FormEventPayloads[FormEventName]) => void): () => void | name: FormEventName | '*'fn: (event: FormEventPayloads[FormEventName]) => void | () => void | Subscribe to a form event or to `'*'` for every past-tense event; returns an unsubscribe. |
| off | (name: FormEventName | '*', fn: (event: FormEventPayloads[FormEventName]) => void): void | name: FormEventName | '*'fn: (event: FormEventPayloads[FormEventName]) => void | void | Remove a handler registered with {@link Form#on}. |
| setDensity | (density: FormDensity): Form | density: FormDensity | Form | Change the form's density live, re-theming control heights without a re-render. |
| setSpacing | (spacing: number | string | null): Form | spacing: number | string | null | Form | Change the vertical gap between items live - a px number or a CSS length - re-theming every stacked container with one property write and no re-render. `null` removes the override so the gap returns to the density default. |
| setReadOnly | (readOnly: boolean): Form | readOnly: boolean | Form | Set every item read-only (or back to editable) in one call. A read-only item stays focusable, selectable and validated. |
| setDisabled | (disabled: boolean): Form | disabled: boolean | Form | Set every item disabled (or back to enabled) in one call. A disabled item leaves the tab order and is excluded from validation. |
| isDirty | (name?: string): boolean | name?: string | boolean | Whether an item (or, with no name, the whole form) holds an unsaved user edit. |
| isConflicted | (name?: string): boolean | name?: string | boolean | Whether an item (or the whole form) has an unsaved edit an external change also touched. |
| save | (): boolean | Promise<boolean> | - | boolean | Promise<boolean> | Save the dirty items through the binding; true when saved (or nothing to save). |
| revert | (): Form | - | Form | Restore every dirty item to its bound/committed value and clear the dirty flags. |
| next | (): Promise<boolean> | - | Promise<boolean> | Advance one step, validating the current step's items first. |
| back | (): Promise<boolean> | - | Promise<boolean> | Go back one step - always allowed, never validated. |
| goToStep | (index: number): Promise<boolean> | index: number | Promise<boolean> | Move to a step by index, validating the steps passed over on the way forward. |
| destroy | (): void | - | void | Empty the element and drop every item record, listener and handler. |
Events
| Event | When | Payload | Cancellable |
|---|---|---|---|
| beforeChange | An edit is about to be committed; cancellable. | FormBeforeChangeEvent | yes |
| change | An edit was committed. | FormValueEvent | no |
| input | The user typed (the value is live, not yet committed). | FormValueEvent | no |
| click | A button was clicked. | FormClickEvent | no |
| focus | An item's control took focus. | FormFocusEvent | - |
| blur | An item's control lost focus. | FormFocusEvent | - |
| validated | One or more items were validated, with the failures. | FormValidatedEvent | no |
| complete | Every box of an `otp` item is now filled. | FormCompleteEvent | - |
Combo
The combo instance {@link createCombo} returns.
Properties
| Property | Type | Description |
|---|---|---|
| el | HTMLElement | The host element. (read-only) |
| root | HTMLElement | The combo root element. (read-only) |
| input | HTMLInputElement | The search input (the combobox). (read-only) |
Methods
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| getValue | (): unknown | - | unknown | The committed value: a single value (or null), or an array when multi. |
| setValue | (value: unknown): Combo | value: unknown | Combo | Write a committed value silently (no event fires). |
| reload | (): Combo | - | Combo | Reload page 0 for the current search, dropping the cache. |
| clear | (): Combo | - | Combo | Clear the selection, firing `change`. |
| open | (): Combo | - | Combo | Open the popup. |
| close | (): Combo | - | Combo | Close the popup. |
| focus | (): Combo | - | Combo | Focus the search input. |
| blur | (): Combo | - | Combo | Blur the search input. |
| setDisabled | (disabled: boolean): void | disabled: boolean | void | Enable or disable the control. |
| setReadOnly | (readOnly: boolean): void | readOnly: boolean | void | Mark the control read-only. |
| destroy | (): void | - | void | Tear the combo down: abort loads, clear timers, detach listeners. |
Events
No events.