{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"alert-dialog","type":"registry:ui","title":"AlertDialog","description":"A confirmation dialog for consequential, hard-to-undo actions — fixed confirm/cancel actions, never dismissed by an outside click","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["overlays"],"registryDependencies":["https://fancy-ui.rama.app/r/button.json","https://fancy-ui.rama.app/r/dialog.json","https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/alert-dialog/AlertDialog.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport { Button } from \"../button/index\";\nimport { DialogSurface } from \"../dialog/DialogSurface\";\n\nexport interface AlertDialogProps {\n\t/** Whether the alert dialog is open. Controlled when given; the component keeps its own copy either way. */\n\topen?: boolean;\n\t/** Fires whenever `open` changes — Confirm, Cancel, or Escape. */\n\tonOpenChange?: (open: boolean) => void;\n\t/** The heading. Omitted entirely (not just visually) when not given, so `aria-labelledby` never points at nothing. */\n\ttitle?: string;\n\t/** The warning copy under the title. Same omission rule as `title`. */\n\tdescription?: string;\n\t/** Label of the destructive action. */\n\tconfirmLabel?: string;\n\t/** Label of the safe action. */\n\tcancelLabel?: string;\n\t/** Called when the destructive action is activated, before the surface closes. */\n\tonConfirm?: () => void;\n\t/**\n\t * Called when the safe action is activated, before the surface closes —\n\t * and also when Escape closes the surface, since Escape is treated as\n\t * the keyboard equivalent of Cancel here. See the README for why.\n\t */\n\tonCancel?: () => void;\n\t/** Element to focus once the surface opens. Defaults to the Cancel button — see the README. */\n\tinitialFocus?: HTMLElement | null;\n\t/** Optional trigger; renders in place and opens the surface on activation. */\n\ttrigger?: ReactNode;\n\t/** Additional classes for the panel. */\n\tclassName?: string;\n\t/**\n\t * Plays the matching open/select/close cue through the sound controller.\n\t * Off by default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nexport const AlertDialog = forwardRef<HTMLDivElement, AlertDialogProps>(function AlertDialog(\n\t{\n\t\topen: openProp,\n\t\tonOpenChange,\n\t\ttitle,\n\t\tdescription,\n\t\tconfirmLabel = \"Confirm\",\n\t\tcancelLabel = \"Cancel\",\n\t\tonConfirm,\n\t\tonCancel,\n\t\tinitialFocus = null,\n\t\ttrigger,\n\t\tclassName,\n\t\tsound = false,\n\t},\n\tforwardedRef\n) {\n\t// The React shape of the source's `open = $bindable(false)`, identical to\n\t// `Dialog`'s: an internal copy seeded from the prop and re-synced during\n\t// render whenever the CALLER changes it, free to move on its own in\n\t// between. That is what lets one implementation serve a caller driving\n\t// `open` from its own state, a caller who passes only `onOpenChange`, and\n\t// a caller who passes neither and lets the trigger run the whole thing.\n\t//\n\t// Re-synced in the render path, not an effect: an effect would paint one\n\t// frame of the stale value first.\n\tconst [open, setOpenState] = useState(openProp ?? false);\n\tconst [lastOpenProp, setLastOpenProp] = useState(openProp);\n\tif (lastOpenProp !== openProp) {\n\t\tsetLastOpenProp(openProp);\n\t\tsetOpenState(openProp ?? false);\n\t}\n\n\t// A no-op while `sound` is false, so the call sites below stay unguarded.\n\tconst playCue = useSoundCue(sound);\n\n\t// Same guard, and the same reason, as Dialog's own `setOpen`: a dismiss\n\t// that changes nothing fires nothing, so a second Escape during the close\n\t// cannot fire `onOpenChange` — or, through `handleCancel` below,\n\t// `onCancel` — a second time.\n\tfunction setOpen(next: boolean) {\n\t\tif (open === next) return;\n\t\tsetOpenState(next);\n\t\tonOpenChange?.(next);\n\t}\n\n\t// A plain ref, not state: nothing renders off it, and both consumers\n\t// (`exclude`, `fallbackFocus`) resolve it at event time. Also doubles as\n\t// `DialogSurface`'s `fallbackFocus` target below — see Dialog's identical\n\t// field for why.\n\tconst triggerRef = useRef<HTMLElement | null>(null);\n\n\tfunction openFromTrigger() {\n\t\tif (open) return;\n\t\tplayCue(\"open\");\n\t\tsetOpen(true);\n\t}\n\n\t// Escape's only path to closing this surface. Routing it through the\n\t// exact same function the Cancel button calls is the point, not an\n\t// implementation shortcut — see the README's \"why is Escape wired to\n\t// onCancel\" note: a user who presses Escape on a destructive prompt\n\t// meant to back out, the same thing clicking Cancel means, so both fire\n\t// the same callback.\n\tfunction handleCancel() {\n\t\t// Restates `setOpen`'s own dedupe locally: this runs before `setOpen`,\n\t\t// so a second Escape/Cancel arriving while `open` is already false\n\t\t// (mid-exit) must not play a second `close`.\n\t\tif (open) playCue(\"close\");\n\t\tonCancel?.();\n\t\tsetOpen(false);\n\t}\n\n\tfunction handleConfirm() {\n\t\t// Commit-close is silent — `setOpen` below carries no cue of its own —\n\t\t// so `select` is the only sound a confirm ever plays. The internal\n\t\t// Cancel/Confirm Buttons are never handed `sound` either: one\n\t\t// activation must play one cue, not this one plus a Button `press`.\n\t\tplayCue(\"select\");\n\t\tonConfirm?.();\n\t\tsetOpen(false);\n\t}\n\n\tconst uid = useFancyId();\n\tconst titleId = title ? `${uid}-title` : undefined;\n\tconst descriptionId = description ? `${uid}-description` : undefined;\n\n\t// Same disclosure wiring as Dialog's: the state goes on the real button\n\t// inside the semantics-free wrapper, `aria-controls` only while the panel\n\t// exists.\n\tconst panelId = `${uid}-panel`;\n\tuseEffect(() => {\n\t\tconst el = triggerRef.current?.firstElementChild;\n\t\tif (!el) return;\n\t\tel.setAttribute(\"aria-haspopup\", \"dialog\");\n\t\tel.setAttribute(\"aria-expanded\", String(open));\n\t\tif (open) el.setAttribute(\"aria-controls\", panelId);\n\t\telse el.removeAttribute(\"aria-controls\");\n\t\treturn () => {\n\t\t\tel.removeAttribute(\"aria-haspopup\");\n\t\t\tel.removeAttribute(\"aria-expanded\");\n\t\t\tel.removeAttribute(\"aria-controls\");\n\t\t};\n\t}, [open, panelId, trigger]);\n\n\tconst panelContent = (\n\t\t<>\n\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t<span className=\"text-destructive\" aria-hidden=\"true\">\n\t\t\t\t\t⚠\n\t\t\t\t</span>\n\t\t\t\t{title ? (\n\t\t\t\t\t<h2 id={titleId} className=\"text-[15px] font-semibold text-balance\">\n\t\t\t\t\t\t{title}\n\t\t\t\t\t</h2>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t\t{description ? (\n\t\t\t\t<p id={descriptionId} className=\"text-muted-foreground text-[12.5px] leading-relaxed\">\n\t\t\t\t\t{description}\n\t\t\t\t</p>\n\t\t\t) : null}\n\t\t\t<div className=\"flex justify-end gap-2\">\n\t\t\t\t{/*\n\t\t\t\t\tCancel first in DOM order — on top of matching the mockup's own\n\t\t\t\t\tleft-to-right layout, this is what makes it the focus trap's default\n\t\t\t\t\tfocus target (the trap always focuses the first focusable descendant\n\t\t\t\t\tabsent an explicit `initialFocus`), with no extra wiring needed to\n\t\t\t\t\tsatisfy \"cancel, not confirm, is focused first\" for the common case\n\t\t\t\t\tof no override.\n\t\t\t\t*/}\n\t\t\t\t<Button variant=\"outline\" size=\"sm\" onClick={handleCancel}>\n\t\t\t\t\t{cancelLabel}\n\t\t\t\t</Button>\n\t\t\t\t<Button variant=\"destructive\" size=\"sm\" onClick={handleConfirm}>\n\t\t\t\t\t{confirmLabel}\n\t\t\t\t</Button>\n\t\t\t</div>\n\t\t</>\n\t);\n\n\treturn (\n\t\t<>\n\t\t\t{trigger ? (\n\t\t\t\t/*\n\t\t\t\t\tSee Dialog's identical wrapper for why this has no ARIA role of its\n\t\t\t\t\town: it only listens for the click bubbling up from whatever\n\t\t\t\t\t`trigger` renders, which is expected to be a real interactive\n\t\t\t\t\telement carrying its own keyboard activation. `display: contents`\n\t\t\t\t\tkeeps it out of layout entirely.\n\t\t\t\t*/\n\t\t\t\t<span\n\t\t\t\t\tref={(node) => {\n\t\t\t\t\t\ttriggerRef.current = node;\n\t\t\t\t\t}}\n\t\t\t\t\tclassName=\"contents\"\n\t\t\t\t\tonClick={openFromTrigger}\n\t\t\t\t>\n\t\t\t\t\t{trigger}\n\t\t\t\t</span>\n\t\t\t) : null}\n\t\t\t<DialogSurface\n\t\t\t\tref={forwardedRef}\n\t\t\t\topen={open}\n\t\t\t\tid={panelId}\n\t\t\t\trole=\"alertdialog\"\n\t\t\t\ttitleId={titleId}\n\t\t\t\tdescriptionId={descriptionId}\n\t\t\t\tescape={true}\n\t\t\t\toutsideClick={false}\n\t\t\t\tonDismiss={handleCancel}\n\t\t\t\tinitialFocus={initialFocus}\n\t\t\t\tfallbackFocus={() => triggerRef.current}\n\t\t\t\texclude={() => [triggerRef.current]}\n\t\t\t\tpanelClass={cn(\"border-destructive/25\", className)}\n\t\t\t>\n\t\t\t\t{panelContent}\n\t\t\t</DialogSurface>\n\t\t</>\n\t);\n});\n\nAlertDialog.displayName = \"AlertDialog\";\n","type":"registry:ui","target":"@ui/fancy/components/alert-dialog/AlertDialog.tsx"},{"path":"react/src/components/alert-dialog/index.ts","content":"\"use client\";\n\nexport { AlertDialog } from \"./AlertDialog\";\nexport type { AlertDialogProps } from \"./AlertDialog\";\n","type":"registry:ui","target":"@ui/fancy/components/alert-dialog/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/alert-dialog?framework=react"}}