{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-inbox",
  "title": "Notification Inbox",
  "description": "A bell with an unread count that opens notifications grouped by day, with read state, mark all as read and per-item actions.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "avatar",
    "button",
    "popover",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/notification-inbox.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  BellIcon,\n  CheckCheckIcon,\n  CheckIcon,\n  CircleDotIcon,\n} from \"lucide-react\"\n\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  countUnread,\n  formatAbsoluteTime,\n  formatTime,\n  formatUnreadCount,\n  groupByDay,\n  initialsOf,\n  setRead,\n  toDate,\n  unreadIds,\n  type InboxNotification,\n  type NotificationActor,\n  type NotificationGroup,\n} from \"@/registry/default/lib/notification-inbox.ts\"\nimport { cn } from \"@/registry/default/lib/utils\"\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@/registry/default/ui/avatar\"\nimport { Button, buttonVariants } from \"@/registry/default/ui/button\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/registry/default/ui/popover\"\n\nexport type { InboxNotification, NotificationActor, NotificationGroup }\n\nexport type NotificationAction<\n  T extends InboxNotification = InboxNotification,\n> = {\n  label: string\n  icon: React.ReactNode\n  onSelect: (notification: T) => void\n}\n\nexport interface NotificationItemProps extends InboxNotification {\n  locale?: string\n  className?: string\n}\n\nfunction NotificationItem({\n  title,\n  body,\n  createdAt,\n  read,\n  actor,\n  locale,\n  className,\n}: NotificationItemProps) {\n  const date = toDate(createdAt)\n\n  return (\n    <span\n      data-slot=\"notification-item\"\n      className={cn(\"flex min-w-0 flex-1 items-start gap-3\", className)}\n    >\n      {actor && (\n        <Avatar aria-hidden=\"true\" className=\"size-8\">\n          {actor.avatarUrl && <AvatarImage src={actor.avatarUrl} alt=\"\" />}\n          <AvatarFallback className=\"text-xs font-medium\">\n            {initialsOf(actor.name)}\n          </AvatarFallback>\n        </Avatar>\n      )}\n      <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <span\n          className={cn(\n            \"text-sm leading-snug break-words\",\n            read ? \"text-muted-foreground\" : \"font-medium text-foreground\"\n          )}\n        >\n          {title}\n        </span>\n        {body && (\n          <span className=\"line-clamp-2 text-xs break-words text-muted-foreground\">\n            {body}\n          </span>\n        )}\n        {date && (\n          <time\n            dateTime={date.toISOString()}\n            title={formatAbsoluteTime(date, locale)}\n            className=\"text-xs text-muted-foreground\"\n          >\n            {formatTime(date, locale)}\n          </time>\n        )}\n      </span>\n    </span>\n  )\n}\n\nexport interface NotificationInboxProps<\n  T extends InboxNotification = InboxNotification,\n> extends Omit<React.ComponentProps<\"button\">, \"children\" | \"type\"> {\n  items?: T[]\n  defaultItems?: T[]\n  onItemsChange?: (items: T[]) => void\n  onMarkRead?: (ids: string[]) => void\n  onMarkUnread?: (ids: string[]) => void\n  onMarkAllRead?: () => void\n  onItemClick?: (notification: T) => void\n  actions?: NotificationAction<T>[]\n  renderItem?: (notification: T) => React.ReactNode\n  open?: boolean\n  defaultOpen?: boolean\n  onOpenChange?: (open: boolean) => void\n  now?: Date\n  locale?: string\n  \"aria-label\"?: string\n}\n\nconst ITEM_LINK_SELECTOR = '[data-slot=\"notification-inbox-item-link\"]'\n\nfunction itemLinks(panel: HTMLElement) {\n  return Array.from(panel.querySelectorAll<HTMLElement>(ITEM_LINK_SELECTOR))\n}\n\nfunction NotificationInbox<T extends InboxNotification = InboxNotification>({\n  items: itemsProp,\n  defaultItems = [],\n  onItemsChange,\n  onMarkRead,\n  onMarkUnread,\n  onMarkAllRead,\n  onItemClick,\n  actions = [],\n  renderItem,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  now,\n  locale,\n  \"aria-label\": ariaLabel = \"Notifications\",\n  className,\n  ...props\n}: NotificationInboxProps<T>) {\n  const headingId = React.useId()\n  const panelRef = React.useRef<HTMLDivElement>(null)\n  const restoreFocusIndex = React.useRef<number | null>(null)\n\n  const { value: items, setValue: setItems } = useControllableState<T[]>({\n    value: itemsProp,\n    defaultValue: defaultItems,\n    onChange: onItemsChange,\n  })\n  const { value: open, setValue: setOpen } = useControllableState<boolean>({\n    value: openProp,\n    defaultValue: defaultOpen,\n    onChange: onOpenChange,\n  })\n\n  const unread = countUnread(items)\n  const groups = groupByDay(items, now ?? new Date(), locale)\n  const positions = new Map(\n    groups\n      .flatMap((group) => group.items)\n      .map((item, index) => [item.id, index])\n  )\n\n  React.useLayoutEffect(() => {\n    const index = restoreFocusIndex.current\n    if (index === null) return\n    restoreFocusIndex.current = null\n    const panel = panelRef.current\n    if (!panel || panel.contains(document.activeElement)) return\n    const links = itemLinks(panel)\n    ;(links[Math.min(index, links.length - 1)] ?? panel).focus()\n  })\n\n  function toggleRead(notification: T) {\n    const read = !notification.read\n    setItems(setRead(items, [notification.id], read))\n    if (read) onMarkRead?.([notification.id])\n    else onMarkUnread?.([notification.id])\n  }\n\n  function markAllRead() {\n    if (unread === 0) return\n    setItems(setRead(items, unreadIds(items), true))\n    onMarkAllRead?.()\n  }\n\n  function openItem(notification: T) {\n    if (!notification.read) {\n      setItems(setRead(items, [notification.id], true))\n      onMarkRead?.([notification.id])\n    }\n    onItemClick?.(notification)\n    setOpen(false)\n  }\n\n  function runAction(\n    action: NotificationAction<T>,\n    notification: T,\n    index: number\n  ) {\n    restoreFocusIndex.current = index\n    action.onSelect(notification)\n  }\n\n  function handleListKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return\n    const panel = panelRef.current\n    if (!panel) return\n    const links = itemLinks(panel)\n    const target = event.target as HTMLElement\n    const current = links.findIndex((link) =>\n      link.closest(\"li\")?.contains(target)\n    )\n    if (current === -1) return\n    const next = {\n      ArrowDown: Math.min(current + 1, links.length - 1),\n      ArrowUp: Math.max(current - 1, 0),\n      Home: 0,\n      End: links.length - 1,\n    }[event.key]!\n    event.preventDefault()\n    links[next]?.focus()\n  }\n\n  const triggerLabel = unread > 0 ? `${ariaLabel}, ${unread} unread` : ariaLabel\n\n  return (\n    <>\n      <Popover open={open} onOpenChange={setOpen}>\n        <PopoverTrigger\n          type=\"button\"\n          data-slot=\"notification-inbox-trigger\"\n          aria-label={triggerLabel}\n          className={cn(\n            buttonVariants({ variant: \"ghost\", size: \"icon\" }),\n            \"relative\",\n            className\n          )}\n          {...props}\n        >\n          <BellIcon aria-hidden=\"true\" />\n          {unread > 0 && (\n            <span\n              aria-hidden=\"true\"\n              data-slot=\"notification-inbox-count\"\n              className=\"absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none font-medium text-primary-foreground tabular-nums\"\n            >\n              {formatUnreadCount(unread)}\n            </span>\n          )}\n        </PopoverTrigger>\n        <PopoverContent\n          align=\"end\"\n          aria-labelledby={headingId}\n          className=\"flex max-h-[calc(var(--radix-popover-content-available-height,var(--available-height,100vh))-0.5rem)] w-[min(24rem,calc(100vw-2rem))] flex-col p-0\"\n        >\n          <div\n            ref={panelRef}\n            tabIndex={-1}\n            data-slot=\"notification-inbox\"\n            className=\"flex min-h-0 flex-col outline-none\"\n          >\n            <div className=\"flex items-center justify-between gap-2 border-b px-4 py-2\">\n              <h2 id={headingId} className=\"text-sm font-semibold\">\n                {ariaLabel}\n              </h2>\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"h-7 px-2 text-xs aria-disabled:cursor-default aria-disabled:opacity-50 aria-disabled:hover:bg-transparent\"\n                aria-disabled={unread === 0 || undefined}\n                onClick={markAllRead}\n              >\n                <CheckCheckIcon aria-hidden=\"true\" className=\"size-3.5\" />\n                Mark all as read\n              </Button>\n            </div>\n            {items.length === 0 ? (\n              <p className=\"px-4 py-10 text-center text-sm text-muted-foreground\">\n                No notifications yet.\n              </p>\n            ) : (\n              <div\n                className=\"max-h-[min(28rem,70vh)] min-h-0 overflow-y-auto overscroll-contain py-1\"\n                onKeyDown={handleListKeyDown}\n              >\n                {groups.map((group) => (\n                  <section\n                    key={group.key}\n                    aria-labelledby={`${headingId}-${group.key}`}\n                  >\n                    <h3\n                      id={`${headingId}-${group.key}`}\n                      className=\"px-4 pt-2 pb-1 text-xs font-medium text-muted-foreground\"\n                    >\n                      {group.label}\n                    </h3>\n                    <ul className=\"px-1\">\n                      {group.items.map((notification) => (\n                        <InboxRow\n                          key={notification.id}\n                          notification={notification}\n                          locale={locale}\n                          actions={actions}\n                          renderItem={renderItem}\n                          onOpen={() => openItem(notification)}\n                          onToggleRead={() => toggleRead(notification)}\n                          onAction={(action) =>\n                            runAction(\n                              action,\n                              notification,\n                              positions.get(notification.id) ?? 0\n                            )\n                          }\n                        />\n                      ))}\n                    </ul>\n                  </section>\n                ))}\n              </div>\n            )}\n          </div>\n        </PopoverContent>\n      </Popover>\n      <span role=\"status\" className=\"sr-only\">\n        {unread > 0\n          ? `${unread} unread ${unread === 1 ? \"notification\" : \"notifications\"}`\n          : \"\"}\n      </span>\n    </>\n  )\n}\n\nfunction InboxRow<T extends InboxNotification>({\n  notification,\n  locale,\n  actions,\n  renderItem,\n  onOpen,\n  onToggleRead,\n  onAction,\n}: {\n  notification: T\n  locale?: string\n  actions: NotificationAction<T>[]\n  renderItem?: (notification: T) => React.ReactNode\n  onOpen: () => void\n  onToggleRead: () => void\n  onAction: (action: NotificationAction<T>) => void\n}) {\n  const { read, title, href } = notification\n  const content = (\n    <>\n      {!read && <span className=\"sr-only\">Unread: </span>}\n      {renderItem ? (\n        renderItem(notification)\n      ) : (\n        <NotificationItem {...notification} locale={locale} />\n      )}\n    </>\n  )\n  const linkClassName =\n    \"flex min-w-0 flex-1 items-start rounded-md p-2 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n  const actionClassName =\n    \"size-7 text-muted-foreground hover:text-foreground pointer-coarse:size-11\"\n\n  return (\n    <li\n      data-slot=\"notification-inbox-item\"\n      data-read={read ? \"\" : undefined}\n      className=\"group/item flex items-start gap-1 rounded-md pl-2 transition-colors hover:bg-accent/60 has-focus-visible:bg-accent/60\"\n    >\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"mt-4 size-2 shrink-0 rounded-full\",\n          read ? \"bg-transparent\" : \"bg-primary\"\n        )}\n      />\n      {href ? (\n        <a\n          href={href}\n          data-slot=\"notification-inbox-item-link\"\n          className={linkClassName}\n          onClick={onOpen}\n        >\n          {content}\n        </a>\n      ) : (\n        <button\n          type=\"button\"\n          data-slot=\"notification-inbox-item-link\"\n          className={linkClassName}\n          onClick={onOpen}\n        >\n          {content}\n        </button>\n      )}\n      <div className=\"flex shrink-0 items-center gap-0.5 py-1.5 pr-1 opacity-0 transition-opacity group-focus-within/item:opacity-100 group-hover/item:opacity-100 pointer-coarse:gap-2 pointer-coarse:opacity-100\">\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className={actionClassName}\n          aria-label={`Mark as ${read ? \"unread\" : \"read\"}: ${title}`}\n          title={read ? \"Mark as unread\" : \"Mark as read\"}\n          onClick={onToggleRead}\n        >\n          {read ? (\n            <CircleDotIcon aria-hidden=\"true\" className=\"size-3.5\" />\n          ) : (\n            <CheckIcon aria-hidden=\"true\" className=\"size-3.5\" />\n          )}\n        </Button>\n        {actions.map((action) => (\n          <Button\n            key={action.label}\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            className={actionClassName}\n            aria-label={`${action.label}: ${title}`}\n            title={action.label}\n            onClick={() => onAction(action)}\n          >\n            {action.icon}\n          </Button>\n        ))}\n      </div>\n    </li>\n  )\n}\n\nexport { NotificationInbox, NotificationItem }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/notification-inbox.ts",
      "content": "export type NotificationActor = {\n  name: string\n  avatarUrl?: string\n}\n\nexport type InboxNotification = {\n  id: string\n  title: string\n  body?: string\n  createdAt: Date | string\n  read?: boolean\n  href?: string\n  actor?: NotificationActor\n}\n\nexport type NotificationGroup<T extends InboxNotification = InboxNotification> =\n  {\n    key: string\n    label: string\n    items: T[]\n  }\n\nconst DAY_MS = 24 * 60 * 60 * 1000\nconst UNDATED_KEY = \"undated\"\n\nexport function toDate(value: Date | string | undefined | null) {\n  if (value === undefined || value === null || value === \"\") return null\n  const date = value instanceof Date ? value : new Date(value)\n  return Number.isNaN(date.getTime()) ? null : date\n}\n\nfunction startOfDay(date: Date) {\n  return new Date(date.getFullYear(), date.getMonth(), date.getDate())\n}\n\nfunction dayKey(date: Date) {\n  const month = String(date.getMonth() + 1).padStart(2, \"0\")\n  const day = String(date.getDate()).padStart(2, \"0\")\n  return `${date.getFullYear()}-${month}-${day}`\n}\n\nfunction capitalize(text: string, locale?: string) {\n  return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1)\n}\n\nexport function formatDayLabel(date: Date, now: Date, locale?: string) {\n  const daysAgo = Math.round(\n    (startOfDay(now).getTime() - startOfDay(date).getTime()) / DAY_MS\n  )\n  if (Math.abs(daysAgo) <= 1) {\n    const relative = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" })\n    return capitalize(relative.format(-daysAgo, \"day\"), locale)\n  }\n  return new Intl.DateTimeFormat(locale, {\n    weekday: \"long\",\n    month: \"long\",\n    day: \"numeric\",\n    ...(date.getFullYear() === now.getFullYear() ? {} : { year: \"numeric\" }),\n  }).format(date)\n}\n\nexport function formatTime(date: Date, locale?: string) {\n  return new Intl.DateTimeFormat(locale, { timeStyle: \"short\" }).format(date)\n}\n\nexport function formatAbsoluteTime(date: Date, locale?: string) {\n  return new Intl.DateTimeFormat(locale, {\n    dateStyle: \"medium\",\n    timeStyle: \"short\",\n  }).format(date)\n}\n\nexport function groupByDay<T extends InboxNotification>(\n  items: T[],\n  now: Date,\n  locale?: string,\n  undatedLabel = \"Earlier\"\n): NotificationGroup<T>[] {\n  const dated = items\n    .map((item, index) => ({ item, index, date: toDate(item.createdAt) }))\n    .sort((a, b) => {\n      if (a.date && b.date) {\n        return b.date.getTime() - a.date.getTime() || a.index - b.index\n      }\n      if (a.date) return -1\n      if (b.date) return 1\n      return a.index - b.index\n    })\n\n  const groups: NotificationGroup<T>[] = []\n  for (const { item, date } of dated) {\n    const key = date ? dayKey(date) : UNDATED_KEY\n    let group = groups[groups.length - 1]\n    if (group?.key !== key) {\n      group = {\n        key,\n        label: date ? formatDayLabel(date, now, locale) : undatedLabel,\n        items: [],\n      }\n      groups.push(group)\n    }\n    group.items.push(item)\n  }\n  return groups\n}\n\nexport function countUnread(items: InboxNotification[]) {\n  return items.reduce((total, item) => total + (item.read ? 0 : 1), 0)\n}\n\nexport function unreadIds(items: InboxNotification[]) {\n  return items.filter((item) => !item.read).map((item) => item.id)\n}\n\nexport function setRead<T extends InboxNotification>(\n  items: T[],\n  ids: string[],\n  read: boolean\n): T[] {\n  const targets = new Set(ids)\n  let changed = false\n  const next = items.map((item) => {\n    if (!targets.has(item.id) || Boolean(item.read) === read) return item\n    changed = true\n    return { ...item, read }\n  })\n  return changed ? next : items\n}\n\nexport function formatUnreadCount(count: number, max = 99) {\n  return count > max ? `${max}+` : String(count)\n}\n\nexport function initialsOf(name: string) {\n  const words = name.trim().split(/\\s+/).filter(Boolean)\n  if (words.length === 0) return \"?\"\n  const letters =\n    words.length === 1\n      ? [...words[0]].slice(0, 2)\n      : [[...words[0]][0], [...words[words.length - 1]][0]]\n  return letters.join(\"\").toUpperCase()\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}