{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-25",
  "title": "Bell with filtered inbox",
  "description": "A bell that opens the inbox, with All, Mentions and Invites tabs that carry their unread counts.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/animated-size.json",
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/popover.json",
    "https://ui.ndekocode.dev/r/tabs.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/notification/notification-25.tsx",
      "content": "'use client';\n\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  type Variants,\n} from 'motion/react';\nimport { useState } from 'react';\n\nimport { BellIcon, CheckCheckIcon } from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/registry/ndeko/ui/popover';\nimport { Tabs, TabsList, TabsTab } from '@/registry/ndeko/ui/tabs';\n\nimport { PEOPLE, type Person } from './_parts/people';\nimport { SenderAvatar, type NotificationKind } from './_parts/sender-avatar';\nimport { UnreadDot } from './_parts/unread-dot';\n\ntype InboxTab = 'all' | 'mentions' | 'invites';\n\ntype BellEntry = {\n  id: string;\n  kind: NotificationKind;\n  sender: Person;\n  verb: string;\n  target: string;\n  age: string;\n  isUnread: boolean;\n};\n\nconst ENTRIES: BellEntry[] = [\n  {\n    id: 'mention-1',\n    kind: 'mention',\n    sender: PEOPLE.sana,\n    verb: 'mentioned you in',\n    target: 'Booking calendar skips a week',\n    age: '4m',\n    isUnread: true,\n  },\n  {\n    id: 'invite-1',\n    kind: 'invite',\n    sender: PEOPLE.amara,\n    verb: 'invited you to',\n    target: 'Atlas Redesign',\n    age: '20m',\n    isUnread: true,\n  },\n  {\n    id: 'comment-1',\n    kind: 'comment',\n    sender: PEOPLE.leo,\n    verb: 'commented on',\n    target: 'Offline drafts for field notes',\n    age: '1h',\n    isUnread: true,\n  },\n  {\n    id: 'mention-2',\n    kind: 'mention',\n    sender: PEOPLE.tomas,\n    verb: 'mentioned you in',\n    target: 'Expiry of shared links',\n    age: '3h',\n    isUnread: false,\n  },\n  {\n    id: 'assigned-1',\n    kind: 'assigned',\n    sender: PEOPLE.mei,\n    verb: 'assigned you',\n    target: 'Copy for the weekly digest',\n    age: 'Yesterday',\n    isUnread: false,\n  },\n  {\n    id: 'invite-2',\n    kind: 'invite',\n    sender: PEOPLE.jonas,\n    verb: 'invited you to',\n    target: 'Platform guild',\n    age: 'Mon',\n    isUnread: false,\n  },\n];\n\nconst TABS: { value: InboxTab; label: string }[] = [\n  { value: 'all', label: 'All' },\n  { value: 'mentions', label: 'Mentions' },\n  { value: 'invites', label: 'Invites' },\n];\n\nconst FADE_TRANSITION: Transition = {\n  duration: 0.15,\n  ease: [0.23, 1, 0.32, 1],\n};\n\nconst FADE_VARIANTS: Variants = {\n  enter: { opacity: 0 },\n  visible: { opacity: 1 },\n  exit: { opacity: 0 },\n};\n\nfunction isInboxTab(value: unknown): value is InboxTab {\n  return TABS.some((tab) => tab.value === value);\n}\n\nfunction belongsTo(entry: BellEntry, tab: InboxTab) {\n  if (tab === 'mentions') {\n    return entry.kind === 'mention';\n  }\n  if (tab === 'invites') {\n    return entry.kind === 'invite';\n  }\n  return true;\n}\n\nfunction entriesOf(entries: BellEntry[], tab: InboxTab) {\n  return entries.filter((entry) => belongsTo(entry, tab));\n}\n\nfunction unreadCountOf(entries: BellEntry[]) {\n  return entries.filter((entry) => entry.isUnread).length;\n}\n\nfunction bellLabelOf(unreadCount: number) {\n  if (unreadCount === 0) {\n    return 'Notifications, nothing unread';\n  }\n  if (unreadCount === 1) {\n    return 'Notifications, 1 unread';\n  }\n  return `Notifications, ${unreadCount} unread`;\n}\n\nfunction tabLabelOf(label: string, unreadCount: number) {\n  if (unreadCount === 0) {\n    return label;\n  }\n  return `${label}, ${unreadCount} unread`;\n}\n\nfunction withRead(entries: BellEntry[], entryId: string) {\n  return entries.map((entry) => {\n    if (entry.id !== entryId) {\n      return entry;\n    }\n    return { ...entry, isUnread: false };\n  });\n}\n\nfunction allRead(entries: BellEntry[]) {\n  return entries.map((entry) => ({ ...entry, isUnread: false }));\n}\n\nfunction BellRow({\n  entry,\n  onOpen,\n}: {\n  entry: BellEntry;\n  onOpen: (entryId: string) => void;\n}) {\n  return (\n    <li>\n      <button\n        type='button'\n        onClick={() => onOpen(entry.id)}\n        className='hover:bg-foreground/[0.03] focus-visible:ring-ring/25 relative flex w-full cursor-pointer items-start gap-3 rounded-lg py-2 ps-4 pe-2.5 text-left outline-none focus-visible:ring-[3px]'\n      >\n        <UnreadDot\n          isUnread={entry.isUnread}\n          className='absolute start-1.5 top-[1.15rem]'\n        />\n        <SenderAvatar\n          person={entry.sender}\n          kind={entry.kind}\n          surfaceClassName='bg-popover'\n        />\n        <span className='min-w-0 flex-1'>\n          <span\n            className={cn(\n              'block text-[13px] leading-5 transition-colors duration-200',\n              !entry.isUnread && 'text-muted-foreground'\n            )}\n          >\n            <span className='font-medium'>{entry.sender.name}</span>{' '}\n            <span className='text-muted-foreground'>{entry.verb}</span>{' '}\n            <span className='font-medium'>{entry.target}</span>\n          </span>\n          <span className='text-muted-foreground block text-xs tabular-nums'>\n            {entry.age}\n            {entry.isUnread && <span className='sr-only'>, unread</span>}\n          </span>\n        </span>\n      </button>\n    </li>\n  );\n}\n\ntype InboxPanelProps = {\n  entries: BellEntry[];\n  tab: InboxTab;\n  onTabChange: (tab: InboxTab) => void;\n  onOpen: (entryId: string) => void;\n  onMarkAllRead: () => void;\n};\n\n/** The content of the bell: one component, drawn inline in the preview and inside the real popover. */\nfunction InboxPanel({\n  entries,\n  tab,\n  onTabChange,\n  onOpen,\n  onMarkAllRead,\n}: InboxPanelProps) {\n  const hasUnread = unreadCountOf(entries) > 0;\n  const visibleEntries = entriesOf(entries, tab);\n\n  function changeTab(value: unknown) {\n    if (isInboxTab(value)) {\n      onTabChange(value);\n    }\n  }\n\n  return (\n    <div className='flex flex-col'>\n      <div className='flex h-11 items-center justify-between gap-2 ps-4 pe-2'>\n        <p className='text-[13px] font-semibold'>Notifications</p>\n        <Button\n          variant='ghost'\n          size='sm'\n          disabled={!hasUnread}\n          onClick={onMarkAllRead}\n          className='text-muted-foreground'\n        >\n          <CheckCheckIcon />\n          Mark all as read\n        </Button>\n      </div>\n      <Tabs value={tab} onValueChange={changeTab} className='gap-1'>\n        <TabsList\n          variant='segmented'\n          aria-label='Filter notifications'\n          className='mx-3'\n        >\n          {TABS.map((item) => {\n            const tabEntries = entriesOf(entries, item.value);\n            const unread = unreadCountOf(tabEntries);\n            const hasCount = unread > 0;\n            return (\n              <TabsTab\n                key={item.value}\n                value={item.value}\n                aria-label={tabLabelOf(item.label, unread)}\n              >\n                {item.label}\n                {hasCount && (\n                  <span\n                    aria-hidden='true'\n                    className='bg-foreground/[0.07] text-muted-foreground inline-flex h-4 min-w-4 items-center justify-center rounded-[5px] px-1 text-[11px] tabular-nums'\n                  >\n                    {unread}\n                  </span>\n                )}\n              </TabsTab>\n            );\n          })}\n        </TabsList>\n      </Tabs>\n      <AnimatedSize>\n        <div className='relative'>\n          <AnimatePresence mode='popLayout' initial={false}>\n            <motion.ul\n              key={tab}\n              aria-label='Notifications'\n              variants={FADE_VARIANTS}\n              initial='enter'\n              animate='visible'\n              exit='exit'\n              transition={FADE_TRANSITION}\n              className='max-h-[22rem] overflow-y-auto px-1.5 pt-1 pb-1.5'\n            >\n              {visibleEntries.map((entry) => (\n                <BellRow key={entry.id} entry={entry} onOpen={onOpen} />\n              ))}\n            </motion.ul>\n          </AnimatePresence>\n        </div>\n      </AnimatedSize>\n    </div>\n  );\n}\n\nexport default function Notification25() {\n  const [entries, setEntries] = useState(ENTRIES);\n  const [tab, setTab] = useState<InboxTab>('all');\n  const unreadCount = unreadCountOf(entries);\n  const hasUnread = unreadCount > 0;\n\n  function open(entryId: string) {\n    setEntries((current) => withRead(current, entryId));\n  }\n\n  function markAllRead() {\n    setEntries(allRead);\n  }\n\n  const panel = (\n    <InboxPanel\n      entries={entries}\n      tab={tab}\n      onTabChange={setTab}\n      onOpen={open}\n      onMarkAllRead={markAllRead}\n    />\n  );\n\n  return (\n    <div className='flex w-full max-w-[26rem] flex-col items-end gap-2'>\n      <Popover>\n        <PopoverTrigger\n          render={\n            <Button\n              variant='ghost'\n              size='icon'\n              aria-label={bellLabelOf(unreadCount)}\n            />\n          }\n        >\n          <BellIcon />\n          <UnreadDot\n            isUnread={hasUnread}\n            className='ring-card absolute top-1.5 right-1.5 size-2 ring-2'\n          />\n        </PopoverTrigger>\n        <PopoverContent\n          align='end'\n          className='w-[26rem] max-w-[calc(100vw-1rem)] p-0'\n        >\n          {panel}\n        </PopoverContent>\n      </Popover>\n      <div className='surface-raised w-full rounded-xl'>{panel}</div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/notification-25.tsx"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/people.ts",
      "content": "export type Person = {\n  id: string;\n  name: string;\n};\n\nexport const PEOPLE = {\n  amara: {\n    id: 'amara',\n    name: 'Amara Okafor',\n  },\n  leo: {\n    id: 'leo',\n    name: 'Léo Martin',\n  },\n  sana: {\n    id: 'sana',\n    name: 'Sana Iqbal',\n  },\n  tomas: {\n    id: 'tomas',\n    name: 'Tomás Rivera',\n  },\n  mei: {\n    id: 'mei',\n    name: 'Mei Lin',\n  },\n  jonas: { id: 'jonas', name: 'Jonas Weber' },\n} satisfies Record<string, Person>;\n\nexport function firstNameOf(person: Person) {\n  const [firstName] = person.name.split(' ');\n  return firstName;\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/people.ts"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/sender-avatar.tsx",
      "content": "import {\n  AtSignIcon,\n  CircleDashedIcon,\n  MailIcon,\n  MessageCircleIcon,\n  UserRoundPlusIcon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\n\nimport type { Person } from './people';\n\nexport type NotificationKind =\n  'mention' | 'comment' | 'assigned' | 'status' | 'invite';\n\ntype KindMeta = { label: string; color: string; glyph: Icon };\n\nexport const KIND_META: Record<NotificationKind, KindMeta> = {\n  mention: { label: 'Mention', color: 'var(--info)', glyph: AtSignIcon },\n  comment: {\n    label: 'Comment',\n    color: 'var(--success)',\n    glyph: MessageCircleIcon,\n  },\n  assigned: {\n    label: 'Assignment',\n    color: 'var(--primary)',\n    glyph: UserRoundPlusIcon,\n  },\n  status: {\n    label: 'Status change',\n    color: 'var(--muted-foreground)',\n    glyph: CircleDashedIcon,\n  },\n  invite: { label: 'Invitation', color: 'var(--warning)', glyph: MailIcon },\n};\n\ntype SenderAvatarProps = {\n  person: Person;\n  kind: NotificationKind;\n  /** The colour behind the badge's cut-out: the surface the avatar sits on. */\n  surfaceClassName?: string;\n  className?: string;\n};\n\n/** A sender's face with the kind of the notification on its lower corner. */\nexport function SenderAvatar({\n  person,\n  kind,\n  surfaceClassName = 'bg-card',\n  className,\n}: SenderAvatarProps) {\n  const meta = KIND_META[kind];\n  const Glyph = meta.glyph;\n  const badgeStyle = { '--chip': meta.color } as React.CSSProperties;\n\n  return (\n    <span className={cn('relative inline-flex shrink-0', className)}>\n      <Avatar className='size-8' seed={person.name} />\n      <span\n        aria-hidden='true'\n        className={cn(\n          'absolute -end-1 -bottom-1 inline-flex rounded-full p-px',\n          surfaceClassName\n        )}\n      >\n        <span\n          style={badgeStyle}\n          className='chip-3d inline-flex size-4 items-center justify-center rounded-full'\n        >\n          <Glyph strokeWidth={2.25} className='size-2.5' />\n        </span>\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/sender-avatar.tsx"
    },
    {
      "path": "registry/ndeko/components/notification/_parts/unread-dot.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion, type Transition } from 'motion/react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nconst EASE_OUT_STRONG: [number, number, number, number] = [0.23, 1, 0.32, 1];\n\nconst ARRIVE: Transition = { duration: 0.18, ease: EASE_OUT_STRONG };\n\nconst LEAVE: Transition = { duration: 0.12, ease: EASE_OUT_STRONG };\n\nfunction hiddenScaleOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return 1;\n  }\n  return 0.4;\n}\n\ntype UnreadDotProps = {\n  isUnread: boolean;\n  className?: string;\n};\n\n/** The primary dot of an unread notification. It shrinks away when the notification is read. */\nexport function UnreadDot({ isUnread, className }: UnreadDotProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const hiddenScale = hiddenScaleOf(shouldReduceMotion);\n\n  return (\n    <AnimatePresence initial={false}>\n      {isUnread && (\n        <motion.span\n          key='unread'\n          aria-hidden='true'\n          initial={{ opacity: 0, scale: hiddenScale }}\n          animate={{ opacity: 1, scale: 1, transition: ARRIVE }}\n          exit={{ opacity: 0, scale: hiddenScale, transition: LEAVE }}\n          className={cn('bg-primary block size-1.5 rounded-full', className)}\n        />\n      )}\n    </AnimatePresence>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/unread-dot.tsx"
    }
  ],
  "categories": [
    "components",
    "notification"
  ],
  "type": "registry:component"
}