{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "openlmis-form-dialog",
  "title": "Form Dialog",
  "description": "Dialog for a form that stays inside the viewport and scrolls only its body, ignores clicks outside so nothing typed is lost, locks while a save runs, and shows save and load errors in place with a way to try again.",
  "dependencies": [
    "lucide-react@^1",
    "@base-ui/react@^1"
  ],
  "registryDependencies": [
    "alert",
    "button",
    "dialog",
    "field",
    "spinner",
    "https://registry.soldevelo.com/r/openlmis-form-fields.json"
  ],
  "files": [
    {
      "path": "registry/components/openlmis/form-dialog/form-dialog.tsx",
      "content": "\"use client\"\n\nimport { AlertCircleIcon } from \"lucide-react\"\nimport { useEffect, useRef } from \"react\"\nimport type { ReactNode } from \"react\"\n\nimport {\n  Alert,\n  AlertAction,\n  AlertDescription,\n  AlertTitle,\n} from \"@/components/ui/alert\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { Spinner } from \"@/components/ui/spinner\"\n\ntype FormDialogProps = {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  /** Runs once the close animation ends, e.g. to drop the content it was showing. */\n  onOpenChangeComplete?: (open: boolean) => void\n  closeButton?: boolean\n  children: ReactNode\n}\n\n/** A dialog sized for a form, that stays inside the viewport and scrolls its body when it has to. */\nexport function FormDialog({\n  open,\n  onOpenChange,\n  onOpenChangeComplete,\n  closeButton = true,\n  children,\n}: FormDialogProps) {\n  const popupRef = useRef<HTMLDivElement>(null)\n  const touchRef = useRef(false)\n\n  useEffect(() => {\n    const onPointerDown = (event: PointerEvent) => {\n      touchRef.current = event.pointerType === \"touch\"\n    }\n    const onKeyDown = () => {\n      touchRef.current = false\n    }\n    document.addEventListener(\"pointerdown\", onPointerDown, true)\n    document.addEventListener(\"keydown\", onKeyDown, true)\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown, true)\n      document.removeEventListener(\"keydown\", onKeyDown, true)\n    }\n  }, [])\n\n  return (\n    <Dialog\n      // A click outside would throw away everything typed, so only Cancel, Close and Escape close it.\n      disablePointerDismissal\n      onOpenChange={onOpenChange}\n      onOpenChangeComplete={onOpenChangeComplete}\n      open={open}\n    >\n      {/* Flex instead of grid, so the form can hand the leftover height to its body. */}\n      <DialogContent\n        // oxlint-disable-next-line shadcn/no-arbitrary-values -- no scale value keeps a margin inside the dynamic viewport\n        className=\"flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-lg\"\n        initialFocus={(interaction) => {\n          const popup = popupRef.current\n          if (interaction === \"touch\" || (!interaction && touchRef.current)) {\n            return popup\n          }\n          return firstField(popup) ?? popup\n        }}\n        ref={popupRef}\n        showCloseButton={closeButton}\n      >\n        {children}\n      </DialogContent>\n    </Dialog>\n  )\n}\n\n// Skips helpers marked `data-no-initial-focus`, such as a field's info button.\nfunction firstField(popup: HTMLElement | null) {\n  const body = popup?.querySelector(\"[data-slot='form-dialog-body']\")\n  return body?.querySelector<HTMLElement>(\n    \":is(input:not([type='hidden']), textarea, select, button, [tabindex]):not([disabled]):not([hidden]):not([tabindex='-1']):not([aria-disabled='true']):not([data-no-initial-focus])\"\n  )\n}\n\ntype FormDialogFormProps = {\n  onSubmit: () => void\n  children: ReactNode\n}\n\n/** Lays out the header, body and footer so only the body scrolls. */\nexport function FormDialogForm({ onSubmit, children }: FormDialogFormProps) {\n  return (\n    <form\n      className=\"flex min-h-0 flex-1 flex-col gap-4\"\n      noValidate\n      onSubmit={(event) => {\n        event.preventDefault()\n        event.stopPropagation()\n        onSubmit()\n      }}\n    >\n      {children}\n    </form>\n  )\n}\n\nexport function FormDialogHeader({ children }: { children: ReactNode }) {\n  // Room at the end for the close button, so a long title never runs under it.\n  return <DialogHeader className=\"gap-1 pe-8\">{children}</DialogHeader>\n}\n\nexport function FormDialogTitle({ children }: { children: ReactNode }) {\n  return <DialogTitle>{children}</DialogTitle>\n}\n\nexport function FormDialogDescription({ children }: { children: ReactNode }) {\n  return <DialogDescription>{children}</DialogDescription>\n}\n\n/** Scrolls on its own, so the header and footer stay in view on a short screen. */\nexport function FormDialogBody({ children }: { children: ReactNode }) {\n  // Bleeds to the dialog's edges, so focus rings are not clipped and the scrollbar sits at the edge.\n  return (\n    <div\n      className=\"-mx-4 min-h-0 flex-1 overflow-y-auto px-4 py-1\"\n      data-slot=\"form-dialog-body\"\n    >\n      {children}\n    </div>\n  )\n}\n\nexport function FormDialogFooter({ children }: { children: ReactNode }) {\n  return <DialogFooter>{children}</DialogFooter>\n}\n\ntype FormDialogCancelProps = {\n  disabled?: boolean\n  children?: ReactNode\n}\n\nexport function FormDialogCancel({\n  disabled = false,\n  children = \"Cancel\",\n}: FormDialogCancelProps) {\n  return (\n    <DialogClose\n      disabled={disabled}\n      render={<Button type=\"button\" variant=\"outline\" />}\n    >\n      {children}\n    </DialogClose>\n  )\n}\n\ntype FormDialogSubmitProps = {\n  pending?: boolean\n  disabled?: boolean\n  children: ReactNode\n}\n\nexport function FormDialogSubmit({\n  pending = false,\n  disabled = false,\n  children,\n}: FormDialogSubmitProps) {\n  return (\n    <Button\n      disabled={pending || disabled}\n      focusableWhenDisabled={pending}\n      type=\"submit\"\n    >\n      {pending && <Spinner data-icon=\"inline-start\" />}\n      {children}\n    </Button>\n  )\n}\n\ntype FormDialogErrorProps = {\n  title: ReactNode\n  description: ReactNode\n  /** A button beside the message, such as Try Again. */\n  action?: ReactNode\n}\n\n/** A failed save or load, shown inside the dialog so what was typed stays on screen. */\nexport function FormDialogError({\n  title,\n  description,\n  action,\n}: FormDialogErrorProps) {\n  return (\n    <Alert variant=\"destructive\">\n      <AlertCircleIcon />\n      <AlertTitle>{title}</AlertTitle>\n      <AlertDescription>{description}</AlertDescription>\n      {action && <AlertAction>{action}</AlertAction>}\n    </Alert>\n  )\n}\n\ntype FormDialogLoadErrorProps = {\n  /** The dialog's own title, so it still says what failed to open. */\n  title: ReactNode\n  errorTitle: ReactNode\n  errorDescription?: ReactNode\n  onRetry: () => void\n  retryLabel?: ReactNode\n  cancelLabel?: ReactNode\n}\n\n/** The whole dialog when what it edits could not be loaded: its title, the error, Try Again. */\nexport function FormDialogLoadError({\n  title,\n  errorTitle,\n  errorDescription = \"Check your connection and try again.\",\n  onRetry,\n  retryLabel = \"Try Again\",\n  cancelLabel,\n}: FormDialogLoadErrorProps) {\n  return (\n    <>\n      <FormDialogHeader>\n        <FormDialogTitle>{title}</FormDialogTitle>\n      </FormDialogHeader>\n      <FormDialogError\n        action={\n          <Button onClick={onRetry} size=\"sm\" type=\"button\" variant=\"outline\">\n            {retryLabel}\n          </Button>\n        }\n        description={errorDescription}\n        title={errorTitle}\n      />\n      <FormDialogFooter>\n        <FormDialogCancel>{cancelLabel}</FormDialogCancel>\n      </FormDialogFooter>\n    </>\n  )\n}",
      "type": "registry:component",
      "target": "components/openlmis/form-dialog/form-dialog.tsx"
    },
    {
      "path": "registry/components/openlmis/form-dialog/use-dialog-target.ts",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\n\n/** Open while `target` is set, showing the last target until the close animation ends; an object target must keep its identity. */\nexport function useDialogTarget<T>(target: T | undefined, onClose: () => void) {\n  const [shown, setShown] = useState(target)\n  if (target !== undefined && target !== shown) setShown(target)\n\n  return {\n    shown,\n    /** The `FormDialog` props; `locked` keeps it open, e.g. while a save runs. */\n    dialogProps: (locked = false) => ({\n      open: target !== undefined,\n      onOpenChange: (open: boolean) => {\n        if (!open && !locked) onClose()\n      },\n      onOpenChangeComplete: (open: boolean) => {\n        if (!open) setShown(undefined)\n      },\n    }),\n  }\n}",
      "type": "registry:hook",
      "target": "components/openlmis/form-dialog/use-dialog-target.ts"
    }
  ],
  "cssVars": {
    "light": {
      "destructive": "oklch(0.577 0.245 27.325)",
      "destructive-foreground": "oklch(0.985 0 0)",
      "success": "oklch(0.596 0.145 163.225)",
      "success-foreground": "oklch(0.985 0 0)",
      "warning": "oklch(0.666 0.179 58.318)",
      "warning-foreground": "oklch(0.985 0 0)",
      "info": "oklch(0.55 0.13 237)",
      "info-foreground": "oklch(0.985 0 0)"
    },
    "dark": {
      "destructive": "oklch(0.704 0.191 22.216)",
      "destructive-foreground": "oklch(0.21 0.04 25)",
      "success": "oklch(0.696 0.17 162.48)",
      "success-foreground": "oklch(0.21 0.04 163)",
      "warning": "oklch(0.769 0.188 70.08)",
      "warning-foreground": "oklch(0.22 0.04 60)",
      "info": "oklch(0.7 0.14 237)",
      "info-foreground": "oklch(0.2 0.04 240)"
    }
  },
  "meta": {
    "project": "openlmis",
    "height": "416px"
  },
  "docs": "Requires a `base-*` style in components.json (the `shadcn init` default). Legacy `new-york`/`radix-*` styles install Radix primitives, which reject the `render` prop this item uses.",
  "categories": [
    "forms",
    "overlay"
  ],
  "type": "registry:component"
}
