{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "form-sheet",
  "type": "registry:component",
  "title": "Form sheet",
  "description": "Side sheet for data entry: title, scrolling fields, a footer with cancel and submit always in reach; full width on phones.",
  "registryDependencies": [
    "@wibo/utils",
    "@wibo/alert",
    "@wibo/button",
    "@wibo/sheet"
  ],
  "files": [
    {
      "path": "src/patterns/form-sheet.tsx",
      "type": "registry:component",
      "content": "import { createContext, useContext, useLayoutEffect, useRef, useState, useTransition, type ComponentProps, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Alert, AlertDescription } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from \"@/components/ui/sheet\";\n\n/*\n * Data entry opens over the page, never on it. A list or a detail page shows\n * data and offers the verb (a header button, a row menu item); the fields\n * open in this side sheet: the title and what the verb does, a body that\n * scrolls, and a footer that stays in reach with Cancel and the submit\n * button. Full width on phones. After a successful submit the form calls\n * `useFormSheet().close()`; errors stay inside the sheet, next to the fields.\n * Holds no copy: every word comes from the caller.\n *\n * It holds state and React context: render it from client components and\n * import it by path (`@wibo/ui/patterns/form-sheet`), not from the barrel.\n */\n\ninterface FormSheetControls {\n  /** Closes the sheet (after a successful submit, or from a custom cancel). */\n  close: () => void;\n}\n\nconst FormSheetContext = createContext<FormSheetControls | null>(null);\n\n/** The enclosing form sheet's controls; a no-op outside one, so a form also works on a page of its own. */\nfunction useFormSheet(): FormSheetControls {\n  return useContext(FormSheetContext) ?? { close: () => {} };\n}\n\ninterface FormSheetProps {\n  /** The element that opens the sheet (a `Button`), rendered as the sheet's trigger. Omit for a sheet opened by state. */\n  trigger?: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  title: ReactNode;\n  /** One or two lines saying what the verb does. */\n  description?: ReactNode;\n  /** `wide` for JSON editors and long forms (672 px instead of 448 px). */\n  size?: \"default\" | \"wide\";\n  /** Accessible name of the close button. */\n  closeLabel?: string;\n  className?: string;\n  /** A `FormSheetForm`, or `FormSheetBody` and `FormSheetFooter` for a sheet without a form. */\n  children: ReactNode;\n}\n\nfunction FormSheet({\n  trigger,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  title,\n  description,\n  size = \"default\",\n  closeLabel,\n  className,\n  children,\n}: FormSheetProps) {\n  // The content is portalled after mount, so opening on the first render does not affect hydration.\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  // Opened by state (a row menu, a card's button), the sheet has no trigger to return to: focus goes back to what opened it,\n  // read as the sheet opens, before its content mounts and a field's `autoFocus` takes the focus.\n  const returnFocus = useRef<HTMLElement | null>(null);\n  const open = openProp ?? uncontrolled;\n  useLayoutEffect(() => {\n    if (open) returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n  }, [open]);\n  const setOpen = (next: boolean) => {\n    if (openProp === undefined) setUncontrolled(next);\n    onOpenChange?.(next);\n  };\n  return (\n    <Sheet open={open} onOpenChange={setOpen}>\n      {trigger ? <SheetTrigger asChild>{trigger}</SheetTrigger> : null}\n      <SheetContent\n        data-slot=\"form-sheet\"\n        closeLabel={closeLabel}\n        className={cn(\"w-full gap-0\", size === \"wide\" ? \"sm:max-w-2xl\" : \"sm:max-w-md\", className)}\n        onCloseAutoFocus={(event) => {\n          if (trigger || !returnFocus.current?.isConnected) return;\n          event.preventDefault();\n          returnFocus.current.focus();\n        }}\n        {...(description ? {} : { \"aria-describedby\": undefined })}\n      >\n        <SheetHeader className=\"shrink-0 border-b border-border\">\n          <SheetTitle>{title}</SheetTitle>\n          {description ? <SheetDescription>{description}</SheetDescription> : null}\n        </SheetHeader>\n        <FormSheetContext.Provider value={{ close: () => setOpen(false) }}>{children}</FormSheetContext.Provider>\n      </SheetContent>\n    </Sheet>\n  );\n}\n\n/**\n * The sheet's form: fills the sheet under the header so the body scrolls and\n * the footer stays put. A function `action` (a server action, or the dispatch\n * of `useActionState`) runs in a transition from the submit event instead of\n * as a React form action, so what the person typed stays in the fields when\n * the action answers with an error (React resets a form after its action).\n * On success the sheet closes, which clears the form anyway.\n */\nfunction FormSheetForm({ className, action, onSubmit, ...props }: ComponentProps<\"form\">) {\n  const [, startTransition] = useTransition();\n  const shared = { \"data-slot\": \"form-sheet-form\", className: cn(\"flex min-h-0 flex-1 flex-col\", className), ...props };\n  if (typeof action !== \"function\") return <form action={action} onSubmit={onSubmit} {...shared} />;\n  return (\n    <form\n      {...shared}\n      onSubmit={(event) => {\n        onSubmit?.(event);\n        if (event.defaultPrevented) return;\n        event.preventDefault();\n        const data = new FormData(event.currentTarget);\n        startTransition(() => action(data));\n      }}\n    />\n  );\n}\n\n/** The fields, one column, scrolling inside the sheet. */\nfunction FormSheetBody({ className, ...props }: ComponentProps<\"div\">) {\n  return <div data-slot=\"form-sheet-body\" className={cn(\"grid min-h-0 flex-1 content-start gap-4 overflow-y-auto p-4\", className)} {...props} />;\n}\n\ninterface FormSheetFooterProps extends ComponentProps<\"div\"> {\n  /** Label of the button that closes the sheet without saving; `null` hides it (an answer view with one \"Done\"). */\n  cancelLabel?: string | null;\n}\n\n/** Cancel and the submit button (the children), on the sheet's bottom edge, always in reach. */\nfunction FormSheetFooter({ cancelLabel = \"Cancel\", className, children, ...props }: FormSheetFooterProps) {\n  return (\n    <div\n      data-slot=\"form-sheet-footer\"\n      className={cn(\"flex shrink-0 items-center justify-end gap-2 border-t border-border p-4 pb-[max(1rem,env(safe-area-inset-bottom))]\", className)}\n      {...props}\n    >\n      {cancelLabel === null ? null : (\n        <SheetClose asChild>\n          <Button type=\"button\" variant=\"outline\">\n            {cancelLabel}\n          </Button>\n        </SheetClose>\n      )}\n      {children}\n    </div>\n  );\n}\n\n/** The form's own error, under the fields and above the footer; renders nothing without children. Field errors go in `FieldError`. */\nfunction FormSheetError({ className, children }: { className?: string; children?: ReactNode }) {\n  if (children === undefined || children === null || children === false || children === \"\") return null;\n  return (\n    <Alert variant=\"danger\" data-slot=\"form-sheet-error\" className={className}>\n      <AlertDescription>{children}</AlertDescription>\n    </Alert>\n  );\n}\n\nexport { FormSheet, FormSheetBody, FormSheetError, FormSheetFooter, FormSheetForm, useFormSheet };\nexport type { FormSheetControls, FormSheetFooterProps, FormSheetProps };\n"
    }
  ]
}
