{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-30",
  "title": "Notification preferences",
  "description": "One switch per kind of notification, saved as it changes, with the kind dimmed when it is off.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/switch.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-30.tsx",
      "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\n\nimport { CheckIcon, LoaderIcon } from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Switch } from '@/registry/ndeko/ui/switch';\n\nimport { KIND_META, type NotificationKind } from './_parts/sender-avatar';\nimport { Swap } from './_parts/swap';\n\ntype Preference = {\n  kind: NotificationKind;\n  title: string;\n  trigger: string;\n};\n\ntype SaveState = 'idle' | 'saving' | 'saved';\n\nconst PREFERENCES: Preference[] = [\n  {\n    kind: 'mention',\n    title: 'Mentions',\n    trigger: 'Someone writes your name in a task, a comment or a document',\n  },\n  {\n    kind: 'assigned',\n    title: 'Assignments',\n    trigger: 'A task is assigned to you or taken off your plate',\n  },\n  {\n    kind: 'comment',\n    title: 'Comments on your tasks',\n    trigger: 'Someone replies on a task you created or follow',\n  },\n  {\n    kind: 'status',\n    title: 'Status changes',\n    trigger: 'A task you follow moves to another column',\n  },\n  {\n    kind: 'invite',\n    title: 'Invitations',\n    trigger: 'You are invited to a team or a project',\n  },\n];\n\nconst INITIAL_ENABLED: Record<NotificationKind, boolean> = {\n  mention: true,\n  assigned: true,\n  comment: true,\n  status: false,\n  invite: true,\n};\n\nconst SAVE_DELAY_MS = 600;\n\nfunction saveLabelOf(state: SaveState) {\n  if (state === 'saving') {\n    return 'Saving';\n  }\n  if (state === 'saved') {\n    return 'Saved';\n  }\n  return 'Saved automatically';\n}\n\nfunction enabledCountLabelOf(count: number) {\n  if (count === 1) {\n    return '1 kind on';\n  }\n  return `${count} kinds on`;\n}\n\nfunction useSaveState() {\n  const [state, setState] = useState<SaveState>('idle');\n  const timer = useRef<number | null>(null);\n\n  useEffect(() => {\n    return () => {\n      if (timer.current !== null) {\n        window.clearTimeout(timer.current);\n      }\n    };\n  }, []);\n\n  function save() {\n    if (timer.current !== null) {\n      window.clearTimeout(timer.current);\n    }\n    setState('saving');\n    timer.current = window.setTimeout(() => {\n      timer.current = null;\n      setState('saved');\n    }, SAVE_DELAY_MS);\n  }\n\n  return { state, save };\n}\n\ntype PreferenceRowProps = {\n  preference: Preference;\n  isEnabled: boolean;\n  onToggle: (kind: NotificationKind, isEnabled: boolean) => void;\n};\n\nfunction PreferenceRow({\n  preference,\n  isEnabled,\n  onToggle,\n}: PreferenceRowProps) {\n  const labelId = useId();\n  const triggerId = useId();\n  const meta = KIND_META[preference.kind];\n  const Glyph = meta.glyph;\n  const badgeStyle = { '--chip': meta.color } as React.CSSProperties;\n\n  return (\n    <li className='flex items-center gap-3 py-2.5'>\n      <span\n        aria-hidden='true'\n        style={badgeStyle}\n        className={cn(\n          'chip-3d inline-flex size-7 shrink-0 items-center justify-center rounded-full transition-opacity duration-200',\n          !isEnabled && 'opacity-45'\n        )}\n      >\n        <Glyph strokeWidth={2.25} className='size-3.5' />\n      </span>\n      <div className='min-w-0 flex-1'>\n        <p id={labelId} className='text-[13px] leading-5 font-medium'>\n          {preference.title}\n        </p>\n        <p id={triggerId} className='text-muted-foreground text-xs'>\n          {preference.trigger}\n        </p>\n      </div>\n      <Switch\n        checked={isEnabled}\n        onCheckedChange={(checked) => onToggle(preference.kind, checked)}\n        aria-labelledby={labelId}\n        aria-describedby={triggerId}\n      />\n    </li>\n  );\n}\n\nexport default function Notification30() {\n  const titleId = useId();\n  const [enabled, setEnabled] = useState(INITIAL_ENABLED);\n  const { state: saveState, save } = useSaveState();\n  const isSaving = saveState === 'saving';\n  const enabledCount = PREFERENCES.filter(\n    (preference) => enabled[preference.kind]\n  ).length;\n\n  function toggle(kind: NotificationKind, isEnabled: boolean) {\n    setEnabled((current) => ({ ...current, [kind]: isEnabled }));\n    save();\n  }\n\n  return (\n    <section\n      aria-labelledby={titleId}\n      className='bg-card hairline-ring ring-border w-full max-w-[28rem] rounded-xl px-4 pt-4 pb-1.5 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'\n    >\n      <header className='flex items-start justify-between gap-3'>\n        <div>\n          <h3 id={titleId} className='text-sm font-medium'>\n            Notify me about\n          </h3>\n          <p className='text-muted-foreground text-xs tabular-nums'>\n            {enabledCountLabelOf(enabledCount)}\n          </p>\n        </div>\n        <p\n          role='status'\n          className='text-muted-foreground flex h-5 items-center gap-1.5 text-xs'\n        >\n          <Swap swapKey={String(isSaving)}>\n            {isSaving && <LoaderIcon className='size-3.5 animate-spin' />}\n            {!isSaving && <CheckIcon className='size-3.5' />}\n          </Swap>\n          {saveLabelOf(saveState)}\n        </p>\n      </header>\n      <ul aria-label='Kinds of notification' className='mt-2'>\n        {PREFERENCES.map((preference) => (\n          <PreferenceRow\n            key={preference.kind}\n            preference={preference}\n            isEnabled={enabled[preference.kind]}\n            onToggle={toggle}\n          />\n        ))}\n      </ul>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/notification-30.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/swap.tsx",
      "content": "'use client';\n\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  type Variants,\n} from 'motion/react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nconst SWAP_TRANSITION: Transition = { duration: 0.15, ease: [0.2, 0, 0, 1] };\n\nconst SWAP_VARIANTS: Variants = {\n  enter: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n  visible: { opacity: 1, scale: 1, filter: 'blur(0px)' },\n  exit: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\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 SWAP_VARIANTS;\n}\n\ntype SwapProps = {\n  /** Changes when the content becomes another one. */\n  swapKey: string;\n  className?: string;\n  children: React.ReactNode;\n};\n\n/** An icon that becomes another: the old one shrinks out of focus while the new one settles. */\nexport function Swap({ swapKey, className, children }: SwapProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n\n  return (\n    <AnimatePresence mode='popLayout' initial={false}>\n      <motion.span\n        key={swapKey}\n        variants={variantsOf(shouldReduceMotion)}\n        initial='enter'\n        animate='visible'\n        exit='exit'\n        transition={SWAP_TRANSITION}\n        className={cn('flex', className)}\n      >\n        {children}\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/notification/_parts/swap.tsx"
    }
  ],
  "categories": [
    "components",
    "notification"
  ],
  "type": "registry:component"
}