Forms, records and reviewed changes
These compositions use fictional data shaped like real consumer workflows. Product validation, requests and financial rules stay in application callbacks.
Loading the interactive recipes…
Install the React recipes
lumen add validated-form --target react
lumen add operational-records --target react
lumen add review-workflow --target react
lumen add import-review --target react
lumen add record-workspace --target reactValidation, submission and reset
Native validity and product errors share a summary with links to controls. Failed submissions retain input. The request guard blocks duplicate submit events; server idempotency remains application-owned. Return errors with controlId values matching the visible controls, including AmountField.
import type { ReactNode, SyntheticEvent } from 'react'
import { useLayoutEffect, useRef, useState } from 'react'
import { type LumenFormErrorInput, type LumenFormErrors, normalizeLumenFormErrors } from '@santi020k/lumen-core'
import { Alert, ErrorSummary } from '@santi020k/lumen-react'
export interface ValidatedFormRecipeProps {
children: (state: { errors: LumenFormErrors, pending: boolean }) => ReactNode
id: string
label: string
summaryHeading: string
failureMessage: string
successMessage: string
validate?: (form: HTMLFormElement) => LumenFormErrorInput
submit: (values: FormData) => Promise<LumenFormErrorInput | undefined>
onFailure?: (error: unknown) => void
onReset?: (event: SyntheticEvent<HTMLFormElement>) => void
}
const emptyErrors: LumenFormErrors = { fields: [], form: [] }
const nativeErrors = (form: HTMLFormElement): LumenFormErrors => {
const view = form.ownerDocument.defaultView
const fields: LumenFormErrors['fields'] = []
if (!view) return emptyErrors
for (const control of form.elements) {
const isControl = control instanceof view.HTMLInputElement ||
control instanceof view.HTMLSelectElement || control instanceof view.HTMLTextAreaElement
if (!isControl) continue
if (control.willValidate && !control.validity.valid) {
fields.push({ controlId: control.id, name: control.name, message: control.validationMessage })
}
}
return { fields, form: [] }
}
/** Product validation, network policy and idempotency remain in the supplied callbacks. */
export const ValidatedFormRecipe = ({
children, id, label, summaryHeading, failureMessage, successMessage, validate, submit, onFailure, onReset
}: ValidatedFormRecipeProps) => {
const formRef = useRef<HTMLFormElement>(null)
const activeRequestRef = useRef(false)
const attemptedRef = useRef(false)
const [errors, setErrors] = useState<LumenFormErrors>(emptyErrors)
const [pending, setPending] = useState(false)
const [succeeded, setSucceeded] = useState(false)
const [focusErrors, setFocusErrors] = useState(false)
const readErrors = (form: HTMLFormElement) => {
const native = nativeErrors(form)
const product = normalizeLumenFormErrors(validate?.(form))
return { fields: [...native.fields, ...product.fields], form: [...native.form, ...product.form] }
}
useLayoutEffect(() => {
const form = formRef.current
if (!form || !focusErrors) return
const targetId = errors.fields.find(item => item.controlId)?.controlId
const target = targetId ? form.ownerDocument.getElementById(targetId) : undefined
if (target instanceof HTMLElement && (form.contains(target) || ('form' in target && target.form === form))) target.focus()
else form.querySelector<HTMLElement>('[data-ui-error-summary]')?.focus()
setFocusErrors(false)
}, [errors, focusErrors])
const handleSubmit = async (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault()
if (activeRequestRef.current) return
const form = event.currentTarget
const next = readErrors(form)
attemptedRef.current = true
setErrors(next)
setSucceeded(false)
if (next.fields.length || next.form.length) {
setFocusErrors(true)
return
}
// Capture values before pending UI disables the controls.
const values = new FormData(form)
activeRequestRef.current = true
setPending(true)
try {
const result = normalizeLumenFormErrors(await submit(values))
setErrors(result)
if (result.fields.length || result.form.length) setFocusErrors(true)
else setSucceeded(true)
} catch (error) {
setErrors({ fields: [], form: [failureMessage] })
setFocusErrors(true)
onFailure?.(error)
} finally {
activeRequestRef.current = false
setPending(false)
}
}
return (
<form
ref={formRef}
id={id}
aria-label={label}
aria-busy={pending}
noValidate
onSubmit={event => {
void handleSubmit(event)
}}
onBlur={event => {
const next = event.relatedTarget
// Keep a focused action stationary between pointer down and click.
if (next instanceof HTMLElement && next.closest('button') && event.currentTarget.contains(next)) return
if (attemptedRef.current && !activeRequestRef.current) setErrors(readErrors(event.currentTarget))
}}
onChange={event => {
if (attemptedRef.current && !activeRequestRef.current) setErrors(readErrors(event.currentTarget))
}}
onReset={event => {
if (activeRequestRef.current) {
event.preventDefault()
return
}
onReset?.(event)
if (event.defaultPrevented) return
attemptedRef.current = false
setErrors(emptyErrors)
setSucceeded(false)
}}
>
<ErrorSummary id={`${id}-errors`} heading={summaryHeading} errors={errors} />
{children({ errors, pending })}
{succeeded && <Alert role="status">{successMessage}</Alert>}
</form>
)
}For Astro Actions, use the server error normalization recipe. For managed React fields, use React Hook Form and the optional LumenAmountFieldController. Do not register the amount's formatted visible value as the submitted decimal string.
Responsive records and focus
Stable IDs preserve row expansion. Manual sorting delegates row order to the server. Row menus use Lumen's anchored top layer; opening a dialog must preserve its focus and closing must return to the action trigger.
import { useState } from 'react'
import { Button, DataTable, type DataTableCell, type DataTableColumn, type DataTableRow, type DataTableSort, Dialog, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Stack } from '@santi020k/lumen-react'
export interface OperationalRecordsRecipeProps {
columns: DataTableColumn[]
rows: DataTableRow[]
sort: DataTableSort | null
onSortChange: (sort: DataTableSort | null) => void
label: string
actionsLabel: string
editLabel: string
closeLabel: string
detailsLabel: string
expandLabel: string
collapseLabel: string
}
const recordText = (cell: DataTableCell): string => typeof cell === 'object' && cell !== null ?
String(cell.label ?? cell.value ?? '') :
String(cell ?? '')
/** Manual sorting delegates requests and row order to the host. Stable IDs are required. */
export const OperationalRecordsRecipe = ({
columns, rows, sort, onSortChange, label, actionsLabel, editLabel, closeLabel,
detailsLabel, expandLabel, collapseLabel
}: OperationalRecordsRecipeProps) => {
const [active, setActive] = useState<DataTableRow | undefined>(undefined)
return (
<>
<Stack direction="horizontal" wrap gap="related">
{columns.filter(column => column.sortable).map(column => (
<Button
key={column.key}
variant="outline"
aria-pressed={sort?.key === column.key}
onClick={() => {
onSortChange({ key: column.key, direction: sort?.key === column.key && sort.direction === 'ascending' ? 'descending' : 'ascending' })
}}
>
{column.header}
</Button>
))}
</Stack>
<DataTable
role="region"
tabIndex={0}
aria-label={label}
layout="records"
rows={rows}
columns={[...columns.map(column => ({ ...column, sortable: false })), { key: 'actions',
header: actionsLabel,
render: (_cell, row) => (
<DropdownMenu>
<DropdownMenuTrigger aria-label={`${actionsLabel}: ${recordText(row.name ?? row.id)}`}>{actionsLabel}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => {
setActive(row)
}}
>
{editLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) }]}
sortMode="manual"
sort={sort}
onSortChange={onSortChange}
expandLabel={expandLabel}
collapseLabel={collapseLabel}
detailsLabel={detailsLabel}
renderDetails={row => <p>{recordText(row.detail)}</p>}
/>
<Dialog
open={active !== undefined}
onOpenChange={open => {
if (!open) setActive(undefined)
}}
aria-label={editLabel}
>
<p>{recordText(active?.name)}</p>
<Button onClick={() => {
setActive(undefined)
}}
>
{closeLabel}
</Button>
</Dialog>
</>
)
}Exact amount drafts
AmountField accepts ASCII decimal strings, formats the requested locale, retains trailing decimal points during editing, and submits only complete values. Unsupported paste and excess precision are rejected without rounding. Required and incomplete drafts participate in native validity. A host validator still enforces monetary units, limits, authorization and API validation.
Saved views and reviewed operations
DataTableSavedViews requests named preference changes through host callbacks. DataTableView supplies inclusive amount/date ranges and explicit selection of eligible records on the current page. Selected IDs stay separate from saved preferences. With server pagination, resolve and authorize every selected ID before a batch action.
The review-workflow recipe submits only a reviewed proposal for the current revision. Edits require another review, confirmed failures preserve input, and unknown outcomes require checking the original command. The import-review recipe exposes source findings and before/after values; record-workspace composes facts with an auditable event timeline. Parsing, matching, financial rules and persistence stay application-owned.
Reader-owned scrolling
MessageScroller is passive by default. Enable autoScroll to follow updates while at the end. Give stable message nodes data-ui-message-item so prepended history preserves the visible anchor. A public Button with data-ui-message-jump jumps to the latest event. The component never announces every streaming token; the application owns concise screen-reader status announcements.