{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"alert","title":"Alert","description":"Persistent feedback with semantic tone, optional actions and an optional close control, inline, as a page banner or as a card band. Search terms: notice.","dependencies":["class-variance-authority@0.7.1","lucide-react@1.18.0"],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/motion"],"files":[{"path":"packages/ui/src/components/alert/alert.tsx","content":"\"use client\";\n\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { XIcon } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { KitButton } from \"../button/button-internal\";\nimport { MotionReveal } from \"../motion\";\n\n// The columns are design-rework.css's (Alert): the action takes a column of\n// its own only from 640 px and only beside a title or description; below\n// 640 px it wraps under the text, so the grid keeps no empty track for it\n// (rulebook S11). The text column is minmax(0, 1fr), so a long value wraps\n// instead of pushing the action out (rulebook K5). A banner or a band lays\n// out by its own width instead (design-rework.css, Alert layouts).\nconst alertVariants = cva(\"relative grid w-full items-start\", {\n  defaultVariants: { layout: \"inline\", variant: \"default\" },\n  variants: {\n    // An inline Alert's 16 px icon is one text line high; a banner's and a\n    // band's icon size is design-rework.css's.\n    layout: {\n      band: \"\",\n      banner: \"\",\n      inline: \"[&>svg]:h-lh [&>svg]:w-4\",\n    },\n    variant: {\n      default: \"[&>svg]:text-muted-foreground\",\n      error: \"[&>svg]:text-destructive\",\n      info: \"[&>svg]:text-info\",\n      success: \"[&>svg]:text-success\",\n      warning: \"[&>svg]:text-warning\",\n    },\n  },\n});\n\n// Only an error or a warning interrupts a screen reader (role=\"alert\"). The\n// default, info and success tones are a polite status, so an Alert rendered\n// after an action does not cut into what is being read, and a page with\n// several Alerts keeps a single assertive region at most. A `role` passed in\n// wins.\nconst assertiveVariants: ReadonlySet<string> = new Set([\"error\", \"warning\"]);\n\n/**\n * - `inline` is a surface in the content flow.\n * - `banner` is a band across the top of a page, above its header, for\n *   something that concerns the whole page: the tone's tint over the full\n *   width, a hairline below it.\n * - `band` is the same band at the top of a Card, directly under its header,\n *   across the card's full width.\n *\n * A banner and a band put the icon, the text and the close control on one\n * row while their own width allows it; below 32rem of their own width the\n * icon and the close control sit on the title's line.\n */\nexport type AlertLayout = \"inline\" | \"banner\" | \"band\";\n\nexport interface AlertProps\n  extends\n    React.ComponentProps<\"div\">,\n    Pick<VariantProps<typeof alertVariants>, \"variant\"> {\n  layout?: AlertLayout;\n  /** Shows the Alert, controlled. Closing collapses it with the kit's motion. */\n  open?: boolean;\n  /** Shows the Alert at first, uncontrolled. Defaults to `true`. */\n  defaultOpen?: boolean;\n  /** Called when `AlertClose` closes the Alert, with `false`. */\n  onOpenChange?: (open: boolean) => void;\n}\n\ninterface AlertContextValue {\n  close: (trigger: HTMLElement | null) => void;\n  layout: AlertLayout;\n}\n\nconst AlertContext = React.createContext<AlertContextValue | null>(null);\n\nconst tabbableSelector = [\n  \"a[href]\",\n  \"area[href]\",\n  \"button:not([disabled])\",\n  \"input:not([disabled]):not([type='hidden'])\",\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  \"iframe\",\n  \"[contenteditable]:not([contenteditable='false'])\",\n  \"[tabindex]\",\n].join(\",\");\n\nfunction isTabbable(element: HTMLElement): boolean {\n  if (element.tabIndex < 0) return false;\n  if (element.closest(\"[inert], [aria-hidden='true']\")) return false;\n  return element.getClientRects().length > 0;\n}\n\n/**\n * The element that takes focus when a closing Alert held it: the next tabbable\n * element after the Alert in document order, or the one before it at the end\n * of the page, so focus never falls back to the body.\n */\nfunction focusTargetAfter(root: HTMLElement): HTMLElement | null {\n  const candidates = Array.from(\n    root.ownerDocument.querySelectorAll<HTMLElement>(tabbableSelector),\n  ).filter((element) => !root.contains(element) && isTabbable(element));\n  const following = candidates.find(\n    (element) =>\n      root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING,\n  );\n  if (following) return following;\n  const preceding = candidates.filter(\n    (element) =>\n      root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING,\n  );\n  return preceding.at(-1) ?? null;\n}\n\nexport function Alert({\n  className,\n  defaultOpen = true,\n  layout = \"inline\",\n  onOpenChange,\n  open: openProp,\n  variant,\n  ...props\n}: AlertProps): React.ReactElement | null {\n  const resolvedVariant = variant ?? \"default\";\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);\n  const open = openProp ?? uncontrolledOpen;\n  // A banner, a band and an Alert whose open state is set collapse when they\n  // close; an inline Alert without one keeps its plain markup.\n  const reveals =\n    layout !== \"inline\" || openProp !== undefined || onOpenChange !== undefined;\n\n  const context = React.useMemo<AlertContextValue>(\n    () => ({\n      close: (trigger) => {\n        const root = rootRef.current;\n        if (root && trigger && root.contains(document.activeElement)) {\n          focusTargetAfter(root)?.focus({ preventScroll: true });\n        }\n        if (openProp === undefined) setUncontrolledOpen(false);\n        onOpenChange?.(false);\n      },\n      layout,\n    }),\n    [layout, onOpenChange, openProp],\n  );\n\n  const alert = (\n    <AlertContext.Provider value={context}>\n      <div\n        className={cn(\n          slotClass(\"alert\", props),\n          alertVariants({ layout, variant }),\n          className,\n        )}\n        data-layout={layout === \"inline\" ? undefined : layout}\n        data-slot=\"alert\"\n        data-variant={resolvedVariant}\n        ref={rootRef}\n        role={assertiveVariants.has(resolvedVariant) ? \"alert\" : \"status\"}\n        {...props}\n      />\n    </AlertContext.Provider>\n  );\n\n  if (!reveals) return open ? alert : null;\n  return <MotionReveal open={open}>{alert}</MotionReveal>;\n}\n\nexport function AlertTitle({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  return (\n    <div\n      className={cn(\n        slotClass(\"alert-title\", props),\n        \"wrap-anywhere [svg~&]:col-start-2\",\n        className,\n      )}\n      data-slot=\"alert-title\"\n      {...props}\n    />\n  );\n}\n\nexport function AlertDescription({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  return (\n    <div\n      className={cn(\n        slotClass(\"alert-description\", props),\n        \"flex flex-col gap-2.5 wrap-anywhere [svg~&]:col-start-2\",\n        className,\n      )}\n      data-slot=\"alert-description\"\n      {...props}\n    />\n  );\n}\n\nexport function AlertAction({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  const layout = React.useContext(AlertContext)?.layout ?? \"inline\";\n  return (\n    // Below 640 px the action wraps under the text, in the text's column. From\n    // 640 px it sits beside the text and spans two rows only beside a title\n    // and a description (rulebook S11). A banner or a band places it by its\n    // own width (design-rework.css).\n    <div\n      className={cn(\n        slotClass(\"alert-action\", props),\n        \"flex empty:hidden\",\n        layout === \"inline\" &&\n          \"max-sm:mt-2 [svg~&]:col-start-2 sm:row-start-1 sm:self-center sm:[[data-slot=alert-title]~[data-slot=alert-description]~&]:row-end-3 sm:[[data-slot=alert-description]~&]:col-start-2 sm:[[data-slot=alert-title]~&]:col-start-2 sm:[svg~[data-slot=alert-description]~&]:col-start-3 sm:[svg~[data-slot=alert-title]~&]:col-start-3\",\n        className,\n      )}\n      data-slot=\"alert-action\"\n      {...props}\n    />\n  );\n}\n\nexport interface AlertCloseProps extends Omit<\n  React.ComponentProps<\"button\">,\n  \"children\"\n> {\n  /** The accessible name of the close control. Defaults to \"Close\". */\n  \"aria-label\"?: string;\n}\n\n/**\n * The × that closes the Alert: the kit's ghost dismiss control, the same as a\n * Dialog's or a Notification's. The Alert collapses with the kit's motion, and\n * focus moves to the next element after it instead of to the page.\n */\nexport function AlertClose({\n  \"aria-label\": ariaLabel = \"Close\",\n  className,\n  onClick,\n  ...props\n}: AlertCloseProps): React.ReactElement {\n  const context = React.useContext(AlertContext);\n  return (\n    <KitButton\n      aria-label={ariaLabel}\n      className={cn(slotClass(\"alert-close\", props), className)}\n      onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context?.close(event.currentTarget);\n      }}\n      profile=\"overlay-dismiss\"\n      shape=\"square\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...props}\n    >\n      <XIcon aria-hidden=\"true\" />\n    </KitButton>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/alert/alert.tsx"},{"path":"packages/ui/src/components/alert/index.ts","content":"export * from \"./alert\";\n","type":"registry:ui","target":"~/src/siteplane/components/alert/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/alert.md","categories":["Feedback"],"meta":{"avoidWhen":["Avoid for consequential modal decisions; use Alert Dialog.","Avoid for transient status outside the content flow; use Toast."],"description":"Persistent feedback with semantic tone, optional actions and an optional close control, inline, as a page banner or as a card band.","knownLimitations":[],"providerNotes":[],"releaseStatus":"stable","searchTerms":["alert","banner","notice","band"],"useWhen":["Use for a message about the state of a page or a form that stays in the content flow, with a tone and optional actions.","Use layout=\"banner\" for a problem that concerns the whole page, at its top, and layout=\"band\" for a problem of one card, under its header."]}}