Blocks

Notification Preferences

An events × channels notification matrix that saves per switch, with locked always-on rows, a disconnected channel column, bulk row/column actions and midnight-spanning quiet hours.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, BellRing, Check, Loader2, Lock, Moon, TriangleAlert, Unplug, X } from "lucide-react"
import { cn } from "@/lib/utils"
import type {
  NotificationChannel,
  NotificationEvent,
  NotificationPreferenceChange,
  NotificationPreferencesData,
  NotificationPreferencesStatus,
  PreferenceOrigin,
  QuietHours,
} from "./notification-preferences.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/notification-preferences.json

Prompt

Build a React + TypeScript + Tailwind "NotificationPreferences" block
(lucide-react icons) with zod. It is a matrix: rows are notification events,
columns are delivery channels, each intersection is a switch the signed-in
user flips for themselves.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    channels: { id, label, description?, connected, connectHint?,
                connectLabel? }[];
    events:   { id, label, description?, requiredChannels?: string[],
                requiredReason? }[];
    preferences: Record<eventId, Record<channelId, boolean>>;  // sparse: missing = off
    quietHours?: { enabled, start: "HH:MM", end: "HH:MM", timeZone,
                   now?: "HH:MM" } }
- Props = z.infer of the schema plus onPreferenceChange?(change) ,
  onQuietHoursChange?(next) , onConnectChannel?(channelId) , onRetry?,
  title / description / caption, eventColumnWidth, skeletonRows, className.
- The change object is { eventId, channelId, enabled, origin: "manual" |
  "bulk" } so the host can tell a single flip from one written by a bulk action.
- Two facts live in the DATA, not in view special cases: a channel that has no
  integration (connected: false) and an event that cannot be muted on a channel
  (requiredChannels + requiredReason).
- No callback means no affordance: without onPreferenceChange the matrix is
  read-only, without onConnectChannel a disconnected column draws no button,
  without onRetry the error state draws no retry.

Behavior
- SAVE IS IMMEDIATE, PER SWITCH. Flipping applies the new value optimistically,
  spins a loader inside that switch's knob, and calls onPreferenceChange.
  Rejecting (or throwing synchronously) rolls back THAT ONE SWITCH — never the
  matrix — marks it destructive with an alert glyph, and announces the failure;
  activating it again retries. Wrap the handler in
  `new Promise(resolve => resolve(handler(...)))` so a synchronous throw becomes
  a rejection instead of a switch that spins forever. Keep a per-cell request
  counter and drop a settled response whose counter is stale, so a slow
  rejection can't undo a newer intent.
- Locked cells: if requiredChannels includes the column, the cell renders
  checked, aria-disabled, with a lock in the knob and requiredReason wired
  through aria-describedby. Clicking states the reason instead of doing nothing.
  Use aria-disabled, never the native disabled attribute — a natively disabled
  control cannot be focused and cannot explain itself.
- Disconnected columns: cells still show the stored value (the user picked it
  before the integration went away) but cannot be toggled, and the column header
  carries connectHint plus a real Connect button wired to onConnectChannel. A
  greyed-out column with no way forward is a dead end.
- Bulk: each row header and each column header has an on/off pair. A bulk action
  targets only connected, unlocked cells, marks every cell it covers with
  origin="bulk", and issues one save per cell that actually changed — so a bulk
  edit inherits the same per-switch pending and the same single-cell rollback.
  A bulk-set cell shows a small dot (shape, not colour); flipping it by hand
  makes it "manual" and the dot disappears, so batch and hand-set values stay
  distinguishable.
- Quiet hours: one window with an enable switch and two time selects, saved as a
  unit with the same optimistic + rollback rule. The window may wrap midnight:
  membership is `start < end ? t >= start && t < end : t >= start || t < end`
  (half-open, so 07:00 ends the window). start === end is a zero-length window,
  not a 24-hour one. Time-zone responsibility belongs to the host: timeZone is a
  display label and `now` is the current wall clock ALREADY in that zone, so the
  component never reads the clock and render stays deterministic.
