{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dialog","type":"registry:ui","title":"Dialog","description":"A focused surface for decisions that need attention.","categories":["disclosure"],"dependencies":["@radix-ui/react-dialog","lucide-react","motion"],"registryDependencies":["https://ui.teamofsilicons.com/r/silicon-motion-tokens.json"],"files":[{"path":"registry/components/dialog/dialog.tsx","type":"registry:component","target":"@components/silicon-ui/dialog/dialog.tsx","content":"\"use client\";\n\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { createContext, useCallback, useContext, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { ESCAPE_LAYER_ATTRIBUTE, layerEscape } from \"../lib/escape\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { useReturnFocus } from \"../lib/return-focus\";\nimport styles from \"./dialog.module.css\";\n\n/** Mirrors the open state so the content can stay mounted while it animates out, and retarget mid-flight if it is reopened or closed early. */\nconst OpenContext = createContext<boolean | null>(null);\n\nexport function Dialog({ open: openProp, defaultOpen = false, onOpenChange, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Root>) {\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  const open = openProp ?? uncontrolled;\n  const setOpen = useCallback((next: boolean) => { if (openProp === undefined) setUncontrolled(next); onOpenChange?.(next); }, [openProp, onOpenChange]);\n  return <OpenContext.Provider value={open}><DialogPrimitive.Root {...props} open={open} onOpenChange={setOpen}/></OpenContext.Provider>;\n}\n\nexport const DialogTrigger = DialogPrimitive.Trigger;\nexport const DialogClose = DialogPrimitive.Close;\n\nexport interface DialogContentProps extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {\n  title: string;\n  /** Plain text swaps in with a rise when it changes; markup (a bold id) is shown as given. */\n  description?: ReactNode;\n  /** Keep the title and optional description available to assistive technology without drawing a header or close button. */\n  hideHeader?: boolean;\n  /** Place the dialog near the top of the viewport; centered by default. */\n  placement?: \"center\" | \"top\";\n  children: ReactNode;\n}\n\nconst fade: Transition = { duration: motionTokens.duration.instant };\nconst leave: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\n\n/** When the title or description changes while open, the new copy rises in and the old copy leaves upward. */\nfunction SwapText({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  return <AnimatePresence mode=\"popLayout\" initial={false}>\n    <motion.span key={text} className={styles.swap} initial={reduced ? false : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{text}</motion.span>\n  </AnimatePresence>;\n}\n\nexport function DialogContent({ title, description, hideHeader = false, placement = \"center\", children, className, onPointerDownOutside, onEscapeKeyDown, onOpenAutoFocus, onCloseAutoFocus, ...props }: DialogContentProps) {\n  const open = useContext(OpenContext);\n  // Closing puts focus back on what opened the dialog, Trigger or not (lib/return-focus.ts).\n  const returnFocus = useReturnFocus(open, onOpenAutoFocus, onCloseAutoFocus);\n  // Escape inside belongs to an open list, calendar or question first (lib/escape.ts); the next one closes the dialog.\n  const escape = { [ESCAPE_LAYER_ATTRIBUTE]: \"\", onEscapeKeyDown: layerEscape(onEscapeKeyDown), ...returnFocus };\n  const reduced = useReducedMotion();\n  // When the open state last changed. Radix waits for the click before treating a press as outside, and a press on the trigger\n  // while the dialog leaves reopens it first, so that press must not close it again.\n  const change = useRef({ open, at: 0 });\n  useLayoutEffect(() => { change.current = { open, at: performance.now() }; }, [open]);\n  const pressOutside: DialogContentProps[\"onPointerDownOutside\"] = event => {\n    onPointerDownOutside?.(event);\n    if (open !== null && (!change.current.open || event.detail.originalEvent.timeStamp < change.current.at)) event.preventDefault();\n  };\n  const classes = [styles.content, hideHeader && styles.headerless, placement === \"top\" && styles.top, className].filter(Boolean).join(\" \");\n  const inner = hideHeader ? <>\n    <DialogPrimitive.Title className={styles.visuallyHidden}>{title}</DialogPrimitive.Title>\n    {description ? <DialogPrimitive.Description className={styles.visuallyHidden}>{description}</DialogPrimitive.Description> : null}\n    <div className={styles.body}>{children}</div>\n  </> : <>\n    <div className={styles.header}><div><DialogPrimitive.Title className={styles.title}><SwapText text={title}/></DialogPrimitive.Title>{description ? <DialogPrimitive.Description className={styles.description}>{typeof description === \"string\" ? <SwapText text={description}/> : <span className={styles.swap}>{description}</span>}</DialogPrimitive.Description> : null}</div><DialogPrimitive.Close className={styles.close} aria-label=\"Close dialog\"><X size={16} strokeWidth={1.75} aria-hidden=\"true\"/></DialogPrimitive.Close></div>\n    <div className={styles.body}>{children}</div>\n  </>;\n  // Explicitly remove Radix's generated description reference when no description was supplied.\n  const describedBy = props[\"aria-describedby\"];\n  // Under a bare Radix root the open state is unknown here, so CSS keyframes keyed off data-state animate the layers instead.\n  if (open === null) return <DialogPrimitive.Portal>\n    <DialogPrimitive.Overlay className={`${styles.overlay} ${styles.keyframes}`}/>\n    <DialogPrimitive.Content {...props} {...escape} aria-describedby={describedBy} onPointerDownOutside={pressOutside} className={`${classes} ${styles.keyframes}`}>{inner}</DialogPrimitive.Content>\n  </DialogPrimitive.Portal>;\n  // The overlay fades while the dialog rises 8px and scales up on a spring. Closing is shorter and quieter, and starts from wherever the entrance is.\n  return <AnimatePresence>\n    {open && <DialogPrimitive.Portal key=\"dialog\" forceMount>\n      <DialogPrimitive.Overlay asChild forceMount><motion.div className={styles.overlay} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: reduced ? fade : leave }} transition={reduced ? fade : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}/></DialogPrimitive.Overlay>\n      <DialogPrimitive.Content {...props} {...escape} aria-describedby={describedBy} onPointerDownOutside={pressOutside} asChild forceMount>\n        <motion.div className={classes} initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, scale: .96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduced ? { opacity: 0, transition: fade } : { opacity: 0, y: 4, scale: .98, transition: leave }} transition={reduced ? fade : { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }}>{inner}</motion.div>\n      </DialogPrimitive.Content>\n    </DialogPrimitive.Portal>}\n  </AnimatePresence>;\n}\n"},{"path":"registry/components/dialog/dialog.module.css","type":"registry:component","target":"@components/silicon-ui/dialog/dialog.module.css","content":".overlay { position: fixed; inset: 0; z-index: var(--layer-overlay); background: var(--scrim); backdrop-filter: blur(7px); }\n/* Centered with auto margins (like a native modal dialog) so transform stays free for the entrance spring. */\n.content { position: fixed; z-index: var(--layer-dialog); inset: 0; width: min(calc(100vw - var(--space-block)), var(--dialog-width, 440px)); height: fit-content; max-height: calc(100vh - var(--space-block)); max-height: calc(100dvh - var(--space-block)); margin: auto; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-overlay); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); }\n/* A palette supplies its own surface; the dialog still owns the modal layer and focus behavior. */\n.headerless { width: min(calc(100vw - var(--space-block)), var(--dialog-width, 640px)); border: 0; background: transparent; box-shadow: none; }\n.headerless .body { padding: 0; }\n.visuallyHidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.top { --dialog-top: min(14dvh, var(--space-screen)); top: var(--dialog-top); bottom: auto; max-height: calc(100dvh - var(--dialog-top) - var(--space-block)); margin-block: 0; }\n@media (max-width: 480px) { .top { --dialog-top: var(--space-related); } }\n/* Motion animates both layers. Keyframes only run when DialogContent sits under a bare Radix root. */\n.keyframes.overlay[data-state=\"open\"] { animation: overlay-in var(--duration-standard) var(--ease-enter) both; }\n.keyframes.overlay[data-state=\"closed\"] { animation: overlay-out var(--duration-fast) var(--ease-standard) both; }\n.keyframes.content[data-state=\"open\"] { animation: content-fade-in var(--duration-fast) var(--ease-enter) both, content-rise-in var(--duration-spring) var(--ease-spring) both; }\n.keyframes.content[data-state=\"closed\"] { animation: content-out var(--duration-fast) var(--ease-standard) both; }\n.content:focus { outline: none; }\n/* While the layers leave, clicks pass through, so the trigger can reopen the dialog mid-exit. Radix sets pointer-events inline on the overlay. */\n.overlay[data-state=\"closed\"], .content[data-state=\"closed\"] { pointer-events: none !important; }\n.header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-comfort); padding: var(--space-section); border-bottom: 1px solid var(--border); }\n.title { margin: 0; font-family: var(--font-display); font-size: var(--text-lg); font-weight: 600; letter-spacing: var(--tracking-display); line-height: var(--leading-body); }\n.description { margin: var(--space-related) 0 0; color: var(--dialog-description-colour, var(--text-secondary)); font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: var(--leading-body); }\n.title, .description { position: relative; overflow-wrap: anywhere; }\n.swap { display: block; }\n/* Line height sits on the text itself, so a page-level heading reset cannot shrink the title and pull the close button off its line. */\n.title .swap { line-height: var(--leading-body); }\n/* A ghost icon button at Button's xs size; the negative margin centres it on the title's first line. */\n.close { flex: 0 0 auto; margin-block: calc((var(--text-lg) * var(--leading-body) - var(--control-height-xs)) / 2); display: grid; place-items: center; width: var(--control-height-xs); height: var(--control-height-xs); padding: 0; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-secondary); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n@media (hover: hover) and (pointer: fine) { .close:hover { background: var(--surface-muted); color: var(--foreground); } }\n.close:focus-visible { outline: none; background: var(--surface-muted); color: var(--foreground); }\n.close:active { background: var(--surface-muted); color: var(--foreground); transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.body { padding: var(--space-section); font-size: var(--text-sm); line-height: var(--leading-body); }\n@keyframes overlay-in { from { opacity: 0; } }\n@keyframes overlay-out { to { opacity: 0; } }\n@keyframes content-fade-in { from { opacity: 0; } }\n@keyframes content-rise-in { from { transform: translateY(8px) scale(.96); } }\n@keyframes content-out { to { opacity: 0; transform: translateY(4px) scale(.98); } }\n@keyframes content-fade-out { to { opacity: 0; } }\n@media (prefers-reduced-motion: reduce) {\n  .keyframes.overlay[data-state=\"open\"], .keyframes.content[data-state=\"open\"] { animation: overlay-in var(--duration-instant) var(--ease-standard) both; }\n  .keyframes.overlay[data-state=\"closed\"] { animation: overlay-out var(--duration-instant) var(--ease-standard) both; }\n  .keyframes.content[data-state=\"closed\"] { animation: content-fade-out var(--duration-instant) var(--ease-standard) both; }\n  .close, .close:active { transition: none; }\n  .close:active { transform: none; }\n}\n"},{"path":"lib/escape.ts","type":"registry:lib","target":"@components/silicon-ui/lib/escape.ts","content":"/**\n * Escape inside a layer (Dialog, Drawer, BottomSheet, Popover) belongs to the control that has focus when that control\n * has something of its own to close: an open Combobox list, an open DatePicker calendar, an InlineEdit being edited, a\n * ConfirmMorph asking its question. Only the next Escape closes the layer.\n *\n * Radix closes its layers from a capture listener on the document, before the control's own key handler runs, so every\n * Silicon UI layer passes its `onEscapeKeyDown` through `layerEscape` (and marks its own panel with `data-escape-layer`, where\n * the search for an inner popup stops). Pages need nothing for this; their own `onEscapeKeyDown` still runs first and\n * can keep the layer open for reasons of its own with `event.preventDefault()`.\n */\n\n/** Marks a layer's own panel (so it is never mistaken for a popup of a control inside it). */\nexport const ESCAPE_LAYER_ATTRIBUTE = \"data-escape-layer\";\n\n/** Controls that close something of their own on Escape while focus is in them. */\nconst OWN_ESCAPE = [\n  // Combobox with its list open (focus stays in its input).\n  '[role=\"combobox\"][aria-expanded=\"true\"]',\n  // DatePicker's trigger (or any popup button) while its panel is open.\n  '[aria-haspopup=\"dialog\"][aria-expanded=\"true\"]',\n  '[aria-haspopup=\"listbox\"][aria-expanded=\"true\"]',\n  // InlineEdit while editing: Escape puts the saved text back.\n  \"[data-editing]\",\n].join(\", \");\n\n/** ConfirmMorph asking its question or showing a result (Escape returns it to rest). */\nconst MORPH_STATES = '[data-state=\"confirming\"], [data-state=\"done\"], [data-state=\"error\"]';\n\n/** True when Escape at `target` is handled by a control inside the layer, which closes its own part first. */\nexport function escapeBelongsInside(target: EventTarget | null): boolean {\n  if (!(target instanceof Element)) return false;\n  if (target.closest(OWN_ESCAPE)) return true;\n  // A ConfirmMorph's root (focused while it works or shows a result) or one of its faces. A HoldToConfirm button also\n  // says data-state=\"done\", but it has no faces and no Escape of its own.\n  const morph = target.closest(MORPH_STATES);\n  if (morph?.querySelector(\"[data-face]\")) return true;\n  // A popup of a control (DatePicker's calendar is a dialog inside the layer); the layer's own panel is not one.\n  const dialog = target.closest('[role=\"dialog\"]');\n  return !!dialog && !dialog.hasAttribute(ESCAPE_LAYER_ATTRIBUTE);\n}\n\n/**\n * The `onEscapeKeyDown` a Silicon UI layer gives Radix: the page's own handler first, then the layer stays open when the\n * Escape belongs to a control inside it.\n */\nexport function layerEscape<E extends KeyboardEvent>(own?: (event: E) => void): (event: E) => void {\n  return event => {\n    own?.(event);\n    if (!event.defaultPrevented && escapeBelongsInside(event.target)) event.preventDefault();\n  };\n}\n"},{"path":"lib/return-focus.ts","type":"registry:lib","target":"@components/silicon-ui/lib/return-focus.ts","content":"/**\n * Focus goes back to what opened a layer when it closes (WCAG 2.4.3), however it was opened.\n *\n * Radix's modal Dialog returns focus only to its own <Dialog.Trigger>. Most layers here open from controlled state (a\n * plain button, a menu item, a command, a key), so there is no Trigger and focus fell to <body> when they closed.\n * `useReturnFocus(open)` remembers what had focus when `open` turned true and hands the layer's content an\n * `onCloseAutoFocus` that puts focus back there:\n *\n * - it is captured in an insertion effect, which runs before anything in the layer can take focus (an `autoFocus`\n *   field is focused in the layout phase, Radix's FocusScope moves focus in a passive effect); under a bare Radix root,\n *   where `open` is unknown, `onOpenAutoFocus` captures it instead;\n * - the caller's own `onCloseAutoFocus` runs first, and one that calls `preventDefault()` keeps its own target;\n * - nothing remembered (focus was on <body>, or the element is gone): Radix's own behaviour, its Trigger if any;\n * - focus already moved on to something outside the layer while it left (a command opened another layer, a link was\n *   followed): it stays there.\n */\nimport { useInsertionEffect, useRef } from \"react\";\n\n/** What has focus now, unless that is nothing in particular (<body>). */\nfunction focusedElement(): HTMLElement | null {\n  const active = typeof document === \"undefined\" ? null : document.activeElement;\n  return active instanceof HTMLElement && active !== document.body ? active : null;\n}\n\nexport interface ReturnFocusHandlers {\n  onOpenAutoFocus: (event: Event) => void;\n  onCloseAutoFocus: (event: Event) => void;\n}\n\n/**\n * `open`: the layer's open state (null or undefined when the caller cannot know it). `onOpenAutoFocus` /\n * `onCloseAutoFocus`: the caller's own handlers, composed (they run first).\n */\nexport function useReturnFocus(open: boolean | null | undefined, onOpenAutoFocus?: (event: Event) => void, onCloseAutoFocus?: (event: Event) => void): ReturnFocusHandlers {\n  const opener = useRef<HTMLElement | null>(null);\n  useInsertionEffect(() => {\n    if (open) opener.current = focusedElement();\n  }, [open]);\n  return {\n    onOpenAutoFocus(event) {\n      if (open === null || open === undefined) opener.current = focusedElement();\n      onOpenAutoFocus?.(event);\n    },\n    onCloseAutoFocus(event) {\n      onCloseAutoFocus?.(event);\n      const target = opener.current;\n      opener.current = null;\n      const layer = event.currentTarget instanceof Element ? event.currentTarget : null;\n      if (event.defaultPrevented || !target?.isConnected || layer?.contains(target)) return;\n      event.preventDefault();\n      const active = document.activeElement;\n      if (active && active !== document.body && !layer?.contains(active)) return;\n      target.focus();\n    },\n  };\n}\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/dialog","meta":{"tier":"free","kind":"component","docs":"https://ui.teamofsilicons.com/dialog","markdown":"https://ui.teamofsilicons.com/dialog.md","tags":["modal","overlay"],"status":"stable","experimentalProps":{"DialogContent":{"hideHeader":["*"],"placement":["*"]}}}}