{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-center",
  "title": "Notification Center",
  "author": "@fma-ds/ds",
  "description": "Notifications in the header and on their own page, driven by props and callbacks (no server state).\n\n- **`NotificationMenu`**: the bell — an icon `GlanceTrigger` whose `GlanceIndicator` shows the unread count (also in the button's name). It opens a panel with the recent items, \"Mark all as read\" (`onMarkAllAsRead`), loading and empty states and a \"View all\" link (`viewAllHref`).\n- **`NotificationList`**: every notification as cards with a read/unread `Badge`, for a page paired with `Pagination`.\n- **`NotificationItem`**: an unread dot (or an `icon`), the title — a link when it has `href`, calling `onOpen` — the message, the date (formatted in the `I18nProvider` locale) and icon buttons for `onMarkAsRead` and `onDelete`, described by the title.\n\nUrgent notifications are a `ConfirmDialog` opened with `useDialogPromise`.\n\nBuilt-in text is in English; override it with `i18n.labels`.",
  "dependencies": [
    "cn@^0.5.0",
    "lucide-react@^1.61.0",
    "react-aria-components@^1.22.1"
  ],
  "registryDependencies": [
    "@fma-ds/badge",
    "@fma-ds/button",
    "@fma-ds/empty",
    "@fma-ds/glance",
    "@fma-ds/skeleton",
    "@fma-ds/tooltip"
  ],
  "files": [
    {
      "path": "src/blocks/notification-center/notification-center.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"cn\";\nimport { BellIcon, CheckCheckIcon, CheckIcon, Trash2Icon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Link, useLocale } from \"react-aria-components\";\nimport { Badge } from \"@/core/badge/badge\";\nimport { Button, LinkButton } from \"@/core/button/button\";\nimport { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from \"@/core/empty/empty\";\nimport {\n  Glance,\n  GlanceContent,\n  GlanceFooter,\n  GlanceHeader,\n  GlanceIndicator,\n  GlanceTitle,\n  GlanceTrigger,\n} from \"@/core/glance/glance\";\nimport { Skeleton } from \"@/core/skeleton/skeleton\";\nimport { Tooltip, TooltipTrigger } from \"@/core/tooltip/tooltip\";\n\ntype NotificationData = {\n  id: string;\n  title: React.ReactNode;\n  message?: React.ReactNode;\n  date?: Date | null;\n  isRead?: boolean;\n  /** Where the notification leads; makes the title a link. */\n  href?: string;\n  /** Replaces the unread dot with an icon (e.g. an `IconTile`). */\n  icon?: React.ReactNode;\n};\n\ntype NotificationLabels = {\n  /** The panel and page list name. */\n  title: string;\n  /** The bell's accessible name. */\n  trigger: (unread: number) => string;\n  unread: string;\n  read: string;\n  markAsRead: string;\n  markAllAsRead: string;\n  delete: string;\n  empty: string;\n  emptyDescription: string;\n  loading: string;\n  viewAll: string;\n  formatDate: (date: Date, locale: string) => string;\n};\n\ntype NotificationI18n = { labels?: Partial<NotificationLabels> };\n\nconst defaultNotificationLabels: NotificationLabels = {\n  title: \"Notifications\",\n  trigger: (unread) =>\n    unread > 0 ? `Notifications, ${unread} unread` : \"Notifications, none unread\",\n  unread: \"Unread\",\n  read: \"Read\",\n  markAsRead: \"Mark as read\",\n  markAllAsRead: \"Mark all as read\",\n  delete: \"Delete\",\n  empty: \"No notifications\",\n  emptyDescription: \"You're all caught up.\",\n  loading: \"Loading notifications…\",\n  viewAll: \"View all notifications\",\n  formatDate: (date, locale) =>\n    new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeStyle: \"short\" }).format(date),\n};\n\nfunction useNotificationLabels(i18n?: NotificationI18n): NotificationLabels {\n  const overrides = i18n?.labels;\n  return React.useMemo(() => ({ ...defaultNotificationLabels, ...overrides }), [overrides]);\n}\n\ntype NotificationHandlers = {\n  onMarkAsRead?: (id: string) => void;\n  onDelete?: (id: string) => void;\n  /** Called when the title link is followed (e.g. to mark it as read). */\n  onOpen?: (notification: NotificationData) => void;\n};\n\n/** An icon button with its label in a tooltip, tied to the notification's title. */\nfunction NotificationAction({\n  label,\n  describedBy,\n  onPress,\n  children,\n}: {\n  label: string;\n  describedBy: string;\n  onPress: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <TooltipTrigger>\n      <Button\n        variant=\"ghost\"\n        size=\"icon-sm\"\n        aria-label={label}\n        aria-describedby={describedBy}\n        onPress={onPress}\n      >\n        {children}\n      </Button>\n      <Tooltip>{label}</Tooltip>\n    </TooltipTrigger>\n  );\n}\n\n/**\n * One notification: unread dot (or icon), title (a link when it has `href`),\n * message, date and the read/delete actions. `variant=\"card\"` is the roomier\n * page layout, with a status badge and the full message.\n */\nfunction NotificationItem({\n  notification,\n  variant = \"compact\",\n  i18n,\n  onMarkAsRead,\n  onDelete,\n  onOpen,\n  className,\n}: NotificationHandlers & {\n  notification: NotificationData;\n  variant?: \"compact\" | \"card\";\n  i18n?: NotificationI18n;\n  className?: string;\n}) {\n  const labels = useNotificationLabels(i18n);\n  const { locale } = useLocale();\n  const titleId = React.useId();\n  const { id, title, message, date, isRead = false, href, icon } = notification;\n  const isCard = variant === \"card\";\n\n  return (\n    <li\n      data-slot=\"notification-item\"\n      data-variant={variant}\n      data-read={isRead || undefined}\n      className={cn(\n        \"flex min-w-0 items-start gap-3\",\n        isCard ? \"rounded-xl border bg-card p-4 text-card-foreground\" : \"rounded-lg p-2\",\n        className,\n      )}\n    >\n      {icon ? (\n        <span data-slot=\"notification-media\" aria-hidden=\"true\" className=\"relative shrink-0\">\n          {icon}\n          {!isRead && (\n            <span\n              data-slot=\"notification-dot\"\n              className={cn(\n                \"absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary ring-2\",\n                isCard ? \"ring-card\" : \"ring-popover\",\n              )}\n            />\n          )}\n        </span>\n      ) : (\n        <span\n          data-slot=\"notification-dot\"\n          aria-hidden=\"true\"\n          className={cn(\"mt-1.5 size-2 shrink-0 rounded-full\", !isRead && \"bg-primary\")}\n        />\n      )}\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n        <div\n          className={cn(\n            \"flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1\",\n            isCard && \"justify-between\",\n          )}\n        >\n          <span\n            id={titleId}\n            data-slot=\"notification-title\"\n            className={cn(\n              \"min-w-0 leading-snug\",\n              isCard ? \"text-base\" : \"text-sm\",\n              isRead ? \"text-muted-foreground\" : \"font-medium\",\n            )}\n          >\n            {href ? (\n              <Link\n                href={href}\n                onPress={() => onOpen?.(notification)}\n                className=\"rounded-sm underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50\"\n              >\n                {title}\n              </Link>\n            ) : (\n              title\n            )}\n          </span>\n          {isCard ? (\n            <Badge variant={isRead ? \"muted-dot\" : \"primary-dot\"}>\n              {isRead ? labels.read : labels.unread}\n            </Badge>\n          ) : (\n            !isRead && <span className=\"sr-only\">{labels.unread}</span>\n          )}\n        </div>\n        {message && (\n          <div\n            data-slot=\"notification-message\"\n            className={cn(\"text-sm text-muted-foreground\", !isCard && \"line-clamp-2\")}\n          >\n            {message}\n          </div>\n        )}\n        {date && (\n          <time dateTime={date.toISOString()} className=\"text-xs text-muted-foreground\">\n            {labels.formatDate(date, locale)}\n          </time>\n        )}\n      </div>\n      {(onMarkAsRead || onDelete) && (\n        <div data-slot=\"notification-actions\" className=\"-my-1 flex shrink-0 items-center\">\n          {onMarkAsRead && !isRead && (\n            <NotificationAction\n              label={labels.markAsRead}\n              describedBy={titleId}\n              onPress={() => onMarkAsRead(id)}\n            >\n              <CheckIcon />\n            </NotificationAction>\n          )}\n          {onDelete && (\n            <NotificationAction\n              label={labels.delete}\n              describedBy={titleId}\n              onPress={() => onDelete(id)}\n            >\n              <Trash2Icon />\n            </NotificationAction>\n          )}\n        </div>\n      )}\n    </li>\n  );\n}\n\nfunction NotificationsEmpty({\n  labels,\n  className,\n}: {\n  labels: NotificationLabels;\n  className?: string;\n}) {\n  return (\n    <Empty data-slot=\"notification-empty\" className={className}>\n      <EmptyHeader>\n        <EmptyMedia variant=\"icon\">\n          <CheckCheckIcon />\n        </EmptyMedia>\n        <EmptyTitle>{labels.empty}</EmptyTitle>\n        <EmptyDescription>{labels.emptyDescription}</EmptyDescription>\n      </EmptyHeader>\n    </Empty>\n  );\n}\n\nfunction NotificationsLoading({\n  labels,\n  count,\n  variant,\n}: {\n  labels: NotificationLabels;\n  count: number;\n  variant: \"compact\" | \"card\";\n}) {\n  return (\n    <div\n      role=\"status\"\n      data-slot=\"notification-loading\"\n      className={cn(\"flex flex-col\", variant === \"card\" ? \"gap-3\" : \"gap-1\")}\n    >\n      <span className=\"sr-only\">{labels.loading}</span>\n      {Array.from({ length: count }, (_, index) => (\n        <div\n          // biome-ignore lint/suspicious/noArrayIndexKey: static placeholders\n          key={index}\n          aria-hidden=\"true\"\n          className={cn(\n            \"flex gap-3\",\n            variant === \"card\" ? \"rounded-xl border p-4\" : \"rounded-lg p-2\",\n          )}\n        >\n          <Skeleton className=\"mt-1 size-2 rounded-full\" />\n          <div className=\"flex flex-1 flex-col gap-2\">\n            <Skeleton className=\"h-4 w-2/3\" />\n            <Skeleton className=\"h-3 w-full\" />\n            <Skeleton className=\"h-3 w-1/3\" />\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n\ntype NotificationMenuProps = NotificationHandlers & {\n  notifications: NotificationData[];\n  /** Defaults to the unread items in `notifications`; pass the server's total if it pages. */\n  unreadCount?: number;\n  isLoading?: boolean;\n  /** Shows \"Mark all as read\" in the panel header while there are unread items. */\n  onMarkAllAsRead?: () => void;\n  /** The page with every notification; shows the \"View all\" link in the footer. */\n  viewAllHref?: string;\n  isOpen?: boolean;\n  onOpenChange?: (isOpen: boolean) => void;\n  i18n?: NotificationI18n;\n  className?: string;\n};\n\n/**\n * The header bell: an icon `GlanceTrigger` with the unread count, opening a\n * panel with the recent notifications, their actions, \"Mark all as read\" and\n * a link to the full page.\n */\nfunction NotificationMenu({\n  notifications,\n  unreadCount,\n  isLoading = false,\n  onMarkAsRead,\n  onMarkAllAsRead,\n  onDelete,\n  onOpen,\n  viewAllHref,\n  isOpen,\n  onOpenChange,\n  i18n,\n  className,\n}: NotificationMenuProps) {\n  const labels = useNotificationLabels(i18n);\n  const unread = unreadCount ?? notifications.filter((item) => !item.isRead).length;\n\n  return (\n    <Glance isOpen={isOpen} onOpenChange={onOpenChange}>\n      <GlanceTrigger\n        size=\"icon\"\n        aria-label={labels.trigger(unread)}\n        data-slot=\"notification-trigger\"\n        className={className}\n      >\n        <BellIcon />\n        {unread > 0 && <GlanceIndicator>{unread > 99 ? \"99+\" : unread}</GlanceIndicator>}\n      </GlanceTrigger>\n      <GlanceContent data-slot=\"notification-menu\" className=\"w-[min(24rem,calc(100vw-2rem))]\">\n        {({ close }) => (\n          <>\n            <GlanceHeader className=\"items-center justify-between\">\n              <GlanceTitle>{labels.title}</GlanceTitle>\n              {onMarkAllAsRead && unread > 0 && (\n                <Button variant=\"ghost\" size=\"xs\" onPress={onMarkAllAsRead}>\n                  <CheckCheckIcon data-icon=\"inline-start\" />\n                  {labels.markAllAsRead}\n                </Button>\n              )}\n            </GlanceHeader>\n            <div className=\"-mx-1 max-h-[min(28rem,60vh)] min-h-0 overflow-y-auto px-1\">\n              {isLoading ? (\n                <NotificationsLoading labels={labels} count={3} variant=\"compact\" />\n              ) : notifications.length === 0 ? (\n                <NotificationsEmpty labels={labels} className=\"p-4\" />\n              ) : (\n                <ul aria-label={labels.title} className=\"flex flex-col gap-1\">\n                  {notifications.map((notification) => (\n                    <NotificationItem\n                      key={notification.id}\n                      notification={notification}\n                      i18n={i18n}\n                      onMarkAsRead={onMarkAsRead}\n                      onDelete={onDelete}\n                      onOpen={(item) => {\n                        onOpen?.(item);\n                        close();\n                      }}\n                    />\n                  ))}\n                </ul>\n              )}\n            </div>\n            {viewAllHref && (\n              <GlanceFooter>\n                <LinkButton href={viewAllHref} variant=\"outline\" onPress={close}>\n                  {labels.viewAll}\n                </LinkButton>\n              </GlanceFooter>\n            )}\n          </>\n        )}\n      </GlanceContent>\n    </Glance>\n  );\n}\n\ntype NotificationListProps = NotificationHandlers & {\n  notifications: NotificationData[];\n  isLoading?: boolean;\n  /** How many placeholders to show while loading. */\n  loadingCount?: number;\n  i18n?: NotificationI18n;\n  className?: string;\n};\n\n/** Every notification as cards, for a dedicated page; pair it with `Pagination`. */\nfunction NotificationList({\n  notifications,\n  isLoading = false,\n  loadingCount = 3,\n  onMarkAsRead,\n  onDelete,\n  onOpen,\n  i18n,\n  className,\n}: NotificationListProps) {\n  const labels = useNotificationLabels(i18n);\n\n  if (isLoading) {\n    return <NotificationsLoading labels={labels} count={loadingCount} variant=\"card\" />;\n  }\n  if (notifications.length === 0) {\n    return <NotificationsEmpty labels={labels} className={cn(\"border\", className)} />;\n  }\n  return (\n    <ul\n      aria-label={labels.title}\n      data-slot=\"notification-list\"\n      className={cn(\"flex flex-col gap-3\", className)}\n    >\n      {notifications.map((notification) => (\n        <NotificationItem\n          key={notification.id}\n          variant=\"card\"\n          notification={notification}\n          i18n={i18n}\n          onMarkAsRead={onMarkAsRead}\n          onDelete={onDelete}\n          onOpen={onOpen}\n        />\n      ))}\n    </ul>\n  );\n}\n\nexport type {\n  NotificationData,\n  NotificationI18n,\n  NotificationLabels,\n  NotificationListProps,\n  NotificationMenuProps,\n};\nexport { defaultNotificationLabels, NotificationItem, NotificationList, NotificationMenu };\n",
      "type": "registry:component",
      "target": "components/fma-ds/notification-center/notification-center.tsx"
    }
  ],
  "meta": {
    "status": "experimental",
    "source": "original",
    "license": "MIT",
    "tags": [
      "block",
      "notifications",
      "header",
      "popover",
      "list"
    ],
    "contentHash": "4ba2b130b1df"
  },
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}