Skip to main content
@stralos-co-nz/ui ships a standardised set of components and hooks for form feedback. Use these primitives for new modal editors and calculator forms; a feature may add a specialised validator when its fields need domain-specific rules.

Components at a glance


useFormValidation

useFormValidation owns field registration, per-field blur validation, and full-form validation on save. It surfaces errors as a Record<string, string | null> that feeds directly into FormField’s error prop.

Composite controls

Controls whose focusable element has no meaningful .value property (for example, listbox triggers or date pickers) supply a getValue callback instead. Combobox and CurrencyInput expose or forward their actual text input, so attach the validation ref directly and let the hook read .value.

Save handler

Call validateAll from the save handler. On failure it focuses the first invalid field in document order and returns metadata for the summary toast.
validateAll only validates fields whose element is currently mounted. A conditionally hidden or unmounted field is excluded and cannot block save.

Server-side errors

Map server validation codes to field errors after a failed save:

API


FormGrid

FormGrid is the twelve-column grid that a dialog’s fields sit on. Use it whenever a form has fields of varying widths side-by-side. A FormField with no span fills the whole row, so a form that never sets spans reads exactly like a stack of fields. Use the span prop on FormField to create multi-column rows: "short" occupies 4 columns, "medium" 6, and "full" 12.
Raw children fill the row. Any direct child that does not state its own gridColumn — a helper <Text>, a LocationPicker, or a checkbox <Stack> — spans all twelve columns automatically. This rule is applied through Chakra’s recipes layer so any explicit gridColumn or FormField span still wins, regardless of insertion order. You do not need to add gridColumn="1 / -1" to non-FormField children placed inside a FormGrid.

FormField

FormField wraps Chakra v3’s Field. It wires htmlFor automatically, renders the required indicator, and shows either an inline error or a helper text — never both at once.
When error is set, FormField marks the control aria-invalid and renders the message in a polite live region so screen readers announce it on focus.

SubmitButton

SubmitButton prevents double-submit by keeping focus and refusing activation while isSaving or disabled is true. It shows a spinner while saving. Blocked is aria-disabled, not a real disabled. A real disabled takes the button out of the tab order at the moment it matters most — the person presses Save, the button disables itself, and the browser drops their focus to <body>. aria-disabled keeps focus where it was, reads as unavailable, and refuses the click with preventDefault. In tests, button.disabled is always false. Assert aria-disabled="true" instead; neither toBeDisabled() nor the disabled property sees it.
Pass form={formId} when the button sits outside its <form> element (the common case in a Dialog footer):
Use useId() per modal instance — a module-level constant would make a second mounted instance submit the first one’s form.

notify and notifyValidationErrors

notify is the only permitted way to raise outcome toasts. Every toast requires both a title (what happened) and a message (what it means or what to do next). A bare one-liner title is rejected at runtime.
Use notify.info when work has been accepted but not yet completed — for example, when a background calculation has started and the user should know the result is on its way. Use notify.success only when the operation is finished. Do not use notify.error for in-flight states.

Follow-up action on a toast

Pass an optional action to add one follow-up button to the toast. The toast dismisses itself after the handler runs, so the handler does not need to close it.
NotifyAction is exported from @stralos-co-nz/ui for typing the shape separately:
One action per toast. A choice between several things belongs in a ConfirmDialog, not on a toast.

Blank message at runtime

The message field is required by the type. At runtime, if the value passed is blank or empty (for example, message: err.reason where the server returned an empty reason), the toast raises with the title alone rather than throwing. A blank title still throws — it is written in code, not from server data. This matters when surfacing server errors:

notifyValidationErrors

notifyValidationErrors is the standard save-blocked toast. Pass the result of validateAll() directly:
It renders “1 field needs attention — the first one is highlighted.” or “N fields need attention — the first one is highlighted.” depending on invalidCount.

When not to use notify

Toasts are for outcome feedback only. Never use a toast to ask a question or seek confirmation. A toast raised while a Dialog is open sits in an aria-hidden subtree and outside the focus trap — use ConfirmDialog instead.

Dialog

Dialog is the standard accessible modal. It provides a focus trap, autofocus of the first focusable field, Escape-close, backdrop-close, and aria-labelledby wired from title. The body scrolls independently so the header and footer stay pinned — never place a primary action inside children. The preferred way to add a footer is the actions prop. Pass footer only when the standard layout does not fit.
actions renders a hairline, a Cancel button, and exactly one primary action. Cancel routes through the dirty-close guard automatically.

actions.primary

Use form for any dialog that contains a <form>. A primary wired only to onClick is a type="button" and bypasses every browser-level field constraint, so required fields and format checks are silently skipped.

Other actions fields

Props

Dirty-close guard

