AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Download, Loader2, Lock, RotateCcw, Trash2, TriangleAlert } from "lucide-react"

import {
  AlertDialog,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogMedia,
  AlertDialogTitle,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/data-controls.json

Prompt

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.

On This Page