{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"collapsible-banner","type":"registry:ui","title":"Collapsible Banner","description":"Folds to its title, or lets go entirely.","dependencies":["motion"],"categories":["notification"],"docs":"https://www.interior.dev/docs/collapsible-banner","files":[{"path":"registry/interior/collapsible-banner.tsx","type":"registry:ui","target":"components/interior/collapsible-banner.tsx","content":"\"use client\";\n\nimport { useCallback, useId, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst DISCLOSE = { type: \"spring\", stiffness: 190, damping: 30, mass: 1 } as const;\nconst NUDGE = { type: \"spring\", stiffness: 700, damping: 46, mass: 0.5 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type BannerState = \"open\" | \"folded\" | \"dismissed\";\n\nexport type UseCollapsibleBannerOptions = {\n  state?: BannerState;\n  defaultState?: BannerState;\n  onStateChange?: (state: BannerState) => void;\n  onDismiss?: () => void;\n};\n\nexport type UseCollapsibleBannerResult = {\n  state: BannerState;\n  open: boolean;\n  folded: boolean;\n  dismissed: boolean;\n  fold: () => void;\n  expand: () => void;\n  toggle: () => void;\n  dismiss: () => void;\n  restore: () => void;\n};\n\nexport function useCollapsibleBanner({\n  state: controlled,\n  defaultState = \"open\",\n  onStateChange,\n  onDismiss,\n}: UseCollapsibleBannerOptions = {}): UseCollapsibleBannerResult {\n  const [uncontrolled, setUncontrolled] = useState<BannerState>(defaultState);\n  const state = controlled ?? uncontrolled;\n\n  const changed = useRef(onStateChange);\n  changed.current = onStateChange;\n  const closed = useRef(onDismiss);\n  closed.current = onDismiss;\n\n  const commit = useCallback((next: BannerState) => {\n    setUncontrolled(next);\n    changed.current?.(next);\n  }, []);\n\n  const fold = useCallback(() => commit(\"folded\"), [commit]);\n  const expand = useCallback(() => commit(\"open\"), [commit]);\n  const restore = useCallback(() => commit(\"open\"), [commit]);\n\n  const toggle = useCallback(\n    () => commit(state === \"open\" ? \"folded\" : \"open\"),\n    [commit, state],\n  );\n\n  const dismiss = useCallback(() => {\n    commit(\"dismissed\");\n    closed.current?.();\n  }, [commit]);\n\n  return {\n    state,\n    open: state === \"open\",\n    folded: state === \"folded\",\n    dismissed: state === \"dismissed\",\n    fold,\n    expand,\n    toggle,\n    dismiss,\n    restore,\n  };\n}\n\nconst NOTICE_GLYPH = (\n  <svg width=\"16\" height=\"16\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <circle cx=\"128\" cy=\"128\" r=\"96\" stroke=\"currentColor\" strokeWidth=\"16\" />\n    <polyline\n      points=\"120 120 128 120 128 176 136 176\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n    <circle cx=\"124\" cy=\"84\" r=\"12\" fill=\"currentColor\" />\n  </svg>\n);\n\nconst CARET_DOWN = (\n  <svg width=\"14\" height=\"14\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <polyline\n      points=\"208 96 128 176 48 96\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nconst CLOSE = (\n  <svg width=\"13\" height=\"13\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <line\n      x1=\"200\"\n      y1=\"56\"\n      x2=\"56\"\n      y2=\"200\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n    />\n    <line\n      x1=\"200\"\n      y1=\"200\"\n      x2=\"56\"\n      y2=\"56\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n    />\n  </svg>\n);\n\nexport type CollapsibleBannerProps = {\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  children?: React.ReactNode;\n  action?: React.ReactNode;\n  icon?: React.ReactNode;\n\n  dismissible?: boolean;\n  state?: BannerState;\n  defaultState?: BannerState;\n  onStateChange?: (state: BannerState) => void;\n  onDismiss?: () => void;\n  dismissLabel?: string;\n  dismissedMessage?: string;\n  className?: string;\n};\n\nexport function CollapsibleBanner({\n  title,\n  description,\n  children,\n  action,\n  icon,\n  dismissible = true,\n  state: controlled,\n  defaultState = \"open\",\n  onStateChange,\n  onDismiss,\n  dismissLabel = \"Dismiss notice\",\n  dismissedMessage = \"Notice dismissed.\",\n  className = \"\",\n}: CollapsibleBannerProps) {\n  const reduced = useReducedMotion();\n  const uid = useId();\n  const bodyId = `${uid}-body`;\n  const titleId = `${uid}-title`;\n\n  const { state, open, dismissed, toggle, fold, dismiss } = useCollapsibleBanner({\n    state: controlled,\n    defaultState,\n    onStateChange,\n    onDismiss,\n  });\n\n  const hasBody = Boolean(description || children || action);\n\n  const disclose = reduced\n    ? INSTANT\n    : {\n        height: DISCLOSE,\n        opacity: { duration: 0.14, ease: EASE, delay: open ? 0.05 : 0 },\n        y: DISCLOSE,\n      };\n\n  return (\n    <>\n      <motion.div\n        initial={false}\n        animate={{ height: dismissed ? 0 : \"auto\", opacity: dismissed ? 0 : 1 }}\n        transition={\n          reduced\n            ? INSTANT\n            : { height: DISCLOSE, opacity: { duration: 0.14, ease: EASE } }\n        }\n        style={{ overflow: \"hidden\" }}\n        className=\"rounded-[11px]\"\n      >\n        <div\n          role=\"region\"\n          aria-labelledby={titleId}\n          className={`rounded-[11px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_6px_rgba(0,0,0,0.45)] ${className}`}\n        >\n          <div className=\"flex items-center gap-2.5 p-2.5\">\n            <span\n              aria-hidden=\"true\"\n              className=\"grid size-[26px] shrink-0 place-items-center rounded-[7px] bg-stone-100/70 text-stone-500 shadow-[inset_0_1px_2px_rgba(28,25,23,0.06)] dark:bg-[#252522] dark:text-stone-400 dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.4)]\"\n            >\n              {icon ?? NOTICE_GLYPH}\n            </span>\n\n            {hasBody ? (\n              <button\n                type=\"button\"\n                onClick={toggle}\n                onKeyDown={(e) => {\n                  if (e.key !== \"Escape\" || !open) return;\n                  e.stopPropagation();\n                  fold();\n                }}\n                aria-expanded={open}\n                aria-controls={bodyId}\n                className=\"group flex min-w-0 flex-1 items-center gap-2 rounded-[7px] text-left outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n              >\n                <span\n                  id={titleId}\n                  className=\"min-w-0 flex-1 truncate text-[13px] font-medium leading-5 text-stone-700 dark:text-stone-100\"\n                >\n                  {title}\n                </span>\n                <motion.span\n                  aria-hidden=\"true\"\n                  className=\"flex shrink-0 text-stone-400 group-hover:text-stone-600 dark:text-stone-500 dark:group-hover:text-stone-300\"\n                  initial={false}\n                  animate={{ rotate: open ? 180 : 0 }}\n                  transition={reduced ? INSTANT : NUDGE}\n                >\n                  {CARET_DOWN}\n                </motion.span>\n              </button>\n            ) : (\n              <span\n                id={titleId}\n                className=\"min-w-0 flex-1 truncate text-[13px] font-medium leading-5 text-stone-700 dark:text-stone-100\"\n              >\n                {title}\n              </span>\n            )}\n\n            {dismissible ? (\n              <button\n                type=\"button\"\n                onClick={dismiss}\n                aria-label={dismissLabel}\n                className=\"grid size-[26px] shrink-0 place-items-center rounded-[7px] text-stone-400 transition-colors duration-150 hover:bg-stone-100 hover:text-stone-700 focus-visible:outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:text-stone-500 dark:hover:bg-white/10 dark:hover:text-stone-100 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n              >\n                {CLOSE}\n              </button>\n            ) : null}\n          </div>\n\n          {hasBody ? (\n            <motion.div\n              id={bodyId}\n              inert={!open}\n              initial={false}\n              animate={{ height: open ? \"auto\" : 0, opacity: open ? 1 : 0 }}\n              transition={disclose}\n              style={{ overflow: \"hidden\" }}\n            >\n              <motion.div\n                initial={false}\n                animate={{ y: open ? 0 : -6 }}\n                transition={reduced ? INSTANT : DISCLOSE}\n                className=\"pb-2.5 pl-[46px] pr-2.5\"\n              >\n                {description ? (\n                  <p className=\"text-[12.5px] leading-relaxed text-stone-500 dark:text-stone-400\">\n                    {description}\n                  </p>\n                ) : null}\n\n                {children}\n\n                {action ? <div className=\"mt-2\">{action}</div> : null}\n              </motion.div>\n            </motion.div>\n          ) : null}\n        </div>\n      </motion.div>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {state === \"dismissed\" ? dismissedMessage : \"\"}\n      </span>\n    </>\n  );\n}\n"}]}