Skip to main content
196

Search Lumen

Find components, APIs, guides, and recipes.

GitHub
Web docs
Synthetic consumer fixtures

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

bash
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 react

Validation, 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.

tsx
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.

tsx
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.

Web docsFull catalog