Record List
A numbered list of the records a change affects, with a Show more footer, group headings and a "what differs" table for conflicts.
- Status
- Since
0.23.54- Accessibility pattern
- ordered list, named new-tab links
Last updated
Install
Add Record List from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/record-listThe same command installs the registry items it composes: @vegastack/button, @vegastack/table.
Usage
import {
RecordList,
RecordListItem,
RecordListMore,
} from "@/components/ui/record-list";
<RecordList aria-label="Affected products">
{rows.map((row) => (
<RecordListItem
key={row.id}
icon={<Package />}
title={row.name}
badge={<StatusBadge status={row.status} />}
description={row.newIssue}
href={productHref(row.id)}
/>
))}
</RecordList>
<RecordListMore remaining={total - rows.length} onShowMore={loadMore} />;Scope
| Behaviour | Where it lives |
|---|---|
| The rows, the total, the paging | Host — RecordListMore is a controlled footer |
| Selectable or actionable rows | Item or DataList |
| The dialog around it | Dialog — the list sits in its DialogBody scroll body |
| Tabs with counts over the list | Tabs + RecordTabCount — see Review list dialog |
| The error around a conflict | Alert — see Conflict message |
Anatomy
Examples
Affected records
Each row is numbered, then a muted record-type icon, the name, a small ↗ link right after the
name, an optional badge and an optional description line. The ↗ opens the record in a new tab —
named Open {name} in a new tab — and shows on row hover or focus within the row, and always on
a touch screen.
Show more
RecordListMore says how many records are not shown and offers "Show more". The host appends the
next batch to the same RecordList, so the numbers continue — 11., 12., … — instead of starting
again.
Review list dialog
The pattern for "these records will change — check them first": a Dialog (size="lg") with a
title and one plain line, pill Tabs whose triggers each carry a count (RecordTabCount), a
numbered RecordList per tab inside DialogBody — the one region that scrolls, so the title,
tabs and footer hold still — and a footer of Cancel plus the primary or destructive action. The
second line (description) says why a record is in its tab ("Edited by Ravi", "3 products").
Give the Tabs root min-h-0 so the body can scroll inside the dialog.
<DialogContent size="lg">
<DialogHeader>
<DialogTitle>Undo this run?</DialogTitle>
<DialogDescription>
758 draft products will be removed. 6 stay because someone changed them.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="remove" className="min-h-0">
<TabsList>
<TabsTrigger value="remove">
Will be removed <RecordTabCount count={758} />
</TabsTrigger>
<TabsTrigger value="stay">
Will stay <RecordTabCount count={6} />
</TabsTrigger>
</TabsList>
<DialogBody>
<TabsContent value="remove">
<RecordList aria-label="Products that will be removed">…</RecordList>
</TabsContent>
<TabsContent value="stay">
<RecordList aria-label="Products that will stay">
<RecordListItem
icon={<Package />}
title={name}
description="Edited by Ravi"
href={href}
/>
</RecordList>
</TabsContent>
</DialogBody>
</Tabs>
<DialogFooter>
<DialogClose render={<Button variant="secondary" />}>Cancel</DialogClose>
<Button variant="destructive">Remove 758 products</Button>
</DialogFooter>
</DialogContent>Conflict message
When a save is refused because a record clashes with one that exists, say so plainly: a
destructive Alert with a title ("This product already exists"), one line saying what to do,
the clashing record in a numbered RecordList with its ↗ link, and — optionally — RecordDiff,
the compact "what differs" table (spec | this product | existing product). RecordDiff marks
the rows whose values differ (a tinted row in full ink, and "differs" for screen readers) and
keeps matching rows muted, so it is clear why the two count as the same. The same composition
works inline under a field. Give AlertDescription min-w-0 so long names truncate and the
table scrolls sideways instead of widening the alert on a phone.
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>This product already exists</AlertTitle>
<AlertDescription className="flex min-w-0 flex-col gap-3">
<p>
Every choice matches a product in this family. Change at least one choice
to save it.
</p>
<RecordList aria-label="Matching product">
<RecordListItem
icon={<Package />}
title={existing.name}
href={existing.url}
/>
</RecordList>
<RecordDiff
columns={["This product", "Existing product"]}
rows={specs.map((s) => ({ label: s.name, values: [s.mine, s.theirs] }))}
/>
</AlertDescription>
</Alert>Grouped conflicts
When one change would make several sets of records identical — a settings save that removes the
spec that told them apart — list each set under RecordListGroup, a short muted heading ("Group
1 · 3 products") that also names its list for assistive technology.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | Accessible name of the list ("Affected products"). |
Data attributes and CSS variables on RecordList
| Attribute | Values |
|---|---|
data-slot | "record-list" |
RecordListItem
| Prop | Type | Default | Description |
|---|---|---|---|
title* | React.ReactNode | — | The record's name. |
badge | React.ReactNode | — | A status after the name and its link — a Badge. |
description | React.ReactNode | — | One line under the name — what happens to this record. |
href | string | — | The record's page. Adds a ↗ link after the name that opens it in a new tab. |
icon | React.ReactNode | — | The record type's icon, drawn muted before the name. |
openLabel | string | `Open ${title} in a new tab` when `title` is a string, else "Open in a new tab" | Accessible name of the ↗ link. |
Data attributes and CSS variables on RecordListItem
| Attribute | Values |
|---|---|
data-slot | "record-list-description" | "record-list-icon" | "record-list-item" | "record-list-link" | "record-list-title" |
RecordListMore
| Prop | Type | Default | Description |
|---|---|---|---|
remaining* | number | — | How many records are not shown yet. Nothing renders at zero. |
label | string | "Show more" | The button's label. |
loading | boolean | false | A batch is loading: the button shows its spinner. |
onShowMore | (() => void) | — | Load the next batch. Without it the footer only says how many more there are. |
Data attributes and CSS variables on RecordListMore
| Attribute | Values |
|---|---|
data-slot | "record-list-more" |
RecordListGroup
| Prop | Type | Default | Description |
|---|---|---|---|
title* | React.ReactNode | — | The group's heading ("Group 1 · 3 products"), which also names it for assistive technology. |
Data attributes and CSS variables on RecordListGroup
| Attribute | Values |
|---|---|
data-slot | "record-list-group" | "record-list-group-title" |
RecordDiff
| Prop | Type | Default | Description |
|---|---|---|---|
columns* | readonly React.ReactNode[] | — | The record columns' headings ("This product", "Existing product"). |
rows* | readonly RecordDiffRow[] | — | The specs to compare, one row each. |
differsLabel | string | "differs" | Read after a differing row's label by screen readers. |
labelHeading | React.ReactNode | "Spec" | The first column's heading. |
Data attributes and CSS variables on RecordDiff
| Attribute | Values |
|---|---|
data-differs | "" |
data-slot | "record-diff" | "record-diff-row" |
| Prop | Type | Default | Description |
|---|---|---|---|
label* | React.ReactNode | — | The spec's name ("Beam angle"). |
values* | readonly React.ReactNode[] | — | One value per column, in column order. An empty value reads "—". |
differs | boolean | the values compared as text | Whether the values differ. By default, rows whose values are all strings or numbers are compared as text; pass it for anything else. |
key | string | — | Stable key; defaults to the label when it is a string. |
Accessibility
- The list is an
<ol>, so a screen reader announces each record's position; name it witharia-label. - The record-type icon is decorative (
aria-hidden); the name carries the meaning. - The ↗ link is a real link with its own name,
Open {name} in a new tab, and is reachable by Tab even while it is visually hidden — focusing it shows it. RecordListGroupis arole="group"named by its heading.RecordDiffis a real table with column headers; a differing row reads its label followed by "differs", so the mark is not colour alone.
| Key | Action |
|---|---|
| Tab | Move to the next record's ↗ link. |
| Enter | Open the record in a new tab. |
| Contract | States tested |
|---|---|
| Behaviour | default, link, description, badge, more, no-more |
| Accessibility | labeled, semantic-html, browser-accessibility-test |
| Visual | default, hover, coarse-pointer |