Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowUpRight,
  Check,
  ChevronDown,
  LoaderCircle,
  Minus,
  RotateCw,
  TriangleAlert,
  Wrench,
  X,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/health-check-list.json

Prompt

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 decorationfix only renders while the row is actually unhealthy, and only when it has a real href or onFix behind 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.

On This Page