{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-stack",
  "title": "Notification Stack",
  "description": "Several notifications kept as one compact stack. It spreads into a readable list on hover, focus or tap, and folds back when you leave.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/notification-stack/notification-stack.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface NotificationStackItem {\n  id: string\n  title: string\n  description?: React.ReactNode\n  /** A short time, such as \"2m ago\". */\n  time?: string\n  icon?: React.ReactNode\n}\n\nexport interface NotificationStackProps extends Omit<\n  React.HTMLAttributes<HTMLElement>,\n  \"children\"\n> {\n  items: NotificationStackItem[]\n  onDismiss: (id: string) => void\n  /** Omit it and no Clear all button is drawn. */\n  onClearAll?: () => void\n  /** The heading, and the accessible name of the stack. */\n  label?: string\n  emptyMessage?: string\n  dismissLabel?: string\n  clearLabel?: string\n  /** How many cards show behind the first while the stack is closed. */\n  visibleCount?: number\n}\n\nconst PEEK = 10\nconst GAP = 8\nconst SWIPE = 80\nconst LEAVE_MS = 200\n\nconst SPRING =\n  \"duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none\"\n\nfunction reducedMotion() {\n  return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n}\n\nexport const NotificationStack = React.forwardRef<\n  HTMLElement,\n  NotificationStackProps\n>(function NotificationStack(\n  {\n    className,\n    clearLabel = \"Clear all\",\n    dismissLabel = \"Dismiss\",\n    emptyMessage = \"No notifications\",\n    items,\n    label = \"Notifications\",\n    onBlur,\n    onClearAll,\n    onClick,\n    onDismiss,\n    onFocus,\n    onPointerEnter,\n    onPointerLeave,\n    visibleCount = 3,\n    ...rootProps\n  },\n  forwardedRef\n) {\n  const headingId = React.useId()\n  const rootRef = React.useRef<HTMLElement | null>(null)\n  const listRef = React.useRef<HTMLOListElement | null>(null)\n  const dismissRefs = React.useRef(new Map<string, HTMLButtonElement>())\n  const focusIndexRef = React.useRef<number | null>(null)\n  const dragRef = React.useRef<{\n    id: string\n    x: number\n    pointer: number\n  } | null>(null)\n\n  const [hovered, setHovered] = React.useState(false)\n  const [focusWithin, setFocusWithin] = React.useState(false)\n  const [pinned, setPinned] = React.useState(false)\n  const [heights, setHeights] = React.useState<Record<string, number>>({})\n  const [leaving, setLeaving] = React.useState<Record<string, number>>({})\n  const [drag, setDrag] = React.useState<{ id: string; dx: number } | null>(\n    null\n  )\n\n  const [known, setKnown] = React.useState(() => items.map((item) => item.id))\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const ids = items.map((item) => item.id)\n  if (ids.join() !== known.join()) {\n    const arrived = items.filter((item) => !known.includes(item.id))\n    setKnown(ids)\n    if (arrived.length) {\n      setAnnouncement(arrived.map((item) => item.title).join(\". \"))\n    }\n  }\n\n  const expanded = (hovered || focusWithin || pinned) && items.length > 1\n  const shown = Math.max(visibleCount, 1)\n\n  React.useEffect(() => {\n    const list = listRef.current\n    if (!list || typeof ResizeObserver === \"undefined\") return\n\n    const observer = new ResizeObserver((entries) => {\n      setHeights((current) => {\n        const next = { ...current }\n        for (const entry of entries) {\n          const id = (entry.target as HTMLElement).dataset.measure\n          if (id) next[id] = (entry.target as HTMLElement).offsetHeight\n        }\n        return next\n      })\n    })\n    for (const node of list.querySelectorAll(\"[data-measure]\")) {\n      observer.observe(node)\n    }\n    return () => observer.disconnect()\n  }, [items])\n\n  React.useEffect(() => {\n    if (!pinned) return\n\n    function onPointerDown(event: PointerEvent) {\n      if (!rootRef.current?.contains(event.target as Node)) setPinned(false)\n    }\n\n    document.addEventListener(\"pointerdown\", onPointerDown)\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown)\n  }, [pinned])\n\n  React.useEffect(() => {\n    const index = focusIndexRef.current\n    if (index === null) return\n    focusIndexRef.current = null\n\n    const target = items[Math.min(index, items.length - 1)]\n    const button = target ? dismissRefs.current.get(target.id) : null\n    if (button) button.focus()\n    else rootRef.current?.focus()\n  }, [items])\n\n  function dismiss(id: string, direction = 1) {\n    if (leaving[id]) return\n    const index = items.findIndex((item) => item.id === id)\n    const root = rootRef.current\n    if (root && root.contains(document.activeElement)) {\n      focusIndexRef.current = index\n    }\n\n    if (reducedMotion()) {\n      onDismiss(id)\n      return\n    }\n    setLeaving((current) => ({ ...current, [id]: direction }))\n    setTimeout(() => {\n      setLeaving((current) => {\n        const next = { ...current }\n        delete next[id]\n        return next\n      })\n      onDismiss(id)\n    }, LEAVE_MS)\n  }\n\n  const front = items[0] ? (heights[items[0].id] ?? 0) : 0\n  const offsets: number[] = []\n  let total = 0\n  for (const item of items) {\n    offsets.push(total)\n    total += (heights[item.id] ?? 0) + GAP\n  }\n  const height = expanded\n    ? Math.max(total - GAP, 0)\n    : front + PEEK * (Math.min(items.length, shown) - 1)\n\n  return (\n    <section\n      {...rootProps}\n      ref={(node) => {\n        rootRef.current = node\n        if (typeof forwardedRef === \"function\") forwardedRef(node)\n        else if (forwardedRef) forwardedRef.current = node\n      }}\n      tabIndex={-1}\n      aria-labelledby={headingId}\n      data-slot=\"notification-stack\"\n      data-state={expanded ? \"expanded\" : \"collapsed\"}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event)\n        if (event.pointerType === \"mouse\") setHovered(true)\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event)\n        setHovered(false)\n      }}\n      onFocus={(event) => {\n        onFocus?.(event)\n        if (event.target !== event.currentTarget) setFocusWithin(true)\n      }}\n      onBlur={(event) => {\n        onBlur?.(event)\n        const next = event.relatedTarget\n        if (!next || next === event.currentTarget) setFocusWithin(false)\n        else if (!event.currentTarget.contains(next)) setFocusWithin(false)\n      }}\n      onClick={(event) => {\n        onClick?.(event)\n        if ((event.target as HTMLElement).closest(\"button\")) return\n        if (listRef.current?.contains(event.target as Node)) setPinned(true)\n      }}\n      className={cn(\"w-full max-w-sm outline-none\", className)}\n    >\n      <div className=\"flex min-h-9 items-center gap-2 px-1 pb-2\">\n        <p id={headingId} className=\"text-sm font-semibold\">\n          {label}\n        </p>\n        {items.length ? (\n          <span className=\"bg-muted text-muted-foreground rounded-full px-1.5 text-xs tabular-nums\">\n            {items.length}\n          </span>\n        ) : null}\n        {onClearAll && items.length ? (\n          <button\n            type=\"button\"\n            onClick={() => {\n              focusIndexRef.current = 0\n              onClearAll()\n            }}\n            className=\"text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:ring-ring ml-auto min-h-8 rounded-full px-3 text-xs transition-colors duration-150 outline-none focus-visible:ring-2 motion-reduce:transition-none\"\n          >\n            {clearLabel}\n          </button>\n        ) : null}\n      </div>\n\n      {items.length ? (\n        <ol\n          ref={listRef}\n          className={cn(\"relative transition-[height]\", SPRING)}\n          style={{ height: height || undefined }}\n        >\n          {items.map((item, index) => {\n            const depth = Math.min(index, shown - 1)\n            const back = !expanded && index > 0\n            const hidden = !expanded && index >= shown\n            const gone = leaving[item.id]\n            const dx = drag?.id === item.id ? drag.dx : 0\n            const y = expanded ? offsets[index] : depth * PEEK\n            const scale = expanded ? 1 : 1 - depth * 0.05\n\n            return (\n              <li\n                key={item.id}\n                data-state={gone ? \"leaving\" : undefined}\n                aria-hidden={hidden || undefined}\n                className={cn(\n                  \"absolute inset-x-0 top-0 origin-bottom touch-pan-y transition-[transform,opacity,height]\",\n                  drag?.id === item.id ? \"transition-none\" : SPRING,\n                  hidden && \"pointer-events-none\"\n                )}\n                style={{\n                  zIndex: items.length - index,\n                  height: back && front ? front : undefined,\n                  opacity: gone || hidden ? 0 : 1 - Math.abs(dx) / (SWIPE * 3),\n                  transform: `translate3d(${gone ? `${gone * 100}%` : `${dx}px`}, ${y}px, 0) scale(${scale})`,\n                }}\n                onPointerDown={(event) => {\n                  if (event.pointerType === \"mouse\") return\n                  if ((event.target as HTMLElement).closest(\"button\")) return\n                  dragRef.current = {\n                    id: item.id,\n                    x: event.clientX,\n                    pointer: event.pointerId,\n                  }\n                }}\n                onPointerMove={(event) => {\n                  const start = dragRef.current\n                  if (!start || start.pointer !== event.pointerId) return\n                  const next = event.clientX - start.x\n                  if (!drag && Math.abs(next) < 8) return\n                  if (!drag)\n                    event.currentTarget.setPointerCapture(event.pointerId)\n                  setDrag({ id: item.id, dx: next })\n                }}\n                onPointerUp={(event) => {\n                  const start = dragRef.current\n                  dragRef.current = null\n                  if (!start || start.pointer !== event.pointerId) return\n                  const distance = drag?.id === item.id ? drag.dx : 0\n                  setDrag(null)\n                  if (Math.abs(distance) > SWIPE) {\n                    dismiss(item.id, Math.sign(distance))\n                  }\n                }}\n                onPointerCancel={() => {\n                  dragRef.current = null\n                  setDrag(null)\n                }}\n              >\n                <div\n                  className={cn(\n                    \"border-border bg-background text-foreground h-full overflow-hidden rounded-[var(--radius)] border shadow-sm\",\n                    back && \"shadow-none\"\n                  )}\n                >\n                  <div\n                    data-measure={item.id}\n                    className={cn(\n                      \"flex items-start gap-3 p-3 transition-opacity duration-200 motion-reduce:transition-none\",\n                      back ? \"opacity-0\" : \"opacity-100\"\n                    )}\n                  >\n                    {item.icon ? (\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"bg-muted text-muted-foreground grid size-8 shrink-0 place-items-center rounded-full\"\n                      >\n                        {item.icon}\n                      </span>\n                    ) : null}\n                    <div className=\"min-w-0 flex-1\">\n                      <div className=\"flex items-baseline gap-2\">\n                        <p className=\"min-w-0 flex-1 truncate text-sm font-medium\">\n                          {item.title}\n                        </p>\n                        {item.time ? (\n                          <span className=\"text-muted-foreground shrink-0 text-xs\">\n                            {item.time}\n                          </span>\n                        ) : null}\n                      </div>\n                      {item.description ? (\n                        <div className=\"text-muted-foreground mt-0.5 text-sm\">\n                          {item.description}\n                        </div>\n                      ) : null}\n                    </div>\n                    <button\n                      ref={(node) => {\n                        if (node) dismissRefs.current.set(item.id, node)\n                        else dismissRefs.current.delete(item.id)\n                      }}\n                      type=\"button\"\n                      aria-label={`${dismissLabel}: ${item.title}`}\n                      tabIndex={hidden ? -1 : undefined}\n                      onClick={() => dismiss(item.id)}\n                      className=\"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring -my-1 -mr-1 grid size-8 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2\"\n                    >\n                      <svg\n                        aria-hidden=\"true\"\n                        className=\"size-3.5\"\n                        fill=\"none\"\n                        viewBox=\"0 0 14 14\"\n                      >\n                        <path\n                          d=\"m3.5 3.5 7 7m0-7-7 7\"\n                          stroke=\"currentColor\"\n                          strokeLinecap=\"round\"\n                          strokeWidth=\"1.75\"\n                        />\n                      </svg>\n                    </button>\n                  </div>\n                </div>\n              </li>\n            )\n          })}\n        </ol>\n      ) : (\n        <p className=\"text-muted-foreground border-border rounded-[var(--radius)] border border-dashed px-3 py-6 text-center text-sm\">\n          {emptyMessage}\n        </p>\n      )}\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </section>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "notification",
    "list",
    "feedback",
    "motion"
  ],
  "type": "registry:ui"
}