Skip to content
UI

Form Sheet

beta

A form in a sheet with the behavior every such form needs, from Enter to submit to the discard question.

@zenoxai/ui/patterns/form-sheet Markdown
function FormSheetDemo() {
  const overlay = useFormOverlay()
  const [open, setOpen] = React.useState(false)
  const [hostname, setHostname] = React.useState("")
  const [owner, setOwner] = React.useState("")
  const [notes, setNotes] = React.useState("")
  const [error, setError] = React.useState<string | null>(null)
  const dirty = hostname !== "" || owner !== "" || notes !== ""

  async function register() {
    // Stands in for the request that registers the asset.
    await new Promise((resolve) => setTimeout(resolve, 800))
    setOpen(false)
    toast.success("Asset registered")
  }

  return (
    <>
      <Button onClick={() => setOpen(true)}>Register asset</Button>
      <FormSheet
        overlay={overlay}
        open={open}
        onOpenChange={setOpen}
        title="Register asset"
        description="The asset joins the inventory and the next scan."
        submitLabel="Register asset"
        dirty={dirty}
        onSubmit={() => {
          if (!hostname.trim()) {
            setError("Enter the asset's hostname.")
            document.getElementById("asset-hostname")?.focus()
            return
          }
          void overlay.run(register)
        }}
        onClosed={() => {
          setHostname("")
          setOwner("")
          setNotes("")
          setError(null)
        }}
      >
        <FieldGroup>
          <Field data-invalid={!!error}>
            <FieldLabel htmlFor="asset-hostname">Hostname</FieldLabel>
            <Input
              id="asset-hostname"
              name="hostname"
              required
              autoComplete="off"
              placeholder="srv-db-01.prod"
              value={hostname}
              aria-invalid={error ? true : undefined}
              aria-describedby={error ? "asset-hostname-error" : undefined}
              onChange={(event) => {
                setHostname(event.target.value)
                if (error && event.target.value.trim()) setError(null)
              }}
            />
            <FieldError
              id="asset-hostname-error"
              errors={error ? [{ message: error }] : []}
            />
          </Field>
          <Field>
            <FieldLabel htmlFor="asset-owner">Owner</FieldLabel>
            <Input
              id="asset-owner"
              name="owner"
              placeholder="Infrastructure team"
              value={owner}
              onChange={(event) => setOwner(event.target.value)}
            />
          </Field>
          <Field>
            <FieldLabel htmlFor="asset-notes">
              Notes <FieldOptional>Optional</FieldOptional>
            </FieldLabel>
            <Textarea
              id="asset-notes"
              name="notes"
              value={notes}
              onChange={(event) => setNotes(event.target.value)}
            />
          </Field>
        </FieldGroup>
      </FormSheet>
      {/* The app mounts these once, at its root. */}
      <Toaster />
      <ConfirmDialogHost />
    </>
  )
}

Usage

import { FormSheet, useFormOverlay } from "@zenoxai/ui/patterns/form-sheet"
const overlay = useFormOverlay()

<FormSheet
  overlay={overlay}
  open={open}
  onOpenChange={setOpen}
  title="Create detection rule"
  submitLabel="Create rule"
  dirty={form.formState.isDirty}
  error={failure}
  onFieldBlur={timing.onFieldBlur}
  onSubmit={() => form.handleSubmit((values) => overlay.run(() => create(values)))()}
  onClosed={() => form.reset()}
>
  {fields}
</FormSheet>

Use a form sheet for a form of more than five fields, and for builders, opened from a list (ADR 0018 in the repository). A form of up to five fields goes in a Dialog.

The sheet owns what every form in an overlay repeats, and the caller owns the form library:

  • A real form. The fields sit in a <form noValidate>, and the footer’s submit button is joined to it, so Enter in a field submits.
  • The field checks of the messages guideline: onFieldBlur gets the blur of every field, except one caused by pressing a button of the header or the footer. Submit checks everything, and an error appearing under the pointer would move the button.
  • One request at a time, through overlay.run(request). Focus moves to the submit button, which turns busy and stays focusable, and only then does the body turn inert. Cancel and every way to close wait for the request.
  • A failure in error shows at the top of the body and takes focus once the request has settled. The form keeps its values.
  • The discard question asks before closing over changes, from dirty, and a backdrop click does nothing while there are changes. Close after a success with your own setter.
  • The reset runs in onClosed, once the sheet has closed, so it never shows during the exit.