When confirmOnDirtyClose and isDirty are both true, the close button, Escape, and backdrop click raise a ConfirmDialog popup above the modal rather than closing immediately. The popup is centred and visible regardless of how far the user has scrolled. The backdrop guard covers the full lifetime of the dialog. Edits made after the dialog opened are guarded just as edits present at open time are — the backdrop handler always reads the current dirty state rather than the state captured at open time. The confirm uses emphasis="cancel" so Keep editing is the solid primary button and Discard changes is the ghost action. This ensures the button that loses work is not the most prominent thing on screen. Pass recordName to name the record in the confirm message. The message reads Your unsaved changes to "<recordName>" will be lost. When recordName is omitted, the message falls back to a generic form using the dialog title.

Dirty-close confirmation in remote contexts

When Dialog is rendered inside a RemoteProvider (a micro-frontend remote), the discard confirmation popup is rendered outside the remote’s foreign-layer gate. This means it is never inadvertently hidden by the gate while the parent dialog is waiting to be admitted — the popup remains accessible to assistive technology and focus is correctly trapped inside it regardless of the dialog’s remote admission state. No extra configuration is needed. The behaviour is automatic whenever confirmOnDirtyClose and isDirty are both true.

DialogCancelButton and useDialogClose

A footer Cancel button must route through the same dirty-close guard as the built-in affordances. Use DialogCancelButton as a drop-in:
For a custom control, call useDialogClose to get requestClose:
Calling onClose directly from a footer control bypasses the dirty-close guard — don’t do it. Every federated remote bundles its own copy of Chakra, so a page running several remotes holds multiple independent overlay machinery stacks that know nothing of each other. Stacking two Chakra dialog instances on top of each other causes focus-trap recursion, incorrect Escape targeting, and early scroll-lock release. Dialog applies the modal-stack rule automatically when it is rendered under a RemoteProvider. While any foreign layer — the shell’s or another remote’s — is open, the dialog waits. It opens as soon as the foreign layer closes. An already-open dialog is left alone if a foreign layer opens beneath it; only a new open request is deferred. No prop or template wiring is required. The shell’s own Provider is a pass-through: the rule does not apply there because the shell gates its own modals on the remote side. Ownership boundary. The rule reads overlay ownership from the DOM. A remote overlay must be portalled through this package’s Portal (or wrapped in a RemoteScope) so the data-stralos-remote attribute — the value of REMOTE_ROOT_ATTRIBUTE — travels with the portalled subtree. An overlay rendered through Chakra’s raw Portal carries no attribute; both halves of the rule misread it and neither defers correctly. Remote overlays that are not Dialog. If a remote renders a popover, menu, or other overlay outside of Dialog, apply the gate manually using the exported helpers:
useForeignLayerGate(open, remoteKey) returns true only while no foreign layer is open and the caller is requesting open. Pass remoteKey from useRemoteScope(); under the shell’s own Provider remoteKey is null and the gate is a pass-through. openForeignLayers(remoteKey) is an imperative helper that returns the list of currently open layers belonging to a different owner. Use it for one-off checks outside of React rendering.

ConfirmDialog

ConfirmDialog is a small centred popup for confirmations that interrupt a flow. It is a real nested modal: it takes its own focus trap and hides the dialog behind it from assistive technology while it is open. Escape and the backdrop resolve to onCancel. The Cancel action takes initial focus. A confirmation is a speed bump — the safe choice is the one under the user’s fingers.

Props

When confirmLoading is true, Cancel is also disabled and Escape and the backdrop are suppressed. A confirm already in flight cannot be called back, so an enabled Cancel would promise something it cannot do.

ConstructionSpinner

ConstructionSpinner is the standard progress indicator for operations that run long enough that a bare spinner reads as a hung page. It rotates site-flavoured copy to prove the request is still alive. AI extraction jobs are the primary driver — a crosstab workbook can take tens of seconds. The copy never implies progress toward a percentage the caller cannot actually measure. Phrases describe work being done to material, not how close the job is to finishing.
When stage is provided, it becomes the primary text line and the rotating phrase drops to a smaller subtitle. This keeps the meaningful status visible while the rotating copy proves liveness.

Props

Accessibility

The component renders a role="status" container with aria-live="polite". The live region sits on one element only — announcing every rotation from a region that also contains the stage would re-read the stage on each tick.

Inline phrases arrays

Pass a stable reference when overriding phrases. An inline array (phrases={["one", "two"]}) creates a new reference on every render. The effect keys on the phrase list’s length, not its identity, so a new reference does not restart the interval and freeze the copy on phrase one.

CONSTRUCTION_WORKING_PHRASES

The default phrase list is exported as CONSTRUCTION_WORKING_PHRASES for callers that want to extend or inspect it. It ships with at least 20 unique phrases. Import from @stralos-co-nz/ui.

Save-failure surface

When onSave throws (server rejects the payload), surface the error inline inside the modal rather than closing it. Keep the modal open so the user can correct the problem without re-entering their data. Use ModalError from ./estimateShared and render it unconditionally — pass message={saveError} and let it manage the empty state. ModalError is an ARIA live region (role="alert", aria-live="assertive"). A live region that is inserted together with its content is not reliably announced by screen readers; the region must already be in the DOM before the error arrives. Wrapping in {saveError && <ModalError ... />} restores the silent-failure bug.

Complete example