- Four first-class branches: loading (skeleton table with the same geometry as
  the real one), empty (also used when ready data has zero events or zero
  channels), error (message + optional retry), ready.
- One polite live region announces every outcome: a flip, a bulk summary
  ("… for 3 channels. Skipped Slack, Email."), a save failure, a refusal.

Rendering & styling
- A real <table>: <caption class="sr-only">, <th scope="col"> per channel,
  <th scope="row"> per event, one <td> per intersection. Each switch is a
  <button role="switch" aria-checked> whose accessible name is
  "<event> · <channel>" — never a bare checkbox — with lock / connect / bulk /
  failure detail attached via aria-describedby instead of stuffed into the name.
- Semantic tokens only: bg-card panel, bg-primary track when on, bg-input when
  off, bg-primary/40 + ring-border for an ON cell in a frozen column (dimmed but
  still legible — knob position alone is too quiet), bg-muted for an off one,
  ring-destructive + text-destructive for a failed save, text-muted-foreground
  for supporting copy.
- Column widths: give every channel <th> the SAME width suggestion (9rem) and
  cap the header content with max-w-40. Under auto table layout the column
  holding the longest header string otherwise takes several times its
  neighbours' share — measured 345px vs 187px before the cap.
- Narrow screens: the matrix does not fit on a phone, and it does not pretend
  to. The table scrolls horizontally inside an overflow-auto card with the event
  column pinned (sticky left, width min(15rem, 42vw) — 158px at 375px) and
  border-separate so the pinned column keeps its edge. Row bulk buttons appear
  from @md container width up, so they never eat the label column on a phone.
- prefers-reduced-motion turns off the knob transition and the spinner
  animation; the states themselves stay visible.

Customization levers
- Save semantics: this component is save-as-you-toggle. For an explicit-Save
  form instead, drop onPreferenceChange, keep the values in host state and
  render a footer with dirty-count + Save/Discard — but then delete the per-cell
  spinner, since pending would no longer be per cell.
- Density: py-2 cells / h-5 w-9 switches. Scale both together, or swap the
  switch for a checkbox with a check glyph if the matrix must fit more columns.
- Narrow-screen shape: replace the scrolling table below @md with one card per
  event listing its channels — render ONE of the two layouts, never both, or the
  hidden copy becomes an invisible keyboard trap and doubles the switches.
- Grouping: events render in array order; add a group field and emit a
  <th scope="colgroup"> section row to bucket them.
- Quiet hours: add a per-day array (weekday vs weekend), or per-channel windows,
  by lifting quietHours to a list and reusing isWithinQuietHours per entry.
- Bulk scope: setRow/setColumn currently skip locked and disconnected cells; to
  include a "reset to default" action, add a third bulk button that clears the
  local override instead of writing true/false.

Concepts

  • Save-as-you-toggle, scoped to one switch — each cell owns its request, its spinner and its rollback, so a backend that refuses one write can't discard the other thirty-one changes the user just made.
  • Stale-response guard — a per-cell counter is bumped on every flip; a response that comes back under an old counter is dropped, otherwise a slow rejection would undo a newer intent.
  • Affordance follows capability — no onPreferenceChange means read-only switches, no onConnectChannel means the disconnected column draws no button. A control that cannot honour a click is not drawn as clickable.
  • Data-declared impossibility — "can't be muted" (requiredChannels) and "no integration yet" (connected: false) are contract fields, so the view has one rule for each instead of a special case per event; both keep an accessible explanation attached to the cell.
  • Bulk provenance — a row/column action tags every cell it covers as origin: "bulk"; a later hand-flip promotes that cell to manual and drops the marker dot, so "I set this one deliberately" survives the next batch scan.
  • Midnight-spanning window — quiet hours is an arc on a 24-hour dial, so membership is a union when start > end; the host owns the time zone and passes the current wall clock in, keeping render pure and the fixture reproducible.

On This Page