# Form Sheet

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

```tsx
import * as React from "react"
import { toast } from "sonner"
import { Button } from "@zenoxai/ui/components/button"
import { Field, FieldError, FieldGroup, FieldLabel, FieldOptional } from "@zenoxai/ui/components/field"
import { Input } from "@zenoxai/ui/components/input"
import { Toaster } from "@zenoxai/ui/components/sonner"
import { Textarea } from "@zenoxai/ui/components/textarea"
import { ConfirmDialogHost } from "@zenoxai/ui/patterns/confirm-dialog"
import { FormSheet, useFormOverlay } from "@zenoxai/ui/patterns/form-sheet"

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

```tsx
import { FormSheet, useFormOverlay } from "@zenoxai/ui/patterns/form-sheet"
```

```tsx
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](/components/dialog#with-a-form).

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](/guidelines/messages): `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](/blocks/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.

```tsx
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](/components/locale-provider). The
  close button and the discard question fall back to the `sheet` and `confirm` groups.

## API reference

`import { ... } from "@zenoxai/ui/patterns/form-sheet"`

### FormSheet

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| description | `ReactNode` |  | One sentence under the title. |
| dirty (required) | `boolean` |  | Whether the form has changes the user would lose by closing. |
| error | `FormSheetError \| null` | `null` | The failure of the last request, or null. A new object takes focus once the request settles. |
| finalFocus | `boolean \| 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. |
| footerStart | `ReactNode` |  | Extra footer actions on the start side, such as "Save draft"; disabled while the request runs. |
| labels | `Partial<FormSheetLabels>` |  | Replaces texts, over the LocaleProvider's `formSheet` group. |
| modal | `boolean` |  | 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 (required) | `(open: boolean) => void` |  | Called with `false` once closing is fine: no changes, or the user chose Discard. |
| onSubmit (required) | `(event: FormEvent<HTMLFormElement>) => void` |  | Called on submit, by the button or Enter, after `preventDefault`. |
| open (required) | `boolean` |  | Whether the sheet is open. Controlled. |
| overlay (required) | `FormOverlay` |  | The request state, from useFormOverlay. |
| result | `FormSheetResult \| null` | `null` | A partial success, which replaces the form and its footer, or null. |
| side | `"left" \| "right"` | `right` | The edge the sheet slides in from. Defaults to right. |
| size | `"sm" \| "lg" \| "md"` | `md` | The sheet's width: `sm` 400px, `md` 560px (default), `lg` 800px. |
| submitLabel (required) | `string` |  | The submit button's text, a verb: "Create rule", "Save changes". |
| title (required) | `ReactNode` |  | The sheet's title: "Create detection rule", "Edit rule port-scan". |

### useFormOverlay

No props of its own.

