Blocks

Data Export Request

A GDPR-style data export block — pick scopes against live size estimates, confirm a request that states what the archive contains and who can open it, watch a build that never dresses a queue position up as progress, and download a link that visibly counts down to its own expiry.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Archive,
  CircleAlert,
  Clock,
  Database,
  Download,
  Hourglass,
  Info,
  ListChecks,
  LoaderCircle,
  RefreshCcw,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "DataExportRequest" block with zod,
lucide-react and the shadcn primitives Button, Badge and AlertDialog.

Contract
- A zod schema in a sibling contract file is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    scopes: { id, label, estimatedBytes, included, description?,
              unavailableReason? }[];
    current?: { id?, status: "queued" | "preparing" | "ready" | "failed",
                progress (0..1), queuedAhead?, readyAt?, expiresAt?, bytes?,
                scopeIds?, href?, error? };
    past: { id, requestedAt, scopes: string[], bytes, expiresAt, href? }[];
    linkTtlHours? }.
- `estimatedBytes` is an ESTIMATE (pre-compression) and `bytes` is the finished
  archive's REAL size. They are never presented as the same number: estimates
  are prefixed "≈" and say the finished file will differ.
- Instants (readyAt / expiresAt / requestedAt) are ISO strings, epoch ms or
  Dates — never pre-baked "in 5 hours" strings, so the wording is recomputed
  instead of going stale between the fetch and the paint.
- The block asserts NOTHING legal. No statutory deadline, no retention promise,
  no "your data is encrypted". It renders the instants, sizes and labels it is
  given; a `notice` slot takes the consumer's own legal copy, and the only
  window it will state before a request is `linkTtlHours`, when supplied.
- Props = z.infer of the schema plus: now? (injected clock);
  onRequest?(scopeIds); onDownload?(exportId | null); onRetry?;
  requestBlockedReason?; notice?; label? ("Export your data"); emptyState?;
  errorMessage?; formatBytes?; formatDuration?; formatAbsolute?;
  expiryWarningHours? (6); maxPast? (5); className plus the remaining element
  props, ref forwarded to the <section>.

Behavior
- A QUEUE POSITION IS NOT PROGRESS. While `status: "queued"` the component
  ignores `progress` even if the API sends one, and renders an INDETERMINATE
  progressbar — role="progressbar" with aria-valuetext but deliberately no
  aria-valuenow, which is the ARIA way to say "running, amount unknown". The
  panel says so in words too ("nothing has been built yet, so there is no
  percentage to show"), names the position ("3 exports ahead of yours" / "next
  in line") and labels `readyAt` as an estimate that moves with the queue. Only
  `preparing` gets a determinate bar and a percentage.
- CONSEQUENCES BEFORE THE CONFIRM. Requesting opens an AlertDialog that lists
  the picked scopes with their estimates, states the archive is built from the
  data as it stands right now, and states that anyone holding the download link
  can open the archive until it expires — plus the exact window when
  `linkTtlHours` is given, and otherwise a promise of nothing more than "the
  expiry is shown next to the link". onRequest fires at most once per confirm:
  the guard is a ref keyed to the current job's identity, because two clicks
  dispatched in one task both read the same stale state. The dialog outlives the
  state it was opened in — the block is polled — so the confirm re-reads the
  blocker against the data the click actually lands on, and refuses rather than
  queueing a second archive behind a card that already says one is being built.
- A BLOCKED ACTION EXPLAINS ITSELF. The request button is never a dead disabled
  control, and never an absent one: it renders whether or not a picker sits above
  it, because every "Request again" in the history leads to it and a re-request
  with nowhere to land would otherwise announce advice about a control that was
  not on screen. It takes aria-disabled (never the native attribute, which would
  drop the tab stop and with it the sentence explaining the blocker), keeps its
  aria-describedby pointing at that sentence, and answers EVERY click — not just
  the first — by re-announcing it. Blockers, in order: a job is already being
  prepared; the consumer's own `requestBlockedReason` (a rate limit, a pending
  identity check), which outranks the block's own optimistic "request sent"
  marker because a blocker is the ANSWER to the request that marker is waiting
  on; nothing available to pick at all; nothing picked. A scope with
  `unavailableReason` does the same at row level — it says who can export it
  instead of offering a checkbox that silently refuses to tick.
- THE SELECTION IS THE READER'S. Ticks are stored as OVERRIDES keyed by scope
  id; anything untouched falls back to that scope's own `included`. A poll that
  re-sends the same scopes therefore cannot untick a box mid-thought, while a
  scope that appears later still arrives with the default its owner chose.
- Re-request is a prefill, not a second request path: "Request again" on an
  expired export (or on a failed job) writes that export's scopes into the
  picker and opens the same confirmation. Scope ids that no longer exist are
  dropped and SAID SO ("2 of 3 kinds from that export can still be exported"),
  and the blocker for that click is recomputed from the selection just applied
  — never from the selection as it was a moment earlier.
- EXPIRY IS DERIVED FROM DATA, per row. `expiresAt <= now` flips a ready job or
  a history row to "expired": the download affordance disappears (a link that
  would 410 is worse than no link) and a re-request takes its place. Expiry is
  said of the LINK and nothing else — whether the archive itself is still kept
  is a retention claim only `notice` can make. The live
  link shows a countdown in a role="timer" — implicit aria-live="off", so it
  does not interrupt a screen reader every second — that turns destructive-
  coloured inside `expiryWarningHours`.
