Data Controls
An AI privacy panel where every control saves on its own — a training consent switch with a plain-language consequence line, a retention scale that warns when it deletes nothing, an export job with progress, and a type-to-confirm deletion.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/data-controls.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DataControls" component with lucide-react
and the shadcn alert-dialog, button and input primitives. It is the privacy block
of an AI product's settings page: consent, retention, export, deletion.
Contract
- forwardRef<HTMLElement>, root <section>, extends React.HTMLAttributes<HTMLElement>
minus title / defaultValue / onChange, remaining props spread onto the root.
- value?: { training: boolean; retention: string } — the SAVED settings.
defaultValue?: same shape for an uncontrolled panel.
- onTrainingChange?: (enabled: boolean) => void | Promise<void>
- onRetentionChange?: (retentionId: string) => void | Promise<void>
Both are IMMEDIATE saves. Resolving means persisted; rejecting (or throwing)
rolls back THAT control only. Omitting one makes that control a readable,
focusable, non-operable display — never a switch with nowhere to report.
- retentionOptions?: { id, label, effect, warn? }[] — default 30 days / 90 days /
Keep forever, the last one flagged `warn`. `effect` is the plain-language line
shown under the scale. [] hides the row.
- trainingOnEffect / trainingOffEffect: ReactNode — the two consequence lines.
- Export is a SERVER JOB, so it is prop-driven, not owned: exportState?: "idle" |
"preparing" | "ready" | "error"; exportProgress?: number (0…1, undefined means
indeterminate); exportHref?, exportFilename?, exportSizeLabel?,
exportExpiresLabel?, exportErrorMessage?; onExport?, onCancelExport?.
Every user-visible size and expiry is a PRE-FORMATTED string: the panel formats
no bytes and owns no clock, so the server, the browser and a screenshot fixture
always agree.
- onDeleteAll?: () => void | Promise<void>; deleteSummary? (pre-formatted scope,
e.g. "1,284 chats · 12,481 messages"); deleteConfirmPhrase? (default "DELETE");
deleteCaveat? (default: "Anything already used to train a model is not recalled
by this.", null hides it); deleteErrorMessage?.
- title?, description?, showExport? (true), showDelete? (true), readOnly?,
disabled?, className.
Behavior — one save per control, never one Save button
- Consent, retention, export and deletion answer four different questions and
must not be bundled: consent has to land the moment it is given, and a delete
must never ride along with a retention tweak that happened to be in the draft.
- Optimistic overlay: a sparse Partial<value> layered over the baseline. Show the
new value at once, mark that control "saving", then on resolve flash a per-
control "Saved" tick that retires itself after ~2.4s, or on reject restore the
value the control had BEFORE the change and show a failed line naming what is
still in effect ("Chat history is still kept: 90 days").
- Per-control sequence counter: a rejection whose seq is no longer the current one
is IGNORED. Without it a slow first rejection undoes a fast second intent.
- Baseline movement (a refetch, another tab, an admin) is followed by every
control that is NOT mid-flight; a control with an open request keeps what the
user asked for, because the answer to that request has not arrived yet. Do this
by adjusting state during render against a previous-baseline signature — never
in an effect, or there is a frame showing the old account's overlay.
- Wrap each handler as new Promise(resolve => resolve(fn())), not
Promise.resolve(fn()): the latter cannot catch a handler that throws
synchronously and the control would spin forever.
Behavior — retention scale
- role="radiogroup" + role="radio" with aria-checked: one value, three exclusive
answers. A toggle bar would announce three independent pressed states for it.
- One roving tab stop, Arrow keys wrapping both ways, Home/End, selection follows
focus. Because selection follows focus, arrowing across the scale would fire one
request per rung: show the new value immediately but COALESCE the write behind a
~320ms trailing timer; pointer clicks and Space/Enter bypass it and commit at
once. Sweeping back to the rung you started on cancels the pending write
entirely — nothing is sent and there is nothing to roll back.
- Measure every change against the rung the SERVER holds or is on its way to —
mid-sweep that is where the sweep started, otherwise it is the overlay on
screen, because the overlay always mirrors the last request. That value is both
the "is this worth sending" test and the rollback target. Measuring against the
value the interaction originally started from instead drops the write that
walks a committed change back: the panel would read 30 days while the account
quietly stayed on 90. For the same reason, flipping the consent switch twice
sends twice — the second request is the one that corrects the server.
- The selected option's `effect` sentence is rendered under the scale and is the
group's aria-describedby. The `warn` option (the one that deletes nothing)
switches that line to the destructive tone WITH a warning icon, and the rung
itself carries the same icon: colour never carries meaning alone.
Behavior — export
- The panel renders a job it does not run. idle → a Request export button;
preparing → a role="progressbar" (aria-valuenow + aria-valuetext when a
percentage is known, an indeterminate sweep with aria-valuetext="Preparing"
when it is not) plus a Cancel only when onCancelExport exists; ready → the
filename, the pre-formatted size and expiry, a download <a> rendered ONLY when
exportHref is set (a download button pointing nowhere is a lie) and a "Build a
new one" that queues another job; error → the message plus a Try again that
calls the same onExport.
- Guard onExport while a job is preparing; two jobs mean two archives and two
links. Announce ready and error transitions once, by comparing the previous
prop during render.
Behavior — delete all history
- An AlertDialog holding: what disappears (a short list), the pre-formatted scope,
the caveat about training, and a type-to-confirm input. Compare after trim and
whitespace collapse, case-sensitive; paste is allowed, since blocking it stops
nobody and only annoys the careful.
- The confirm button is a PLAIN destructive Button, not AlertDialogAction:
the action closes the dialog on click and this one has to stay open while the
request runs. Cancel stays an AlertDialogCancel.
- One-shot gate: a ref read and written synchronously inside the click handler.
Several clicks dispatched in one task all observe the same stale state, so a
state-only guard would fire the deletion twice. Release it when the promise
settles.
- While the request is open the dialog does not close: swallow onOpenChange and
preventDefault the Escape key. On resolve, close, clear the phrase and leave one
confirmation line in the row for a few seconds. On reject, keep the dialog, keep
the typed phrase, show the reason and re-arm the button.
- Let the dialog's own auto-focus land on Cancel (the Radix AlertDialog default):
the destructive path should never be one keystroke away.
Behavior — a11y, states, cleanup
- Blocked actions use aria-disabled + a handler guard, never the native `disabled`
attribute: `disabled` blurs the control the instant it flips, dropping focus to
<body> mid-action, and removes it from the tab order while it is still worth
reading. Restore the look with aria-disabled:opacity-50.
- readOnly renders a "Managed by your workspace" lock badge and refuses every
write while keeping values readable and keyboard-reachable; a read-only group
still MOVES focus with the arrow keys, it just does not announce a change it
will not make. disabled dims the panel and refuses everything.
- One sr-only role="status" region for the whole panel, keyed by a counter so the
same sentence twice (two failed retries) is announced twice. Every refusal says
what is still true, not just that it failed.
- The only timers are the saved ticks and the coalesced retention write. Clear
every one of them on unmount; nothing else schedules anything.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for the chrome,
bg-primary + text-primary-foreground for the switch and the selected rung,
bg-muted/60 for the scale track, bg-muted for the progress track,
bg-destructive/5 behind the danger row and bg-destructive/10 for the dialog
glyph, text-destructive for the "deletes nothing" warning and every failure. No
hex, no rgb(), no oklch().
- Rows are separated by border-t and each one is label + consequence line on the
left, control on the right, wrapping to two lines under ~400px via flex-wrap
and a basis-48 text column.
- Long custom labels use wrap-anywhere plus min-w-0, not break-words: only the
former lowers the element's min-content width, which is what stops a 90-
character period name from making the panel wider than its container.
- The indeterminate sweep ships as a hoisted <style href precedence> keyframe and
is motion-reduce:[animation:none] with a still full-width bar underneath; the
spinners are motion-reduce:animate-none and the ticks fade in under motion-safe
only. Nothing about the panel stops working when motion is off.
- cn() merges the consumer's className into the root.
Customization levers
- The scale is data: pass 7 days / 30 days / 1 year / forever, re-word every
`effect` for your jurisdiction, and move the `warn` flag to whichever rung your
privacy notice considers the risky one.
- Wording is the product here: trainingOnEffect / trainingOffEffect are the two
sentences a user actually reads before consenting — write them for your data
policy, not for your API. deleteCaveat is where the honest footnote goes.
- Density: showExport={false} / showDelete={false} / retentionOptions={[]} strip
the panel down to any subset; title={null} and description={null} drop the
header for embedding under an existing settings heading.
- Save model: leave a change handler out and that control becomes a read-only
display of the saved value; readOnly does it for the whole panel, disabled for
the whole panel plus the dimming.
- Confirmation strength: deleteConfirmPhrase can be the workspace name for a
GitHub-style ceremony, or a short word for a low-stakes account. Raise the
coalesce delay if your retention endpoint is expensive, or drop it to 0 to send
one request per rung.
- Export presentation: the job is yours, so poll, stream or websocket it and feed
exportProgress; pass no progress at all for a sweep, or wire exportHref to a
signed URL and let the browser own the download.Concepts
- A save per control, not a form — consent, retention, export and deletion answer four different questions, so each one commits on its own and reports on its own. A single Save button would make "stop training on my content" wait for an unrelated field to be valid, and would bundle a deletion with a tweak the user never meant to send together.
- Optimistic overlay with single-control rollback — the new value is on screen before the request leaves, and a refusal restores exactly one control to what it was, naming what is still in effect. Every other control keeps its own value and its own in-flight request.
- Superseded writes are ignored — each control carries a sequence number, so a slow rejection that arrives after a newer intent is dropped instead of undoing it. Without that guard, a fast double flip settles on whichever response happens to land last.
- Selection follows focus, the write does not — the retention rungs are a real radiogroup, so arrowing moves the selection as it moves focus; the request is held back by a short trailing delay so a sweep across the scale sends one write, and sweeping back to where you started sends none at all. What "where you started" means is the rung the server holds or is already on its way to, not the one the interaction began with — otherwise walking a committed change back would show the new value and send nothing.
- The consequence line is the control — each state of the switch and each rung of the scale carries a sentence about what it does to the data, rendered under the control and referenced by aria-describedby, so the choice can be understood without a tooltip, a docs link or a support ticket.
- Type-to-confirm behind a one-shot gate — erasing everything asks for the phrase to be typed, keeps the dialog open while the request runs, and locks the confirm button through a ref read synchronously inside the handler; a rejection keeps the typed phrase, explains itself and re-arms the button instead of stranding a dead dialog.
BYOK Setup
A bring-your-own-key card — configurable provider slots, a masked field that cleans what you paste and checks the shape per provider, a cancellable live test with latency or a reason, a storage disclaimer that never leaves, and removal behind a confirm.
Chat Import
A staged importer for assistant exports — reads ChatGPT, Claude and JSON-Lines files, previews the conversations it recovered with per-item checkboxes, and ends on a summary that names every skipped item and why.