{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-23",
  "title": "Inbox with unread states",
  "description": "An inbox where opening a row reads it, the dot shrinks away and Mark all as read clears the count.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "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/theme.json",
    "https://ui.ndekocode.dev/r/tooltip.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-23.tsx",
      "content": "'use client';\n\nimport { useId, useState } from 'react';\n\nimport {\n  CheckCheckIcon,\n  CheckIcon,\n  MailIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from '@/registry/ndeko/ui/tooltip';\n\nimport { PEOPLE, type Person } from './_parts/people';\nimport { SenderAvatar, type NotificationKind } from './_parts/sender-avatar';\nimport { SlidingCount } from './_parts/sliding-count';\nimport { UnreadDot } from './_parts/unread-dot';\n\ntype InboxEntry = {\n  id: string;\n  kind: NotificationKind;\n  sender: Person;\n  verb: string;\n  target: string;\n  suffix?: string;\n  excerpt?: string;\n  age: string;\n  isUnread: boolean;\n};\n\nconst ENTRIES: InboxEntry[] = [\n  {\n    id: 'mention',\n    kind: 'mention',\n    sender: PEOPLE.sana,\n    verb: 'mentioned you in',\n    target: 'Booking calendar skips a week',\n    excerpt: 'Can you check the empty week on the phone layout?',\n    age: '4m',\n    isUnread: true,\n  },\n  {\n    id: 'assigned',\n    kind: 'assigned',\n    sender: PEOPLE.mei,\n    verb: 'assigned you',\n    target: 'Copy for the weekly digest',\n    age: '38m',\n    isUnread: true,\n  },\n  {\n    id: 'comment',\n    kind: 'comment',\n    sender: PEOPLE.leo,\n    verb: 'commented on',\n    target: 'Offline drafts for field notes',\n    excerpt: 'Drafts now sync as soon as the tab is back online',\n    age: '2h',\n    isUnread: true,\n  },\n  {\n    id: 'status',\n    kind: 'status',\n    sender: PEOPLE.tomas,\n    verb: 'moved',\n    target: 'Rate limit the share links',\n    suffix: ' to In review',\n    age: '5h',\n    isUnread: false,\n  },\n  {\n    id: 'invite',\n    kind: 'invite',\n    sender: PEOPLE.amara,\n    verb: 'invited you to',\n    target: 'Atlas Redesign',\n    age: 'Yesterday',\n    isUnread: false,\n  },\n];\n\nconst ROW_ACTION =\n  'relative z-10 inline-flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors duration-150 hover:bg-foreground/5 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/25';\n\nfunction unreadLabelOf(count: number) {\n  if (count === 0) {\n    return 'All read';\n  }\n  return `${count} unread`;\n}\n\nfunction readToggleLabelOf(entry: InboxEntry) {\n  if (entry.isUnread) {\n    return 'Mark as read';\n  }\n  return 'Mark as unread';\n}\n\nfunction withRead(entries: InboxEntry[], entryId: string, isUnread: boolean) {\n  return entries.map((entry) => {\n    if (entry.id !== entryId) {\n      return entry;\n    }\n    return { ...entry, isUnread };\n  });\n}\n\nfunction allRead(entries: InboxEntry[]) {\n  return entries.map((entry) => ({ ...entry, isUnread: false }));\n}\n\nfunction emphasisOf(isUnread: boolean) {\n  if (isUnread) {\n    return 'text-foreground';\n  }\n  return 'text-muted-foreground';\n}\n\ntype InboxRowProps = {\n  entry: InboxEntry;\n  onOpen: (entryId: string) => void;\n  onToggleRead: (entryId: string) => void;\n};\n\nfunction InboxRow({ entry, onOpen, onToggleRead }: InboxRowProps) {\n  const readLabel = readToggleLabelOf(entry);\n  const emphasis = emphasisOf(entry.isUnread);\n\n  return (\n    <li className='group/row hover:bg-foreground/[0.03] focus-within:bg-foreground/[0.03] relative flex items-start gap-3 rounded-lg py-2.5 ps-4 pe-2.5 transition-colors duration-150'>\n      <UnreadDot\n        isUnread={entry.isUnread}\n        className='absolute start-1.5 top-[1.4rem]'\n      />\n      <SenderAvatar\n        person={entry.sender}\n        kind={entry.kind}\n        className={cn(\n          'transition-opacity duration-200',\n          !entry.isUnread && 'opacity-70'\n        )}\n      />\n      <button\n        type='button'\n        onClick={() => onOpen(entry.id)}\n        className='focus-visible:after:ring-ring/25 min-w-0 flex-1 cursor-pointer text-left outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-[3px]'\n      >\n        <span className='text-muted-foreground block text-[13px] leading-5'>\n          <span\n            className={cn(\n              'font-medium transition-colors duration-200',\n              emphasis\n            )}\n          >\n            {entry.sender.name}\n          </span>{' '}\n          {entry.verb}{' '}\n          <span\n            className={cn(\n              'font-medium transition-colors duration-200',\n              emphasis\n            )}\n          >\n            {entry.target}\n          </span>\n          {entry.suffix}\n        </span>\n        {entry.excerpt && (\n          <span className='text-muted-foreground mt-0.5 line-clamp-1 block text-xs'>\n            {entry.excerpt}\n          </span>\n        )}\n        <span className='sr-only'>\n          , {entry.age}\n          {entry.isUnread && ', unread'}\n        </span>\n      </button>\n      <span className='relative flex h-5 shrink-0 items-center'>\n        <span\n          aria-hidden='true'\n          className='text-muted-foreground text-xs tabular-nums transition-opacity duration-150 group-focus-within/row:opacity-0 group-hover/row:opacity-0 pointer-coarse:hidden'\n        >\n          {entry.age}\n        </span>\n        <span className='absolute end-0 flex opacity-0 transition-opacity duration-150 group-focus-within/row:opacity-100 group-hover/row:opacity-100 pointer-coarse:static pointer-coarse:opacity-100'>\n          <Tooltip>\n            <TooltipTrigger\n              aria-label={readLabel}\n              onClick={() => onToggleRead(entry.id)}\n              className={ROW_ACTION}\n            >\n              {entry.isUnread && <CheckIcon className='size-3.5' />}\n              {!entry.isUnread && <MailIcon className='size-3.5' />}\n            </TooltipTrigger>\n            <TooltipContent>{readLabel}</TooltipContent>\n          </Tooltip>\n        </span>\n      </span>\n    </li>\n  );\n}\n\nexport default function Notification23() {\n  const titleId = useId();\n  const [entries, setEntries] = useState(ENTRIES);\n  const unreadCount = entries.filter((entry) => entry.isUnread).length;\n  const hasUnread = unreadCount > 0;\n\n  function open(entryId: string) {\n    setEntries((current) => withRead(current, entryId, false));\n  }\n\n  function toggleRead(entryId: string) {\n    const entry = entries.find((item) => item.id === entryId);\n    if (!entry) {\n      return;\n    }\n    setEntries((current) => withRead(current, entryId, !entry.isUnread));\n  }\n\n  function markAllRead() {\n    setEntries(allRead);\n  }\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      className='bg-card hairline-ring ring-border w-full max-w-[26rem] rounded-xl shadow-[0_1px_2px_rgb(0_0_0/0.04)]'\n    >\n      <header className='flex h-11 items-center justify-between gap-2 ps-4 pe-2'>\n        <div className='flex items-baseline gap-2'>\n          <h3 id={titleId} className='text-[13px] leading-none font-semibold'>\n            Inbox\n          </h3>\n          <SlidingCount\n            value={unreadCount}\n            format={unreadLabelOf}\n            className='text-muted-foreground text-xs'\n          />\n        </div>\n        <Button\n          variant='ghost'\n          size='sm'\n          disabled={!hasUnread}\n          onClick={markAllRead}\n          className='text-muted-foreground'\n        >\n          <CheckCheckIcon />\n          Mark all as read\n        </Button>\n      </header>\n      <ul aria-label='Notifications' className='px-1.5 pb-1.5'>\n        {entries.map((entry) => (\n          <InboxRow\n            key={entry.id}\n            entry={entry}\n            onOpen={open}\n            onToggleRead={toggleRead}\n          />\n        ))}\n      </ul>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/notification-23.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/sliding-count.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 { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nconst SHIFT_PX = 8;\n\nconst SLIDE_TRANSITION: Transition = { duration: 0.18, ease: [0.2, 0, 0, 1] };\n\nconst SLIDE_VARIANTS: Variants = {\n  enter: (direction: number) => {\n    const offset = direction * SHIFT_PX;\n    return { opacity: 0, y: offset };\n  },\n  visible: { opacity: 1, y: 0 },\n  exit: (direction: number) => {\n    const offset = direction * SHIFT_PX;\n    return { opacity: 0, y: -offset };\n  },\n};\n\nconst FADE_VARIANTS: Variants = {\n  enter: { opacity: 0 },\n  visible: { opacity: 1 },\n  exit: { opacity: 0 },\n};\n\nfunction variantsOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return SLIDE_VARIANTS;\n}\n\nfunction directionOf(previous: number, next: number) {\n  if (next > previous) {\n    return 1;\n  }\n  return -1;\n}\n\ntype Shown = { value: number; direction: number };\n\ntype SlidingCountProps = {\n  value: number;\n  /** Writes the figure in words, with its singular branch. */\n  format: (value: number) => string;\n  className?: string;\n};\n\n/** A figure the person just changed: it slides in the direction of the change. */\nexport function SlidingCount({ value, format, className }: SlidingCountProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const [shown, setShown] = useState<Shown>({ value, direction: 1 });\n  const text = format(value);\n\n  if (shown.value !== value) {\n    const direction = directionOf(shown.value, value);\n    setShown({ value, direction });\n  }\n\n  return (\n    <span\n      className={cn(\n        'relative inline-flex overflow-hidden tabular-nums',\n        className\n      )}\n    >\n      <AnimatePresence\n        mode='popLayout'\n        initial={false}\n        custom={shown.direction}\n      >\n        <motion.span\n          key={text}\n          custom={shown.direction}\n          variants={variantsOf(shouldReduceMotion)}\n          initial='enter'\n          animate='visible'\n          exit='exit'\n          transition={SLIDE_TRANSITION}\n          className='whitespace-nowrap'\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/sliding-count.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"
}