The record form block is the whole recipe with react-hook-form: the timing of the checks, a server check, dependent fields, editing that sends only the changed fields, and a partial success.

Examples

A partial success

When the record is saved but part of the request failed, pass result: it replaces the body and the footer with what was done, what failed and why, and actions of its own, such as a retry of the failed part. The discard question is off while it shows, since nothing is left to lose.

setResult({
  body: <Alert variant="warning">...</Alert>,
  actions: (
    <>
      <Button variant="outline" onClick={close}>Close</Button>
      <Button onClick={retry}>Try again</Button>
    </>
  ),
})

A side panel

modal={false} keeps the page behind usable, for a builder the user works beside. Pass finalFocus when the sheet opens from an element that goes away, such as a row menu’s item.

Accessibility

  • The sheet is a dialog named by title; description describes it.
  • The busy submit button keeps focus while the request runs, and the failure alert takes focus when it appears, so keyboard and screen reader users hear the outcome.
  • Texts come from the formSheet group of Locale Provider. The close button and the discard question fall back to the sheet and confirm groups.

API reference

Generated from the TypeScript source. className, style and children are accepted everywhere and omitted below.

FormSheet

FormSheet is a form in a Sheet, with the behavior every such form needs: a real `<form>`, so Enter submits; the field checks of ADR 0017, which skip a blur caused by pressing a footer button; one request at a time, with the busy submit button holding focus and the body inert; a failure alert that takes focus; the discard guard; and a reset once the sheet has closed.

PropTypeDefault
descriptionReactNode

One sentence under the title.

-
dirty*boolean

Whether the form has changes the user would lose by closing.

-
errorFormSheetError | null

The failure of the last request, or null. A new object takes focus once the request settles.

null
finalFocusboolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)

Where focus goes on close. Give it when the sheet opened from an element that goes away, such as a row menu's item.

-
footerStartReactNode

Extra footer actions on the start side, such as "Save draft"; disabled while the request runs.

-
labelsPartial<FormSheetLabels>

Replaces texts, over the LocaleProvider's `formSheet` group.

-
modalboolean

Whether the page behind is inert. Defaults to true; `false` makes a side panel the user works beside (ADR 0018).

-
onClosed(() => void)

Called once the sheet has closed, so a reset never shows during the exit.

-
onFieldBlur((event: FocusEvent<HTMLFormElement, Element>) => void)

Receives the blur of every field, except a blur caused by pressing a button of the header or the footer. Run the "leaving a changed field" check here (ADR 0017).

-
onOpenChange*(open: boolean) => void

Called with `false` once closing is fine: no changes, or the user chose Discard.

-
onSubmit*(event: FormEvent<HTMLFormElement>) => void

Called on submit, by the button or Enter, after `preventDefault`.

-
open*boolean

Whether the sheet is open. Controlled.

-
overlay*FormOverlay

The request state, from useFormOverlay.

-
resultFormSheetResult | null

A partial success, which replaces the form and its footer, or null.

null
side"left" | "right"

The edge the sheet slides in from. Defaults to right.

right
size"sm" | "lg" | "md"

The sheet's width: `sm` 400px, `md` 560px (default), `lg` 800px.

md
submitLabel*string

The submit button's text, a verb: "Create rule", "Save changes".

-
title*ReactNode

The sheet's title: "Create detection rule", "Edit rule port-scan".

-

useFormOverlay

useFormOverlay creates the request state of a form in a FormSheet: the handle the caller's submit code calls `run` on, and that the sheet reads to show the busy button and freeze the body. Create it next to the form, as with useDiscardGuard.

No props of its own. Accepts the props of the element it renders.