{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dialog",
  "type": "registry:component",
  "title": "Dialog",
  "description": "Focuses attention on a modal task. Title, body, and two equal actions.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/button.json",
    "https://vlak.dev/r/vlak-lib.json",
    "https://vlak.dev/r/icons.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/dialog.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs, type Leaves } from \"./rs\";\nimport { cx } from \"./cx\";\nimport { setRef } from \"./merge-refs\";\nimport { Icon } from \"./icons\";\n\nexport interface NativeDialogOptions {\n  open: boolean;\n  onClose?: () => void;\n  /**\n   * Escape (and any other platform close request) asks the parent to\n   * close. Off, the dialog must be answered; maps to closedby=\"none\".\n   */\n  dismissable?: boolean;\n  /** A click on the backdrop closes too; maps to closedby=\"any\". */\n  lightDismiss?: boolean;\n  /** Renders a labelled close button in the top corner. */\n  closeLabel?: string;\n}\n\nexport interface DialogProps\n  extends Omit<React.DialogHTMLAttributes<HTMLDialogElement>, \"open\" | \"onClose\">,\n    NativeDialogOptions {\n  /** Extra StyleX leaves merged after the dialog frame (command palette). */\n  extraStyles?: Leaves;\n}\n\nexport const dialogStyles = stylex.create({\n  frame: {\n    boxSizing: \"border-box\",\n    maxWidth: {\n      default: \"20rem\",\n      [mq.phone]: \"calc(100vw - 32px)\",\n    },\n    width: {\n      default: null,\n      [mq.phone]: \"calc(100vw - 32px)\",\n    },\n    borderWidth: vlak.hairline,\n    borderStyle: \"solid\",\n    borderColor: vlak.divider,\n    borderRadius: vlak.radiusSm,\n    padding: {\n      default: \"1.25rem\",\n      [mq.phone]: \"24px 20px\",\n    },\n    backgroundColor: vlak.paper,\n    color: vlak.ink,\n    boxShadow: \"none\",\n    \"::backdrop\": {\n      // The page fades toward its own ground: paper in light, near-black in dark.\n      backgroundColor: [\"rgba(0,0,0,0.25)\", `color-mix(in srgb, ${vlak.paper} 55%, transparent)`],\n    },\n  },\n  title: {\n    display: \"block\",\n    fontSize: {\n      default: \"0.9375rem\",\n      [mq.phone]: \"1.125rem\",\n    },\n    fontWeight: 600,\n    letterSpacing: \"-0.01em\",\n    color: vlak.ink,\n    marginTop: 0,\n    marginInlineEnd: 0,\n    marginBottom: {\n      default: \"0.375rem\",\n      [mq.phone]: \"0.625rem\",\n    },\n    marginInlineStart: 0,\n  },\n  body: {\n    fontSize: {\n      default: \"0.84375rem\",\n      [mq.phone]: \"1rem\",\n    },\n    color: vlak.gray,\n    letterSpacing: \"-0.01em\",\n    lineHeight: 1.45,\n    marginTop: 0,\n    marginInlineEnd: 0,\n    marginBottom: {\n      default: \"1rem\",\n      [mq.phone]: \"1.25rem\",\n    },\n    marginInlineStart: 0,\n  },\n  actions: {\n    display: \"flex\",\n    justifyContent: \"flex-end\",\n    flexDirection: {\n      default: \"row\",\n      [mq.phone]: \"column\",\n    },\n    alignItems: {\n      default: \"flex-end\",\n      [mq.phone]: \"stretch\",\n    },\n    gap: {\n      default: \"0.5rem\",\n      [mq.phone]: \"0.625rem\",\n    },\n  },\n  /** Corner close button. Floats so the title wraps around it. */\n  close: {\n    transition: {\n      default: vlak.transition,\n      [mq.reduce]: \"none\",\n    },\n    float: \"inline-end\",\n    boxSizing: \"border-box\",\n    display: \"inline-flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    width: {\n      default: vlak.hit,\n      [mq.phone]: vlak.hit,\n    },\n    height: {\n      default: vlak.hit,\n      [mq.phone]: vlak.hit,\n    },\n    marginTop: {\n      default: \"-0.5rem\",\n      [mq.phone]: \"-0.75rem\",\n    },\n    marginInlineEnd: {\n      default: \"-0.5rem\",\n      [mq.phone]: \"-0.75rem\",\n    },\n    marginInlineStart: \"0.5rem\",\n    padding: 0,\n    borderWidth: 0,\n    borderRadius: vlak.radiusSm,\n    backgroundColor: {\n      default: \"transparent\",\n      \":hover\": { default: null, [mq.hover]: vlak.controlFill },\n    },\n    color: vlak.ink,\n    cursor: \"pointer\",\n    outlineWidth: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n    outlineStyle: {\n      default: null,\n      \":focus-visible\": \"solid\",\n    },\n    outlineColor: {\n      default: null,\n      \":focus-visible\": vlak.ink,\n    },\n    outlineOffset: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n  },\n});\n\n/* ── Shared native <dialog> behaviour: naming context, close requests, focus ── */\n\ntype DialogPart = \"title\" | \"body\";\n\ninterface DialogContextValue {\n  titleId: string;\n  bodyId: string;\n  register: (part: DialogPart, id: string) => () => void;\n}\n\nexport const DialogContext = React.createContext<DialogContextValue | null>(null);\n\n/**\n * Title and body parts call this: it hands back the id the dialog\n * points aria-labelledby / aria-describedby at, and registers the\n * part so the attribute only exists while the part is rendered.\n */\nexport function useDialogPart(part: DialogPart, explicitId?: string): string | undefined {\n  const ctx = React.useContext(DialogContext);\n  const id = explicitId ?? (part === \"title\" ? ctx?.titleId : ctx?.bodyId);\n  React.useEffect(() => {\n    if (!ctx || !id) return;\n    return ctx.register(part, id);\n  }, [ctx, part, id]);\n  return id;\n}\n\nconst FOCUSABLE =\n  'a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\ninterface NativeDialogProps {\n  onCancel?: React.ReactEventHandler<HTMLDialogElement>;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  \"aria-describedby\"?: string;\n}\n\n/**\n * Drives a native <dialog> from an `open` prop. Escape and light dismiss\n * become an `onClose` call; the parent's `open` stays the source of\n * truth. Returns the ref, the attributes for the element, and the\n * context value the title/body parts register into.\n */\nexport function useNativeDialog(\n  { open, onClose, dismissable = true, lightDismiss = false }: NativeDialogOptions,\n  props: NativeDialogProps,\n  forwardedRef?: React.ForwardedRef<HTMLDialogElement>,\n) {\n  const ref = React.useRef<HTMLDialogElement | null>(null);\n  const openRef = React.useRef(open);\n  const restoreTo = React.useRef<Element | null>(null);\n  const [parts, setParts] = React.useState<{ title?: string; body?: string }>({});\n  const base = React.useId();\n\n  const setDialogRef = React.useCallback(\n    (node: HTMLDialogElement | null) => {\n      ref.current = node;\n      setRef(forwardedRef, node);\n    },\n    [forwardedRef],\n  );\n\n  const register = React.useCallback((part: DialogPart, id: string) => {\n    setParts((current) => (current[part] === id ? current : { ...current, [part]: id }));\n    return () => {\n      setParts((current) => (current[part] === id ? { ...current, [part]: undefined } : current));\n    };\n  }, []);\n\n  const context = React.useMemo<DialogContextValue>(\n    () => ({ titleId: `${base}-title`, bodyId: `${base}-body`, register }),\n    [base, register],\n  );\n\n  const restoreFocus = React.useCallback((dialog: HTMLDialogElement) => {\n    const previous = restoreTo.current;\n    restoreTo.current = null;\n    const active = document.activeElement;\n    if (previous instanceof HTMLElement && previous.isConnected && (active === document.body || dialog.contains(active))) {\n      previous.focus();\n    }\n  }, []);\n\n  React.useEffect(() => {\n    openRef.current = open;\n    const dialog = ref.current;\n    if (!dialog) return;\n    if (open) {\n      if (dialog.open) return;\n      restoreTo.current = document.activeElement;\n      if (typeof dialog.showModal === \"function\") dialog.showModal();\n      else dialog.open = true;\n      if (!dialog.contains(document.activeElement)) {\n        const target =\n          dialog.querySelector<HTMLElement>(\"[autofocus]\") ?? dialog.querySelector<HTMLElement>(FOCUSABLE) ?? dialog;\n        target.focus();\n      }\n      return;\n    }\n    if (dialog.open) dialog.close();\n    restoreFocus(dialog);\n  }, [open, restoreFocus]);\n\n  React.useEffect(() => {\n    // Capture the node now: React clears the forwarded ref before unmount cleanup.\n    const dialog = ref.current;\n    return () => {\n      openRef.current = false;\n      if (!dialog) return;\n      if (dialog.open) {\n        if (typeof dialog.close === \"function\") dialog.close();\n        else dialog.open = false;\n      }\n      restoreFocus(dialog);\n    };\n  }, [restoreFocus]);\n\n  const closedBy = !dismissable ? \"none\" : lightDismiss ? \"any\" : \"closerequest\";\n\n  const onCancel = (e: React.SyntheticEvent<HTMLDialogElement>) => {\n    props.onCancel?.(e);\n    if (e.defaultPrevented) return;\n    // The platform never closes on its own: the parent flips `open`.\n    e.preventDefault();\n    if (dismissable) onClose?.();\n  };\n\n  const onNativeClose = () => {\n    // Closed by the platform (a forced close request, a method=\"dialog\"\n    // form) while the parent still says open: tell it.\n    // A close event is queued by the browser. A newer open (including Strict\n    // Mode's effect replay) must not be dismissed by the previous close event.\n    if (openRef.current && !ref.current?.open) onClose?.();\n  };\n\n  const labelledBy = props[\"aria-labelledby\"] ?? (props[\"aria-label\"] ? undefined : parts.title);\n  const describedBy = cx(props[\"aria-describedby\"], parts.body) || undefined;\n\n  return {\n    ref: setDialogRef,\n    context,\n    dialogProps: {\n      onCancel,\n      onClose: onNativeClose,\n      \"aria-labelledby\": labelledBy,\n      \"aria-describedby\": describedBy,\n      ...({ closedby: closedBy } as object),\n    },\n  };\n}\n\nexport interface DialogCloseButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  /** The accessible name. */\n  label: string;\n}\n\n/**\n * Corner close: a labelled button with the close mark. The caller\n * paints it with its own class over dialogStyles.close.\n */\nexport function DialogCloseButton({ label, ...props }: DialogCloseButtonProps) {\n  return (\n    <button type=\"button\" aria-label={label} {...props}>\n      <Icon name=\"close\" />\n    </button>\n  );\n}\n\n/**\n * A native <dialog>. Focus trapping, Escape, and the backdrop come\n * from the platform; the title names it and the body describes it.\n */\nexport const Dialog = React.forwardRef<HTMLDialogElement, DialogProps>(function Dialog(\n  { open, onClose, dismissable, lightDismiss, closeLabel, className, style, children, extraStyles, ...props },\n  forwardedRef,\n) {\n  const { ref, context, dialogProps } = useNativeDialog({ open, onClose, dismissable, lightDismiss }, props, forwardedRef);\n  const sx = rs([\"rs-dialog\", className], dialogStyles.frame, ...((extraStyles ?? []) as Leaves));\n  const close = rs([\"rs-dialog-close\"], dialogStyles.close);\n  return (\n    <DialogContext.Provider value={context}>\n      <dialog ref={ref} {...props} {...dialogProps} className={sx.className} style={{ ...sx.style, ...style }}>\n        {closeLabel != null && (\n          <DialogCloseButton label={closeLabel} className={close.className} style={close.style} onClick={() => onClose?.()} />\n        )}\n        {children}\n      </dialog>\n    </DialogContext.Provider>\n  );\n});\n\nexport interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {\n  /** Heading level. The dialog is named by this element either way. */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\" | \"p\" | \"span\" | \"div\";\n}\n\nexport const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(function DialogTitle(\n  { as: Tag = \"h2\", className, style, id, ...props },\n  ref,\n) {\n  const titleId = useDialogPart(\"title\", id);\n  const sx = rs([\"rs-dialog-title\", className], dialogStyles.title);\n  return <Tag ref={ref as React.Ref<never>} {...props} id={titleId} className={sx.className} style={{ ...sx.style, ...style }} />;\n});\n\nexport const DialogBody = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(function DialogBody(\n  { className, style, id, ...props },\n  ref,\n) {\n  const bodyId = useDialogPart(\"body\", id);\n  const sx = rs([\"rs-dialog-body\", className], dialogStyles.body);\n  return <p ref={ref} {...props} id={bodyId} className={sx.className} style={{ ...sx.style, ...style }} />;\n});\n\nexport const DialogActions = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(function DialogActions(\n  { className, style, ...props },\n  ref,\n) {\n  const sx = rs([\"rs-dialog-actions\", className], dialogStyles.actions);\n  return <div ref={ref} {...props} className={sx.className} style={{ ...sx.style, ...style }} />;\n});\n",
      "type": "registry:component",
      "target": "components/vlak/dialog.tsx"
    },
    {
      "path": "vlak/styles/dialog.css",
      "content": "/* ── dialog: generated from packages/react/src/components/dialog.tsx ── */\n.rs-dialog{box-sizing:border-box;max-width:20rem;border-width:1px;border-style:solid;border-color:var(--divider);border-radius:var(--radius-sm);padding:1.25rem;background-color:var(--bg);color:var(--text);box-shadow:none}\n.rs-dialog::backdrop{background-color:rgba(0,0,0,0.25);background-color:color-mix(in srgb, var(--bg) 55%, transparent)}\n@media (max-width: 640px){.rs-dialog{max-width:calc(100vw - 32px);width:calc(100vw - 32px);padding:24px 20px}}\n.rs-dialog-title{display:block;font-size:0.9375rem;font-weight:600;letter-spacing:-0.01em;color:var(--text);margin-top:0;margin-inline-end:0;margin-bottom:0.375rem;margin-inline-start:0}\n@media (max-width: 640px){.rs-dialog-title{font-size:1.125rem;margin-bottom:0.625rem}}\n.rs-dialog-body{font-size:0.84375rem;color:var(--text-secondary);letter-spacing:-0.01em;line-height:1.45;margin-top:0;margin-inline-end:0;margin-bottom:1rem;margin-inline-start:0}\n@media (max-width: 640px){.rs-dialog-body{font-size:1rem;margin-bottom:1.25rem}}\n.rs-dialog-actions{display:flex;justify-content:flex-end;flex-direction:row;align-items:flex-end;gap:0.5rem}\n@media (max-width: 640px){.rs-dialog-actions{flex-direction:column;align-items:stretch;gap:0.625rem}}\n.rs-dialog-close,.rs-drawer-close,.rs-sheet-close{transition:var(--transition);float:inline-end;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;width:var(--hit);height:var(--hit);margin-top:-0.5rem;margin-inline-end:-0.5rem;margin-inline-start:0.5rem;padding:0;border-width:0;border-radius:var(--radius-sm);background-color:transparent;color:var(--text);cursor:pointer}\n.rs-dialog-close:focus-visible,.rs-drawer-close:focus-visible,.rs-sheet-close:focus-visible{outline-width:2px;outline-style:solid;outline-color:var(--text);outline-offset:2px}\n@media (prefers-reduced-motion: reduce){.rs-dialog-close,.rs-drawer-close,.rs-sheet-close{transition:none}}\n@media (max-width: 640px){.rs-dialog-close,.rs-drawer-close,.rs-sheet-close{width:var(--hit);height:var(--hit);margin-top:-0.75rem;margin-inline-end:-0.75rem}}\n@media (hover: hover) and (pointer: fine){.rs-dialog-close:hover,.rs-drawer-close:hover,.rs-sheet-close:hover{background-color:var(--control-fill)}}\n",
      "type": "registry:file",
      "target": "styles/vlak/dialog.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "surfaces",
      "classes": [
        "rs-dialog",
        "rs-dialog-title",
        "rs-dialog-body",
        "rs-dialog-actions",
        "rs-dialog-close"
      ],
      "snippet": "<div class=\"rs-dialog\" role=\"dialog\" aria-labelledby=\"remove-title\" aria-describedby=\"remove-body\"><h2 class=\"rs-dialog-title\" id=\"remove-title\">Remove this item?</h2><p class=\"rs-dialog-body\" id=\"remove-body\">This can't be undone.</p><div class=\"rs-dialog-actions\"><button class=\"rs-btn-ghost rs-btn-sm\">Cancel</button><button class=\"rs-btn-primary rs-btn-sm\">Remove</button></div></div>",
      "cssOnly": false,
      "registryDependencies": [
        "button",
        "vlak-lib",
        "icons"
      ],
      "aliases": [
        "Dialog",
        "Modal",
        "Modal dialog"
      ],
      "example": "import { useState } from \"react\";\nimport { Button, Dialog, DialogActions, DialogBody, DialogTitle } from \"@noorddev/vlak-react\";\n\nconst [open, setOpen] = useState(false);\n\n<Button variant=\"ghost\" onClick={() => setOpen(true)}>Remove</Button>\n<Dialog open={open} onClose={() => setOpen(false)} closeLabel=\"Close\">\n  <DialogTitle>Remove this item?</DialogTitle>\n  <DialogBody>This can't be undone.</DialogBody>\n  <DialogActions>\n    <Button variant=\"ghost\" size=\"sm\" onClick={() => setOpen(false)}>Cancel</Button>\n    <Button size=\"sm\" onClick={remove}>Remove</Button>\n  </DialogActions>\n</Dialog>",
      "usage": {
        "use": [
          "A short decision or a form that must finish before the page continues.",
          "One title, one sentence of body, two equal actions."
        ],
        "avoid": [
          "Content the user should keep the page in view for; use Sheet or Popover.",
          "Destructive confirmations that must not dismiss on Escape; use AlertDialog."
        ]
      },
      "keyboard": [
        {
          "keys": "Escape",
          "does": "Calls onClose (when dismissable, the default)"
        },
        {
          "keys": "Tab, Shift + Tab",
          "does": "Cycles focus inside the open dialog"
        },
        {
          "keys": "Enter, Space",
          "does": "Activates the focused action"
        }
      ],
      "a11y": [
        "A native <dialog> opened with showModal(): the platform traps focus, provides the backdrop, and handles Escape.",
        "DialogTitle (an h2 by default) names it through aria-labelledby; DialogBody describes it through aria-describedby, only while mounted.",
        "On open, focus moves to [autofocus] or the first focusable element; on close it returns to the element that opened it.",
        "dismissable={false} maps to closedby=\"none\"; lightDismiss adds a click on the backdrop. closeLabel renders a labelled close button. The ref is forwarded to the <dialog>."
      ]
    }
  }
}
