{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-matrix",
  "type": "registry:ui",
  "title": "Notification Matrix",
  "description": "Events by channels, column select-alls that ripple, and chip cards on phones.",
  "categories": [
    "settings"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/notification-matrix.tsx",
      "content": "\"use client\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Bell, Lock, Mail } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type NotificationChannel = {\n  id: string;\n  label: string;\n  /** A 14px glyph beside the label in the header. */\n  icon?: React.ReactNode;\n  /** The whole channel can't be changed right now, e.g. push is blocked in this browser. Values are kept. */\n  disabled?: boolean;\n  /** A short line under the channel: why it's off, or a button to turn it on. */\n  hint?: React.ReactNode;\n};\n\nexport type NotificationEvent = {\n  id: string;\n  label: string;\n  description?: string;\n  /** Channels this event is never sent on. Drawn as a dash, skipped by select-all. */\n  unsupported?: string[];\n  /** Channels that are always on for this event, like security alerts by email. */\n  required?: string[];\n  /** Why the required channels can't be turned off. */\n  requiredReason?: string;\n};\n\nexport type NotificationGroup = { id: string; label: string; events: NotificationEvent[] };\n\n/** Event id → the channel ids it's delivered on. Required channels are always included. */\nexport type NotificationMatrixValue = Record<string, string[]>;\n\nfunction Phone({ size = 16 }: { size?: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden focusable=\"false\">\n      <rect x=\"4.25\" y=\"1.75\" width=\"7.5\" height=\"12.5\" rx=\"1.75\" />\n      <path d=\"M7 11.75h2\" />\n    </svg>\n  );\n}\n\nexport const defaultNotificationChannels: NotificationChannel[] = [\n  { id: \"email\", label: \"Email\", icon: <Mail size={14} /> },\n  { id: \"push\", label: \"Push\", icon: <Phone size={14} /> },\n  { id: \"in-app\", label: \"In-app\", icon: <Bell size={14} /> },\n];\n\n/* ------------------------------------------------------------------ */\n/* NotificationMatrix                                                  */\n/* ------------------------------------------------------------------ */\n\nexport type NotificationMatrixProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  groups: NotificationGroup[];\n  channels?: NotificationChannel[];\n  value?: NotificationMatrixValue;\n  defaultValue?: NotificationMatrixValue;\n  onValueChange?: (value: NotificationMatrixValue) => void;\n  disabled?: boolean;\n  /** Heading over the event column on wide layouts. */\n  eventsLabel?: string;\n};\n\ntype Wave = { channel: string; on: boolean; order: Map<string, number> } | null;\n\n\nexport function NotificationMatrix({\n  groups,\n  channels = defaultNotificationChannels,\n  value: valueProp,\n  defaultValue = {},\n  onValueChange,\n  disabled = false,\n  eventsLabel = \"Notify me about\",\n  className,\n  style,\n  onKeyDown,\n  ...rest\n}: NotificationMatrixProps) {\n  const uid = useId();\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [wave, setWave] = useState<Wave>(null);\n  const [previewCol, setPreviewCol] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  // A column toggle ripples down the column once, then later single toggles are instant again.\n  useEffect(() => {\n    if (!wave) return;\n    const t = window.setTimeout(() => setWave(null), 500);\n    return () => window.clearTimeout(t);\n  }, [wave]);\n\n  const events = groups.flatMap((g) => g.events);\n  const channelById = new Map(channels.map((c) => [c.id, c]));\n  const supports = (ev: NotificationEvent, ch: string) => !ev.unsupported?.includes(ch);\n  const isRequired = (ev: NotificationEvent, ch: string) => !!ev.required?.includes(ch);\n  // A cell select-all speaks for: supported and not locked. A blocked channel still reports its state.\n  const choosable = (ev: NotificationEvent, ch: string) => supports(ev, ch) && !isRequired(ev, ch);\n  const isOn = (ev: NotificationEvent, ch: string) => isRequired(ev, ch) || !!value[ev.id]?.includes(ch);\n\n  const withChannel = (ev: NotificationEvent, ch: string, on: boolean, from: NotificationMatrixValue) => {\n    const set = new Set(from[ev.id] ?? []);\n    ev.required?.forEach((r) => set.add(r));\n    if (on) set.add(ch);\n    else set.delete(ch);\n    // Keep channel order stable so the value diffs cleanly.\n    return channels.map((c) => c.id).filter((id) => set.has(id));\n  };\n\n  const toggleCell = (ev: NotificationEvent, ch: string, on: boolean) => {\n    setWave(null);\n    setValue({ ...value, [ev.id]: withChannel(ev, ch, on, value) });\n  };\n\n  const column = (ch: string) => {\n    const cells = events.filter((ev) => choosable(ev, ch));\n    const on = cells.filter((ev) => isOn(ev, ch)).length;\n    return { cells, on, total: cells.length, state: on === 0 ? \"off\" : on === cells.length ? \"on\" : \"mixed\" } as const;\n  };\n\n  const toggleColumn = (ch: string) => {\n    const { cells, state } = column(ch);\n    const on = state !== \"on\";\n    const next = { ...value };\n    const order = new Map<string, number>();\n    let i = 0;\n    for (const ev of cells) {\n      if (isOn(ev, ch) !== on) order.set(ev.id, i++);\n      next[ev.id] = withChannel(ev, ch, on, next);\n    }\n    setWave({ channel: ch, on, order });\n    setValue(next);\n    const label = channelById.get(ch)?.label ?? ch;\n    setAnnouncement(`${label} ${on ? \"on\" : \"off\"} for ${cells.length} ${cells.length === 1 ? \"event\" : \"events\"}`);\n  };\n\n  // Arrow keys move between checkboxes like a grid: up and down within a channel, left and right within an event.\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e);\n    if (e.defaultPrevented) return;\n    const dirs: Record<string, [number, number]> = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };\n    const d = dirs[e.key];\n    const from = (e.target as HTMLElement).closest<HTMLElement>(\"[data-matrix-cell]\");\n    if (!d || !from) return;\n    const cells = Array.from(e.currentTarget.querySelectorAll<HTMLElement>(\"[data-matrix-cell]:not([data-disabled])\")).filter((el) => el.offsetParent !== null);\n    const r = Number(from.dataset.row);\n    const c = Number(from.dataset.col);\n    const candidates = cells\n      .map((el) => ({ el, r: Number(el.dataset.row), c: Number(el.dataset.col) }))\n      .filter((p) => (d[0] ? p.c === c && Math.sign(p.r - r) === d[0] : p.r === r && Math.sign(p.c - c) === d[1]))\n      .sort((a, b) => Math.abs(a.r - r) + Math.abs(a.c - c) - (Math.abs(b.r - r) + Math.abs(b.c - c)));\n    if (candidates[0]) {\n      e.preventDefault();\n      candidates[0].el.focus();\n    }\n  };\n\n  const cols = `minmax(0,1fr) repeat(${channels.length}, 4.75rem)`;\n  let row = 0;\n\n  // One DOM for both layouts: below 30rem of container width (the @max-[30rem] classes)\n  // each event stacks over a row of channel chips and the header becomes chips too.\n  return (\n    <Tooltip.Provider delay={400}>\n      <div\n        role=\"group\"\n        aria-labelledby={`${uid}-label`}\n        data-disabled={disabled || undefined}\n        className={cn(\"@container w-full min-w-0\", className)}\n        style={{ ...style, [\"--matrix-cols\" as string]: cols }}\n        onKeyDown={handleKeyDown}\n        {...rest}\n      >\n        <span id={`${uid}-label`} className=\"sr-only\">\n          Notification preferences\n        </span>\n        {/* overflow-clip rounds the corners without becoming a scroll container, so the header can still stick. */}\n        <div className=\"overflow-clip rounded-xl border border-line bg-raised shadow-[var(--shadow)]\">\n          {/* Header: one select-all per channel, stacked over its column. */}\n          <div\n            className={cn(\n              \"sticky top-(--matrix-sticky-top,0px) z-(--z-sticky) grid border-b border-line bg-raised pr-2 [grid-template-columns:var(--matrix-cols)]\",\n              \"@max-[30rem]:flex @max-[30rem]:flex-col @max-[30rem]:gap-2 @max-[30rem]:px-4 @max-[30rem]:py-3\",\n            )}\n          >\n            <div className={cn(\"flex items-end px-4 pb-2.5 pt-3\", \"@max-[30rem]:p-0\")}>\n              <span className=\"font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">\n                <span className={\"@max-[30rem]:hidden\"}>{eventsLabel}</span>\n                <span className={\"hidden @max-[30rem]:inline\"}>All events</span>\n              </span>\n            </div>\n            <div className={cn(\"contents\", \"@max-[30rem]:flex @max-[30rem]:flex-wrap @max-[30rem]:gap-1.5\")}>\n              {channels.map((ch, c) => {\n                const col = column(ch.id);\n                const off = disabled || ch.disabled || col.total === 0;\n                return (\n                  <label\n                    key={ch.id}\n                    data-preview={previewCol === ch.id || undefined}\n                    onPointerEnter={(e) => e.pointerType === \"mouse\" && !off && setPreviewCol(ch.id)}\n                    onPointerLeave={() => setPreviewCol(null)}\n                    className={cn(\n                      \"group/cell relative flex select-none flex-col items-center justify-end gap-2 px-1 pb-2.5 pt-3\",\n                      \"transition-colors duration-150 data-preview:bg-fg/[0.03]\",\n                      \"@max-[30rem]:h-8 @max-[30rem]:flex-row-reverse @max-[30rem]:justify-end @max-[30rem]:gap-2 @max-[30rem]:rounded-full @max-[30rem]:border @max-[30rem]:border-line-2 @max-[30rem]:px-2.5 @max-[30rem]:py-0\",\n                      \"@max-[30rem]:before:absolute @max-[30rem]:before:-inset-y-1.5 @max-[30rem]:before:inset-x-0 @max-[30rem]:before:content-['']\",\n                      off ? \"opacity-50\" : \"cursor-pointer\",\n                    )}\n                  >\n                    <span className=\"flex items-center gap-1.5 text-[12.5px] font-medium leading-4 text-fg-2\">\n                      {ch.icon && <span className={cn(\"flex text-fg-3\", \"@max-[30rem]:hidden\")}>{ch.icon}</span>}\n                      {ch.label}\n                    </span>\n                    <Box\n                      checked={col.state === \"on\"}\n                      indeterminate={col.state === \"mixed\"}\n                      disabled={off}\n                      row={-1}\n                      col={c}\n                      aria-label={`${ch.label} for all events`}\n                      onCheckedChange={() => toggleColumn(ch.id)}\n                      onFocus={() => setPreviewCol(ch.id)}\n                      onBlur={() => setPreviewCol(null)}\n                    />\n                  </label>\n                );\n              })}\n            </div>\n            {channels.some((ch) => ch.hint) && (\n              <div className={cn(\"col-span-full -mt-1 flex flex-col gap-0.5 px-4 pb-2.5 text-right\", \"@max-[30rem]:m-0 @max-[30rem]:p-0 @max-[30rem]:text-left\")}>\n                {channels\n                  .filter((ch) => ch.hint)\n                  .map((ch) => (\n                    <p key={ch.id} className=\"text-[12px] leading-4 text-fg-3\">\n                      <span className=\"text-fg-2\">{ch.label}</span> · {ch.hint}\n                    </p>\n                  ))}\n              </div>\n            )}\n          </div>\n\n          {groups.map((group) => (\n            <div key={group.id} role=\"group\" aria-labelledby={`${uid}-${group.id}`} className=\"border-b border-line last:border-b-0\">\n              {/* Empty cells under each channel keep the column preview continuous through the group label. */}\n              <div className={cn(\"grid pr-2 [grid-template-columns:var(--matrix-cols)]\", \"@max-[30rem]:block\")}>\n                <h3 id={`${uid}-${group.id}`} className=\"px-4 pb-1 pt-3 font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">\n                  {group.label}\n                </h3>\n                {channels.map((ch) => (\n                  <span key={ch.id} aria-hidden data-preview={previewCol === ch.id || undefined} className={cn(\"transition-colors duration-150 data-preview:bg-fg/[0.03]\", \"@max-[30rem]:hidden\")} />\n                ))}\n              </div>\n              <div>\n                {group.events.map((ev) => {\n                  const r = row++;\n                  const reasonId = `${uid}-${ev.id}-reason`;\n                  return (\n                    <div\n                      key={ev.id}\n                      role=\"group\"\n                      aria-labelledby={`${uid}-${ev.id}`}\n                      aria-describedby={ev.description ? `${uid}-${ev.id}-desc` : undefined}\n                      className={cn(\n                        \"grid border-t border-line/60 pr-2 first:border-t-0 [grid-template-columns:var(--matrix-cols)]\",\n                        \"@max-[30rem]:flex @max-[30rem]:flex-col @max-[30rem]:gap-2.5 @max-[30rem]:px-4 @max-[30rem]:py-3\",\n                      )}\n                    >\n                      <div className={cn(\"flex min-w-0 flex-col justify-center gap-0.5 py-2 pl-4 pr-3\", \"@max-[30rem]:p-0\")}>\n                        <span id={`${uid}-${ev.id}`} className=\"text-[13px] font-medium leading-5 text-fg\">\n                          {ev.label}\n                        </span>\n                        {ev.description && (\n                          <span id={`${uid}-${ev.id}-desc`} className=\"line-clamp-2 text-[12px] leading-4 text-pretty text-fg-3\">\n                            {ev.description}\n                          </span>\n                        )}\n                        {ev.required?.length ? (\n                          <span id={reasonId} className=\"sr-only\">\n                            {ev.requiredReason ?? `${ev.required.map((id) => channelById.get(id)?.label ?? id).join(\", \")} always on`}\n                          </span>\n                        ) : null}\n                      </div>\n                      <div className={cn(\"contents\", \"@max-[30rem]:flex @max-[30rem]:flex-wrap @max-[30rem]:gap-1.5\")}>\n                        {channels.map((ch, c) => {\n                          if (!supports(ev, ch.id))\n                            return (\n                              <span\n                                key={ch.id}\n                                data-preview={previewCol === ch.id || undefined}\n                                className={cn(\"grid place-items-center text-fg-4 transition-colors duration-150 data-preview:bg-fg/[0.03]\", \"@max-[30rem]:hidden\")}\n                              >\n                                <span aria-hidden className=\"h-px w-2.5 bg-current\" />\n                                <span className=\"sr-only\">{ch.label} not available</span>\n                              </span>\n                            );\n                          const required = isRequired(ev, ch.id);\n                          const on = isOn(ev, ch.id);\n                          const off = disabled || !!ch.disabled || required;\n                          const w = wave?.channel === ch.id ? wave.order.get(ev.id) : undefined;\n                          const cell = (\n                            <label\n                              key={ch.id}\n                              data-preview={previewCol === ch.id || undefined}\n                              data-on={on || undefined}\n                              className={cn(\n                                \"group/cell relative flex select-none items-center justify-center\",\n                                \"transition-[background-color,border-color] duration-150 data-preview:bg-fg/[0.03]\",\n                                \"@max-[30rem]:h-8 @max-[30rem]:flex-row @max-[30rem]:justify-start @max-[30rem]:gap-2 @max-[30rem]:rounded-full @max-[30rem]:border @max-[30rem]:border-line-2 @max-[30rem]:px-2.5\",\n                                \"@max-[30rem]:data-on:border-fg-4 @max-[30rem]:data-on:bg-fg/[0.05]\",\n                                \"@max-[30rem]:before:absolute @max-[30rem]:before:-inset-y-1.5 @max-[30rem]:before:inset-x-0 @max-[30rem]:before:content-['']\",\n                                !off && \"cursor-pointer @max-[30rem]:active:scale-[0.97] @max-[30rem]:transition-[background-color,border-color,scale]\",\n                                off && !required && \"opacity-50\",\n                              )}\n                            >\n                              <Box\n                                checked={on}\n                                disabled={off}\n                                locked={required}\n                                row={r}\n                                col={c}\n                                delay={w === undefined ? 0 : Math.min(w, 8) * 0.022}\n                                aria-describedby={required ? reasonId : undefined}\n                                onCheckedChange={(next) => toggleCell(ev, ch.id, next)}\n                              />\n                              <span className={cn(\"sr-only text-[12.5px] leading-4 text-fg-2\", \"@max-[30rem]:not-sr-only\", \"@max-[30rem]:group-data-on/cell:text-fg\")}>\n                                {ch.label}\n                              </span>\n                              {required && (\n                                // Beside the box on wide layouts, after the label on chips.\n                                <span aria-hidden className={cn(\"absolute left-[calc(50%+13px)] flex text-fg-4\", \"@max-[30rem]:static @max-[30rem]:text-fg-3\")}>\n                                  <Lock size={12} />\n                                </span>\n                              )}\n                            </label>\n                          );\n                          if (!required) return cell;\n                          return (\n                            <Tooltip.Root key={ch.id}>\n                              <Tooltip.Trigger render={cell} />\n                              <Tooltip.Portal>\n                                <Tooltip.Positioner side=\"top\" sideOffset={2} className=\"z-(--z-tooltip)\">\n                                  <Tooltip.Popup\n                                    className={cn(\n                                      \"flex w-max max-w-64 origin-(--transform-origin) items-center gap-1.5 rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 text-fg-2 shadow-pop\",\n                                      \"transition-[opacity,scale] duration-150 ease-out-expo data-starting-style:scale-96 data-starting-style:opacity-0\",\n                                      \"data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none motion-reduce:data-starting-style:scale-100\",\n                                    )}\n                                  >\n                                    <Lock size={12} className=\"shrink-0 text-fg-3\" />\n                                    {ev.requiredReason ?? \"Always on for this event\"}\n                                  </Tooltip.Popup>\n                                </Tooltip.Positioner>\n                              </Tooltip.Portal>\n                            </Tooltip.Root>\n                          );\n                        })}\n                      </div>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          ))}\n        </div>\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announcement}\n        </span>\n      </div>\n    </Tooltip.Provider>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* The box                                                             */\n/* ------------------------------------------------------------------ */\n\nconst CHECK = \"M3.75 8.25 6.75 11.25 12.25 4.75\";\nconst DASH = \"M4.5 8h7\";\n\ntype BoxProps = Omit<Checkbox.Root.Props, \"className\" | \"children\"> & {\n  locked?: boolean;\n  /** Seconds to wait before drawing: set per row by a column toggle so the change ripples down. */\n  delay?: number;\n  row: number;\n  col: number;\n};\n\nfunction Box({ checked, indeterminate, locked, delay = 0, row, col, disabled, ...rest }: BoxProps) {\n  const reduce = useReducedMotion();\n  const on = !!checked && !indeterminate;\n  const d = reduce ? 0 : delay;\n  return (\n    <Checkbox.Root\n      checked={checked}\n      indeterminate={indeterminate}\n      disabled={disabled}\n      data-matrix-cell=\"\"\n      data-row={row}\n      data-col={col}\n      style={{ transitionDelay: d ? `${d}s` : undefined }}\n      className={(state) =>\n        cn(\n          \"relative inline-grid size-4 shrink-0 place-items-center rounded-[4.5px] border outline-none\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,color,scale] duration-150 ease-out-expo motion-reduce:transition-none\",\n          !state.disabled && \"motion-safe:active:scale-[0.86] motion-safe:group-active/cell:scale-[0.86] active:duration-100 group-active/cell:duration-100\",\n          locked\n            ? \"border-transparent bg-fg-4 text-frame\"\n            : state.checked || state.indeterminate\n              ? \"border-fg bg-fg text-frame\"\n              : cn(\"border-fg-4 bg-raised\", !state.disabled && \"group-hover/cell:border-fg-3\"),\n        )\n      }\n      {...rest}\n    >\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden focusable=\"false\" className=\"size-full\">\n        <motion.path\n          d={CHECK}\n          stroke=\"currentColor\"\n          strokeWidth={1.9}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          initial={false}\n          animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}\n          transition={\n            reduce\n              ? { duration: 0.12 }\n              : on\n                ? { pathLength: { duration: 0.24, ease: ease.out, delay: d + 0.03 }, opacity: { duration: 0.04, delay: d + 0.03 } }\n                : { pathLength: { duration: 0.1, ease: ease.in, delay: d }, opacity: { duration: 0.08, delay: d + 0.04 } }\n          }\n        />\n        <motion.path\n          d={DASH}\n          stroke=\"currentColor\"\n          strokeWidth={1.9}\n          strokeLinecap=\"round\"\n          initial={false}\n          animate={{ scaleX: indeterminate ? 1 : 0.2, opacity: indeterminate ? 1 : 0 }}\n          transition={reduce ? { duration: 0.12, scaleX: { duration: 0 } } : { duration: 0.18, ease: ease.out }}\n        />\n      </svg>\n    </Checkbox.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/notification-matrix"
}
