Health Check List
A self-diagnostics panel — one independently re-runnable probe per row, shape-plus-word statuses, expandable evidence, real fix actions and a counts-and-verdict summary.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/health-check-list.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "HealthCheckList" component — the panel a
CLI doctor, an installer's "system requirements" step or an integration status
page shows: one row per environment / dependency / config probe, each probe
independently re-runnable, with the blocking ones spelling out what to do about
it. lucide-react for icons, cn() (clsx + tailwind-merge) for classes, no other
dependency.
Contract
- export const HealthCheckList = React.forwardRef<HTMLDivElement, HealthCheckListProps>,
remaining props spread onto the root <div>. Props: checks: HealthCheck[];
label? (default "Health checks", the accessible name of the list); showSummary?
(default true); onRunAll?: () => void; onRetry?: (id: string) => void;
runAllLabel? (default "Run all"); defaultExpandedIds?: string[]; className.
- HealthCheck = { id: string; label: string; description?: string; status:
"pending" | "running" | "passed" | "failed" | "warning" | "skipped"; detail?:
string; fix?: { label: string; href?: string; onFix?: () => void };
run?: () => CheckResult | Promise<CheckResult> } where CheckResult is either
the bare outcome string ("passed" | "failed" | "warning" | "skipped") or
{ status, detail? }.
- `description` is the always-visible line under the label (what this probe
looks at); `detail` is the evidence and only ever appears behind a disclosure.
- Two ways to drive it, and they compose. Controlled: the consumer owns every
`status` and gets `onRunAll` / `onRetry` callbacks. Self-driving: a check
carries `run`, and the component owns that row's pending -> running -> result
transition. A row's run result is only honoured while `check.status` is still
the value the run started against — the moment the consumer reports something
new for that id, the consumer wins. Both callbacks always fire, so you can mix.
- Duplicate ids are dropped, first occurrence wins: they would collide on React
keys and on the aria-controls wiring, so toggling one detail panel would toggle
its twin.
Behavior
- Running a probe. Each start takes a monotonic ticket and writes { ticket,
base: check.status, status: "running" } for that id only, so eight concurrent
probes land out of order and each one writes only its own row. On settle the
result is dropped unless (a) the component is still mounted — a probe can
resolve long after the panel is gone — and (b) the row's ticket is still the
one this attempt took. That second guard is what stops a slow first attempt
from overwriting the fresh verdict of the restart that overtook it.
- The probe is wrapped as `new Promise(resolve => resolve(check.run()))`, NOT
`Promise.resolve(check.run())`: a probe that throws synchronously escapes the
latter before it is wrapped and the row would spin forever. A rejection becomes
status "failed" with the error message as the detail.
- "Run all" starts every check that has a `run`, in one batch, and is
aria-disabled while anything is in flight. The per-row control is deliberately
NOT disabled while that row runs — it reads "Restart" and takes a new ticket,
because the point of a per-row control is to poke one probe again, including
one that is stuck.
- Both controls use aria-disabled, never the native `disabled` attribute: the
attribute takes effect in the same tick as the click that caused it, and the
browser blurs the button the user just pressed, dumping keyboard focus on
<body> with nowhere to hand it to.
- Status is carried by shape AND word, never hue: empty ring = "Not checked",
spinner = "Checking", check = "Passed", cross = "Failed", triangle = "Warning",
dash = "Skipped". A monochrome palette carries almost no information in colour
alone, so the word next to each row is the truth and the glyph is the scan.
- Summary header: a counts line ("5 passed · 1 warning · 2 failed", only non-zero
buckets, "checking"/"pending" included while a run is in flight) plus one
verdict line, in priority order: still running -> "Checking your environment…";
any failure -> "2 blocking issues"; any warning -> "Ready, with 1 warning";
anything unrun -> "Not checked yet" / "3 checks not run yet"; otherwise "All
checks passed" (or "Every check was skipped" when nothing actually ran).
- Evidence: any row with `detail` gets a real <button aria-expanded aria-controls>
and a monospace panel. The panel is hidden with the `hidden` attribute, not a
0fr grid collapse — a collapsed grid row still holds its tab stops, so the
panel would become an invisible keyboard trap. Long lines wrap
(whitespace-pre-wrap + break-words) and the panel scrolls at max-h.
A run that reports no detail clears the previous one rather than falling back
to it: a fresh pass must not keep showing the stack trace it just disproved.
- Remedies: `fix` renders only while the row is failed / warning / skipped —
never on a row nobody has run yet (that is a guess) and never on a passing one
(that is noise). `href` renders an <a>, `onFix` renders a <button>; with
neither, nothing is rendered, so there is never a control that does nothing.
- Announcements: ONE persistent sr-only role="status", mounted from the first
paint (a live region created at the same moment as its text is usually not
announced at all). It is written only when the *phase* of the whole panel
changes — idle -> running -> settled — after a ~400ms quiet window, and the
sentence is captured at the moment of the flip ("Running 8 checks…", then
"Finished: 5 passed, 1 warning, 2 failed."). Per-row announcements are what
make this widget unusable on a screen reader, so rows never speak. The first
paint is the baseline and is never announced.
- Empty checks[] renders a plain "No checks configured." line with the live
region still mounted.
Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground (passed node),
bg-destructive / text-background (failed node), border-foreground (warning
node), bg-muted + text-muted-foreground (skipped), border-muted-foreground/30
(unchecked ring), ring-primary/20 (running halo), border-destructive/40 +
bg-destructive/5 (failure panel), ring-ring for focus-visible. No hex, rgb() or
oklch(); no chart tokens on text.
- A11y skeleton: <ul role="list"> with <li> as DIRECT children (a role-less div
in between makes screen readers announce an empty list). The per-row buttons
carry an aria-label that appends the check's label ("Retry Node.js 20 or
newer") — eight buttons all called "Retry" is a useless list on a screen
reader — while keeping the visible words inside the accessible name so WCAG
2.5.3 still holds. Spinners are aria-hidden; the word "Checking" is the
accessible truth, and under prefers-reduced-motion the spinner stops rotating
while the row still reads as running.
- Long labels and unbreakable tokens wrap instead of widening the card: the text
column is min-w-0 + break-words, the status word is shrink-0. Without min-w-0 a
flex child defaults to min-width:auto and a 57-character secret pushes the
status word straight out of the card.
- Motion budget is two things — a rotating loader and a chevron flip — both
motion-reduce:*-none. Nothing depends on animation.
Customization levers
- Vocabulary: STATUS_TEXT maps each status to its word; translate it or say
"OK / Missing / Degraded" to match your platform. The verdict and announcement
sentences are built in two small pure blocks near the top of the render.
- Iconography and severity: NODE_CLASS + StatusGlyph are two lookup tables. Give
warning its own token if your theme has one, swap the check for a filled dot,
or drop the ring border for a bare glyph column.
- Density: size-6 nodes with py-3 rows and a divide-y list is the default; drop
to size-4 / py-2 for a sidebar, or pass showSummary={false} when the page
already has its own header and you only want the rows.
- Concurrency: "Run all" fires every probe at once. Chunk the loop if your
probes hit the same rate-limited endpoint — the per-row ticket guard already
makes any arrival order safe.
- Chatter: the ~400ms quiet window is one constant; raise it for very chatty
runners, or drop the region entirely if the surrounding page already announces
job state.
- Empty state: the "No checks configured." branch is one line — replace it with
your own empty panel or render nothing at all.Concepts
- Per-row probe — every check owns its own async life. Eight probes started together land out of order and each one writes only its row, so the list recounts continuously instead of freezing behind the slowest check.
- Ticket guard — each start takes a monotonic ticket; an answer is thrown away unless its row still holds that ticket (and the panel is still mounted). That is what makes "Restart" safe: the abandoned attempt cannot resurrect its stale verdict a second later.
- Reported status wins — a run result only speaks for the status it was started against, so a component-driven row and a server-driven row can live in the same list without either one clobbering the other.
- Shape plus word — ring / spinner / check / cross / triangle / dash, each spelled out next to the row. Under a monochrome palette hue carries almost no information, and the word is also what a screen reader and a colour-blind reader get.
- Blocking vs advisory — the verdict line ranks failures above warnings above unrun checks, so the header answers "can I start?" before you read a single row.
- Remedy, not decoration —
fixonly renders while the row is actually unhealthy, and only when it has a realhreforonFixbehind it; a button that does nothing is worse than no button. - Phase-only live region — one persistent
role="status"that speaks twice for an eight-check run (start, finish) rather than once per row. Row-level chatter is the classic way this kind of panel becomes unusable on a screen reader.
Inline Error Summary
A top-of-form error summary where every row scrolls to and focuses the field it names — persistent live region, self-focusing panel, and an onNavigate hook for fields inside collapsed sections.
Status Timeline
A read-only progress thread for an async run — icon plus word per stage, a live clock on the running one, expandable failures and a settled-run summary.