- THE BLOCK OWNS NO CLOCK when you inject one. Pass `now` and no timer runs at
  all, so SSR, a screenshot and a replayed poll agree. Leave it out and ONE
  interval ticks for the whole block, subscribed through useSyncExternalStore
  (server snapshot = "no clock", so hydration cannot disagree) and cleared both
  on unmount and before any resubscribe. With no clock at all, timestamps
  render as absolute UTC instants and expiry is not guessed at.
- Percentages that partition the estimate use LARGEST REMAINDER in tenths of a
  percent: exact per-mille shares, floored, with the leftover units handed to
  the largest fractional parts (ties to the larger value, then the earlier
  index). Every selection totals exactly 100.0%, and the bar's segment widths
  are drawn from those same integers so the picture and the labels cannot
  disagree. One scope in a real account dwarfs the others, which is why tenths
  and not whole percents.
- Downloads are honest: an `href` renders a real <a download>, otherwise
  `onDownload` renders a button, and with neither the control is simply absent.
  History is capped at `maxPast` with an explicit "Showing 5 of 9" note — never
  a silent cut, and an empty history says nothing has EVER been built only when
  there is no current job either, because a ready archive three lines above turns
  that sentence into a contradiction. `status: "ready"` with no scopes, no job
  and no history renders the empty branch rather than a picker with zero
  checkboxes.

Rendering & styling
- Semantic tokens only: bg-card / border for the shell, bg-muted/30 for the job
  panel, bg-primary with descending alpha (75/55/35/20) for the composition
  segments — ramps of ONE hue, because the segments are parts of one archive
  and a rainbow would read as unrelated things — bg-muted for tracks and
  skeletons, text-destructive for a failing job and an expiring link,
  accent-primary on the native checkboxes. No hard-coded colours; the only
  inline styles are segment and progress widths.
- Every animation is decorative and guarded by motion-reduce: the queued pulse,
  the preparing spinner, the width transitions, the skeleton shimmer. The
  countdown is not an animation and keeps running.
- One persistent sr-only role="status" line carries request, blocker and
  re-request outcomes, stamped with a change token so re-announcing the SAME
  sentence still mutates the node instead of being swallowed by React's state
  bail-out; the confirmation returns focus to the request button via
  onCloseAutoFocus, because the dialog has no trigger for Radix to restore to.
- Sizes and countdowns are tabular-nums; long scope labels and worker errors use
  min-w-0 + wrap-anywhere (not break-words: only the former lowers min-content
  width) and are never clamped. The whole block reads at 420 px.

Customization levers
- Scope list: any number of scopes, in any order; add `description` for a line
  of explanation and `unavailableReason` to state a per-row blocker. The
  composition bar and every total follow the data — nothing is hardcoded.
- Units and wording: `formatBytes` swaps SI for binary multiples or another
  locale, `formatDuration` swaps the countdown wording, `formatAbsolute` owns
  the timestamps and the no-clock fallback.
- Urgency: `expiryWarningHours` decides when a live link turns destructive;
  raise it for short-lived links, drop it to 0 to never shout.
- Density: `maxPast` trims the history (the "showing N of M" note appears by
  itself); drop the composition bar or the description lines for a compact
  settings-page variant without touching the request logic.
- Policy: `requestBlockedReason` closes the request path with your own sentence;
  `linkTtlHours` is the only window the confirmation will state; `notice` is
  where your retention policy, statutory deadline and contact belong.
- Live time: pass a `now` you tick once per second app-wide instead of letting
  each block own an interval — or pass a frozen instant for tests and
  screenshots.

Concepts

  • A queue position is not progress — the honest read while queued is "nothing has been built yet", so the bar goes indeterminate (role="progressbar" without aria-valuenow) and the number on offer is a position in a line, not a percentage. A bar that creeps while a job hasn't started is the single most common lie on an export page, and it teaches people to distrust the one that follows.
  • Consequences before the confirm — the dialog says what the archive contains, that it is a snapshot of right now, and that whoever holds the link can open it until it expires. That last sentence is the actual risk of a data export, and it belongs in front of the request rather than in a help article afterwards.
  • A blocked button keeps its tab stop — blockers are aria-disabled plus a written reason wired through aria-describedby, never the native disabled attribute: disabling removes the control from the tab order and takes the explanation with it, leaving a keyboard reader with a button that cannot be reached and a reason that cannot be found.
  • Largest remainder, to a tenth — one scope in a real account dwarfs the rest, so whole percents would round the small ones to 0 and the labels would stop adding up. Exact per-mille shares are floored and the leftover units go to the largest fractional parts, which makes every selection total exactly 100.0% — and the segment widths reuse those integers, so the picture cannot drift from the caption.
  • Expiry is data, not a hope — every link carries an instant, so a history row can say "expired" on its own authority, withhold a download that would 410, and offer a re-request instead. The re-request is a prefill into the same confirmation, so there is exactly one path that can create an export.
  • No clock, and no claims — instants in, wording out: inject now and the block starts no timer at all; leave it out and one useSyncExternalStore interval serves the whole block and dies with it. The same restraint applies to language: retention windows, statutory deadlines and contacts arrive through notice, because a component cannot know what your DPA promises.

On This Page