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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/notification-preferences.jsonPrompt
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
onPreferenceChangemeans read-only switches, noonConnectChannelmeans 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 tomanualand 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.
Billing History
A four-state invoice list — per-currency money through Intl, a six-state lifecycle with partial-refund arithmetic, real filters, pagination, and PDF links that never render as dead buttons.
Two-Factor Setup
The enable-2FA flow built around its one irreversible step — recovery codes are rendered once, the finish button refuses to fire until they are saved, and finishing wipes them from the DOM.