feat: add data table and release checks

This commit is contained in:
2026-03-19 20:10:36 +08:00
parent d5e4d5ece3
commit 3f77070802
12 changed files with 2157 additions and 86 deletions
+25 -1
View File
@@ -6,8 +6,29 @@ The repo is still an internal/private monorepo, so Changesets is used conservati
- track version intent for releasable workspace packages
- keep release notes attached to code changes
- avoid auto-committing release files
- avoid tagging private packages during early internal iteration
- let CI verify release intent before package work merges
## CI behavior
This repo now has two release-adjacent workflows:
- `Changeset Status` runs on pull requests and checks whether changes to releasable packages
include a `.changeset/*.md` entry.
- `Release Version PR` runs on `main` and opens or updates a version PR by running
`pnpm changeset version`.
The PR check intentionally focuses on the releasable packages in scope today:
- `@ai-ui/ui`
- `@ai-ui/tokens`
If a PR touches those packages but should not create a release note, apply the
`no-changeset-needed` label. This is the escape hatch for work such as:
- docs-only follow-up near a package
- test-only changes
- internal refactors with no consumer-visible behavior change
## What should get a changeset
@@ -48,4 +69,7 @@ flow is enabled, treat Changesets as the source of truth for:
- which changes deserve release notes
- which internal dependency bumps should be coordinated
The current automation stops at version intent and version PR creation. It does not publish to a
registry, create tags, or assume any package registry credentials exist yet.
See `docs/releasing.md` for the expected workflow around creating and consuming changesets.
+5
View File
@@ -0,0 +1,5 @@
---
"@ai-ui/ui": minor
---
Add the first DataTable implementation with sorting, selection, search, pagination, loading, empty states, and row actions.
+101
View File
@@ -0,0 +1,101 @@
name: Changeset Status
on:
pull_request:
types:
- opened
- reopened
- synchronize
- labeled
- unlabeled
- ready_for_review
permissions:
contents: read
jobs:
changeset-status:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.25.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Require a changeset for releasable package changes
shell: bash
run: |
set -euo pipefail
if [[ ",${PR_LABELS}," == *",no-changeset-needed,"* ]]; then
echo "Skipping changeset requirement because the PR is labeled no-changeset-needed."
exit 0
fi
changed_files="$(git diff --name-only "origin/${BASE_REF}...HEAD")"
if [[ -z "${changed_files}" ]]; then
echo "No changed files detected."
exit 0
fi
echo "Changed files:"
printf '%s\n' "${changed_files}"
needs_changeset=false
has_changeset=false
while IFS= read -r file; do
[[ -z "${file}" ]] && continue
case "${file}" in
.changeset/*.md)
if [[ "${file}" != ".changeset/README.md" ]]; then
has_changeset=true
fi
;;
packages/tokens/*)
needs_changeset=true
;;
packages/ui/package.json)
needs_changeset=true
;;
packages/ui/src/test/*|packages/ui/src/**/*.test.ts|packages/ui/src/**/*.test.tsx)
;;
packages/ui/src/*)
needs_changeset=true
;;
esac
done <<< "${changed_files}"
if [[ "${needs_changeset}" != "true" ]]; then
echo "No releasable package changes detected."
exit 0
fi
if [[ "${has_changeset}" != "true" ]]; then
echo "::error::Changes to @ai-ui/ui or @ai-ui/tokens require a changeset file under .changeset/."
echo "::error::If this PR is intentionally docs-only, test-only, or otherwise non-releasable, apply the no-changeset-needed label."
exit 1
fi
- name: Validate pending changesets
run: pnpm changeset:status --since "origin/${BASE_REF}"
+47
View File
@@ -0,0 +1,47 @@
name: Release Version PR
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: release-version-pr
cancel-in-progress: false
jobs:
version-packages:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.25.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Open or update version PR
uses: changesets/action@v1
with:
version: pnpm changeset version
commit: "chore: version packages"
title: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+527 -62
View File
@@ -6,12 +6,17 @@ import {
CardDescription,
CardHeader,
CardTitle,
DataTable,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
EmptyStateActions,
EmptyStateDescription,
@@ -59,6 +64,7 @@ import {
TooltipProvider,
TooltipTrigger
} from "@ai-ui/ui";
import type { DataTableColumn, DataTableSort } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import { Controller, useForm } from "react-hook-form";
import { useState } from "react";
@@ -74,23 +80,101 @@ type ReleaseWorkspaceProps = {
quietMode?: boolean;
};
const reviewerColumns = [
type RoutingLaneState = "Ready" | "Watching" | "Quiet" | "Holding";
type RoutingLaneRow = {
audience: string;
id: string;
lane: "Editorial" | "Engineering" | "Support";
nextGate: string;
note: string;
owner: string;
ownerEmail: string;
signalScore: number;
state: RoutingLaneState;
};
type RoutingFilter = "all" | "holding" | "quiet" | "watching";
const routingLaneRows: RoutingLaneRow[] = [
{
audience: "Narrative lock",
id: "editorial-copy-lock",
lane: "Editorial",
nextGate: "18:40",
note: "Copy is locked. Waiting on final legal phrasing for the migration footnote.",
owner: "Mae Kurata",
ownerEmail: "mae.kurata@cadence.dev",
signalScore: 8,
state: "Ready"
},
{
audience: "10% canary",
id: "engineering-canary",
lane: "Engineering",
nextGate: "19:15",
note: "Canary checks are green. Queue the 10% wave after route owners sign off.",
owner: "Dorian Vale",
ownerEmail: "dorian.vale@cadence.dev",
signalScore: 14,
state: "Watching"
},
{
audience: "Support queue",
id: "support-queue",
lane: "Support",
nextGate: "19:32",
note: "No escalations yet. Macro pack and customer note are staged for handoff.",
owner: "Lia Sato",
ownerEmail: "lia.sato@cadence.dev",
signalScore: 5,
state: "Quiet"
},
{
audience: "Legal footnote",
id: "editorial-legal-note",
lane: "Editorial",
nextGate: "19:05",
note: "One migration sentence still needs counsel review before the customer note can publish.",
owner: "Mae Kurata",
ownerEmail: "mae.kurata@cadence.dev",
signalScore: 17,
state: "Holding"
},
{
audience: "Wave brief",
id: "engineering-wave-brief",
lane: "Engineering",
nextGate: "19:44",
note: "Rollback thresholds are staged. Keep the fallback owner present during the first quiet cycle.",
owner: "Dorian Vale",
ownerEmail: "dorian.vale@cadence.dev",
signalScore: 11,
state: "Ready"
},
{
audience: "Customer note",
id: "support-customer-note",
lane: "Support",
nextGate: "20:05",
note: "Keep the external note unpublished until the queue stays quiet for one more pass.",
owner: "Lia Sato",
ownerEmail: "lia.sato@cadence.dev",
signalScore: 13,
state: "Watching"
},
{
audience: "Rollback watch",
id: "engineering-rollback-watch",
lane: "Engineering",
nextGate: "20:18",
note: "Guardrails are live. Stay quiet unless conversion drops or route owners lose visibility.",
owner: "Dorian Vale",
ownerEmail: "dorian.vale@cadence.dev",
signalScore: 6,
state: "Quiet"
}
] as const;
];
const timelineStops = [
{
@@ -110,6 +194,13 @@ const timelineStops = [
}
] as const;
const routingFilterOptions: Array<{ label: string; value: RoutingFilter }> = [
{ label: "All lanes", value: "all" },
{ label: "Watching", value: "watching" },
{ label: "Quiet", value: "quiet" },
{ label: "Holding", value: "holding" }
];
function SignalGlyph() {
return (
<div className="grid gap-2">
@@ -157,19 +248,211 @@ function MetricPanel({
);
}
function getLaneBadgeClassName(state: RoutingLaneState) {
switch (state) {
case "Watching":
return "border-[color-mix(in_oklch,var(--color-primary)_28%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_12%,var(--color-card))] text-[var(--color-foreground)]";
case "Quiet":
return "border-[var(--color-border)] bg-[color-mix(in_oklch,var(--color-surface)_88%,white_12%)] text-[var(--color-muted-foreground)]";
case "Holding":
return "border-[color-mix(in_oklch,var(--color-accent)_28%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-accent)_14%,var(--color-card))] text-[var(--color-foreground)]";
case "Ready":
default:
return "border-[var(--color-border)] bg-[var(--color-card)] text-[var(--color-foreground)]";
}
}
function getLaneBadgeVariant(state: RoutingLaneState) {
switch (state) {
case "Watching":
return "solid" as const;
case "Quiet":
return "outline" as const;
case "Holding":
return undefined;
case "Ready":
default:
return "outline" as const;
}
}
function toRoutingValues(row?: RoutingLaneRow): RoutingValues {
return {
lane: row?.lane.toLowerCase() ?? "engineering",
notifications: row ? row.state !== "Quiet" : true,
ownerEmail: row?.ownerEmail ?? "routing@cadence.dev",
summary:
row?.note ??
"Hold the customer note until the support lane stays quiet for one full cycle."
};
}
function RoutingRowActions({
onOpenSettings,
onQueueGate,
onSendDigest,
row
}: {
onOpenSettings: (row: RoutingLaneRow) => void;
onQueueGate: (row: RoutingLaneRow) => void;
onSendDigest: (row: RoutingLaneRow) => void;
row: RoutingLaneRow;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost">
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" size="sm">
<DropdownMenuItem
onSelect={() => {
onOpenSettings(row);
}}
>
Open lane settings
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onQueueGate(row);
}}
>
Queue next gate
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onSendDigest(row);
}}
>
Send digest
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
const [activeLane, setActiveLane] = useState<RoutingLaneRow | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogTarget, setDialogTarget] = useState<RoutingLaneRow | null>(null);
const [routingFilter, setRoutingFilter] = useState<RoutingFilter>(quietMode ? "quiet" : "all");
const [routingLoading, setRoutingLoading] = useState(false);
const [routingSearch, setRoutingSearch] = useState("");
const [routingSelection, setRoutingSelection] = useState<Record<string, boolean>>({});
const [routingSorting, setRoutingSorting] = useState<DataTableSort[]>([
{ desc: false, id: "lane" }
]);
const [sheetOpen, setSheetOpen] = useState(false);
const [toastCopy, setToastCopy] = useState({
description: "The lane owner and release note are staged for the next quiet cycle.",
title: "Routing updated"
});
const [toastOpen, setToastOpen] = useState(false);
const form = useForm<RoutingValues>({
defaultValues: {
lane: "engineering",
notifications: true,
ownerEmail: "routing@cadence.dev",
summary: "Hold the customer note until the support lane stays quiet for one full cycle."
}
defaultValues: toRoutingValues()
});
const visibleRoutingRows = routingLaneRows.filter((row) => {
if (routingFilter === "all") {
return true;
}
return row.state.toLowerCase() === routingFilter;
});
const selectedRoutingRows = routingLaneRows.filter((row) => routingSelection[row.id]);
const selectedRoutingCount = selectedRoutingRows.length;
const visibleWatchCount = visibleRoutingRows.filter(
(row) => row.state === "Watching" || row.state === "Holding"
).length;
const nextOwner = selectedRoutingRows[0]?.owner ?? visibleRoutingRows[0]?.owner ?? "Routing lead";
function showToast(title: string, description: string) {
setToastCopy({ description, title });
setToastOpen(true);
}
function openRoutingSheet(row?: RoutingLaneRow) {
setActiveLane(row ?? null);
form.reset(toRoutingValues(row));
setSheetOpen(true);
}
function queueRoutingGate(row?: RoutingLaneRow) {
setDialogTarget(row ?? null);
setDialogOpen(true);
}
function refreshRoutingSignals() {
setRoutingLoading(true);
window.setTimeout(() => {
setRoutingLoading(false);
showToast(
"Signals refreshed",
"Fresh lane telemetry confirms the desk is still readable enough for the next quiet cycle."
);
}, 900);
}
const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
{
accessor: "lane",
cell: (row: RoutingLaneRow) => (
<div className="grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-[var(--color-foreground)]">{row.lane}</span>
<Badge variant="outline">{row.audience}</Badge>
</div>
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{row.nextGate} next gate
</p>
</div>
),
header: "Lane",
id: "lane",
sortable: true
},
{
accessor: "owner",
cell: (row: RoutingLaneRow) => (
<div className="grid gap-1">
<span className="text-sm font-medium text-[var(--color-foreground)]">{row.owner}</span>
<span className="text-xs text-[var(--color-muted-foreground)]">{row.ownerEmail}</span>
</div>
),
header: "Owner",
id: "owner",
sortable: true
},
{
accessor: "state",
cell: (row: RoutingLaneRow) => (
<div className="flex items-center gap-3">
<Badge className={getLaneBadgeClassName(row.state)} variant={getLaneBadgeVariant(row.state)}>
{row.state}
</Badge>
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Risk {row.signalScore}
</span>
</div>
),
header: "Signal",
id: "state",
sortable: true
},
{
accessor: "note",
cell: (row: RoutingLaneRow) => (
<p className="max-w-[34rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
{row.note}
</p>
),
header: "Routing note",
id: "note"
}
];
return (
<ToastProvider swipeDirection="right">
<TooltipProvider delayDuration={120}>
@@ -222,7 +505,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
<div className="flex flex-wrap items-center gap-3">
<Button
onClick={() => {
setDialogOpen(true);
queueRoutingGate();
}}
>
Queue rollout wave
@@ -230,7 +513,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
<Button
variant="secondary"
onClick={() => {
setSheetOpen(true);
openRoutingSheet();
}}
>
Edit routing lane
@@ -285,8 +568,8 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
</div>
<TabsContent value="overview" className="grid gap-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.08fr)_minmax(18rem,0.92fr)]">
<article className="overflow-hidden rounded-[calc(var(--radius-lg)+0.15rem)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_88%,white_12%),var(--color-card))] shadow-[var(--shadow-sm)]">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.08fr)_minmax(18rem,0.92fr)]">
<article className="min-w-0 overflow-hidden rounded-[calc(var(--radius-lg)+0.15rem)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_88%,white_12%),var(--color-card))] shadow-[var(--shadow-sm)]">
<div className="grid gap-6 p-6 sm:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
@@ -306,14 +589,14 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
note staged, and avoid turning a calm rollout into a noisy one.
</p>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
{timelineStops.map((item) => (
<div
key={item.title}
className="grid gap-3 rounded-[var(--radius-md)] border border-[color-mix(in_oklch,var(--color-border)_84%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_74%,white_26%)] p-4"
className="grid min-w-0 gap-3 rounded-[var(--radius-md)] border border-[color-mix(in_oklch,var(--color-border)_84%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_74%,white_26%)] p-4"
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-[var(--color-foreground)]">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
<p className="min-w-0 text-sm font-medium text-[var(--color-foreground)]">
{item.title}
</p>
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
@@ -345,7 +628,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
<EmptyStateActions>
<Button
onClick={() => {
setSheetOpen(true);
openRoutingSheet();
}}
>
Review routing
@@ -354,7 +637,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
</EmptyStateActions>
</EmptyState>
) : (
<Card>
<Card className="min-w-0">
<CardHeader>
<CardTitle>Support pulse</CardTitle>
<CardDescription>
@@ -404,32 +687,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
</TabsContent>
<TabsContent value="routing" className="grid gap-6">
<div className="grid gap-4 md:grid-cols-3">
{reviewerColumns.map((column, index) => (
<Card
key={column.lane}
className={[
"relative overflow-hidden",
index === 1
? "border-[color-mix(in_oklch,var(--color-primary)_24%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_6%,var(--color-card))]"
: undefined
]
.filter(Boolean)
.join(" ")}
>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle>{column.lane}</CardTitle>
<Badge variant={index === 1 ? "solid" : "outline"}>
{column.state}
</Badge>
</div>
<CardDescription>{column.note}</CardDescription>
</CardHeader>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.08fr)_minmax(17rem,0.92fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
@@ -440,19 +698,190 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
Keep one owner visible, one fallback explicit, and one customer note staged.
</h2>
</div>
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline">{visibleRoutingRows.length} visible lanes</Badge>
<Button
variant="secondary"
onClick={() => {
setSheetOpen(true);
openRoutingSheet(selectedRoutingRows[0]);
}}
>
Adjust lane settings
</Button>
</div>
</div>
<p className="mt-4 max-w-3xl text-sm leading-7 text-[var(--color-muted-foreground)]">
The routing desk stops being narrative-only here. Search narrows the live
lane list, sorting reshapes the order of attention, selection supports a
quiet multi-lane brief, and row actions keep deeper edits in overlays
instead of forcing the table to become an app inside itself.
</p>
</article>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
<MetricPanel eyebrow="Needs eyes" tone="accent" value={String(visibleWatchCount)}>
Watching and holding lanes should stay visible until the next quiet cycle clears.
</MetricPanel>
<MetricPanel eyebrow="Selected lanes" value={String(selectedRoutingCount)}>
{selectedRoutingCount > 0
? `${nextOwner} currently anchors the first selected lane.`
: "Use row selection to brief multiple owners without leaving the desk."}
</MetricPanel>
</div>
</div>
<section className="overflow-hidden rounded-[calc(var(--radius-lg)+0.15rem)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_90%,white_10%),var(--color-card))] shadow-[var(--shadow-sm)]">
<div className="border-b border-[color-mix(in_oklch,var(--color-border)_84%,transparent)] px-6 py-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Routing lanes
</p>
<h3 className="text-xl font-semibold tracking-[var(--tracking-tight)]">
One table for owners, notes, gates, and the next quiet-cycle decision.
</h3>
</div>
<div className="flex flex-wrap items-center gap-3">
<Badge>Search, sort, select, paginate</Badge>
<Badge variant="outline">Row actions stay out of the grid</Badge>
</div>
</div>
</div>
<DataTable
columns={routingColumns}
empty={
<EmptyState
className="min-h-72 justify-center border-0 bg-transparent shadow-none"
tone="subtle"
>
<EmptyStateMedia>
<SignalGlyph />
</EmptyStateMedia>
<EmptyStateHeader>
<EmptyStateEyebrow>No visible routing lanes</EmptyStateEyebrow>
<EmptyStateTitle>The current desk view is intentionally sparse.</EmptyStateTitle>
<EmptyStateDescription>
Clear the search or switch the lane filter if you need a wider view before queueing.
</EmptyStateDescription>
</EmptyStateHeader>
<EmptyStateActions>
<Button
onClick={() => {
setRoutingFilter("all");
setRoutingSearch("");
setRoutingSelection({});
}}
>
Reset table view
</Button>
</EmptyStateActions>
</EmptyState>
}
getRowId={(row: RoutingLaneRow) => row.id}
loading={routingLoading}
onSearchValueChange={setRoutingSearch}
onSelectionChange={setRoutingSelection}
onSortingChange={setRoutingSorting}
pageSize={4}
pageSizeOptions={[4]}
searchLabel="Search routing lanes"
searchPlaceholder="Search lanes, owners, notes"
selectionLabel={(selectedRows) =>
`${selectedRows.length} lane${selectedRows.length === 1 ? "" : "s"} selected for a quiet-cycle brief.`
}
selectionActions={() => (
<>
<Button
size="sm"
variant="secondary"
onClick={() => {
openRoutingSheet(selectedRoutingRows[0]);
}}
>
Reassign first owner
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
showToast(
"Digest queued",
`${selectedRoutingCount} routing lane${selectedRoutingCount === 1 ? "" : "s"} will be summarized for the next quiet cycle.`
);
}}
>
Queue digest
</Button>
</>
)}
enableSelection
renderRowActions={(row: RoutingLaneRow) => (
<RoutingRowActions
onOpenSettings={openRoutingSheet}
onQueueGate={queueRoutingGate}
onSendDigest={(targetRow) => {
showToast(
`${targetRow.lane} digest sent`,
`${targetRow.owner} now has the latest routing note and quiet-cycle gate in their inbox.`
);
}}
row={row}
/>
)}
rows={visibleRoutingRows}
searchValue={routingSearch}
selection={routingSelection}
sorting={routingSorting}
toolbarActions={
<>
<Select
value={routingFilter}
onValueChange={(value) => {
setRoutingFilter(value as RoutingFilter);
setRoutingSelection({});
}}
>
<SelectTrigger aria-label="Routing lane filter" className="w-[11rem]">
<SelectValue placeholder="Filter lanes" />
</SelectTrigger>
<SelectContent>
{routingFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
onClick={() => {
setRoutingFilter(quietMode ? "quiet" : "all");
setRoutingSearch("");
setRoutingSelection({});
setRoutingSorting([{ desc: false, id: "lane" }]);
}}
>
Reset desk
</Button>
<Button
variant="secondary"
onClick={() => {
refreshRoutingSignals();
}}
>
Refresh signals
</Button>
</>
}
/>
</section>
</TabsContent>
<TabsContent value="audience" className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_19rem]">
<TabsContent
value="audience"
className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_19rem]"
>
<Card>
<CardHeader>
<CardTitle>Audience framing</CardTitle>
@@ -552,13 +981,26 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
</div>
</div>
<Dialog onOpenChange={setDialogOpen} open={dialogOpen}>
<Dialog
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setDialogTarget(null);
}
}}
open={dialogOpen}
>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Queue the 10% wave?</DialogTitle>
<DialogTitle>
{dialogTarget
? `Queue ${dialogTarget.lane} / ${dialogTarget.audience}?`
: "Queue the 10% wave?"}
</DialogTitle>
<DialogDescription>
This sends the first controlled wave, not the full customer note. Use it only if
support stays quiet and the routing owner is live.
{dialogTarget
? `This hands ${dialogTarget.owner} the ${dialogTarget.nextGate} gate. Use it only if the note is readable, the owner is live, and the desk is still quiet enough to stay disciplined.`
: "This sends the first controlled wave, not the full customer note. Use it only if support stays quiet and the routing owner is live."}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -567,6 +1009,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
variant="ghost"
onClick={() => {
setDialogOpen(false);
setDialogTarget(null);
}}
>
Keep staged
@@ -575,22 +1018,39 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
type="button"
onClick={() => {
setDialogOpen(false);
setToastOpen(true);
showToast(
dialogTarget ? `${dialogTarget.lane} gate queued` : "Wave queued",
dialogTarget
? `${dialogTarget.owner} now owns the ${dialogTarget.nextGate} gate for the ${dialogTarget.audience.toLowerCase()} pass.`
: "The lane owner and release note are staged for the next quiet cycle."
);
setDialogTarget(null);
}}
>
Queue wave
{dialogTarget ? "Queue gate" : "Queue wave"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Sheet onOpenChange={setSheetOpen} open={sheetOpen}>
<Sheet
onOpenChange={(open) => {
setSheetOpen(open);
if (!open) {
setActiveLane(null);
}
}}
open={sheetOpen}
>
<SheetContent side="right" size="lg">
<SheetHeader>
<SheetTitle>Routing lane settings</SheetTitle>
<SheetTitle>
{activeLane ? `${activeLane.lane} lane settings` : "Routing lane settings"}
</SheetTitle>
<SheetDescription>
Keep the lane owner explicit and the customer note disciplined before the wave is
queued.
{activeLane
? `Update the owner, note, and digest posture for ${activeLane.audience.toLowerCase()} before the next gate.`
: "Keep the lane owner explicit and the customer note disciplined before the wave is queued."}
</SheetDescription>
</SheetHeader>
@@ -599,7 +1059,13 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
className="grid gap-5"
onSubmit={form.handleSubmit(() => {
setSheetOpen(false);
setToastOpen(true);
showToast(
activeLane ? `${activeLane.lane} lane updated` : "Routing updated",
activeLane
? `${activeLane.owner} now has a refreshed routing brief for ${activeLane.audience.toLowerCase()}.`
: "The lane owner and release note are staged for the next quiet cycle."
);
setActiveLane(null);
})}
>
<FormItem name="ownerEmail" required>
@@ -685,6 +1151,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
variant="ghost"
onClick={() => {
setSheetOpen(false);
setActiveLane(null);
}}
>
Cancel
@@ -697,10 +1164,8 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
</Sheet>
<Toast onOpenChange={setToastOpen} open={toastOpen} variant="success">
<ToastTitle>Routing updated</ToastTitle>
<ToastDescription>
The lane owner and release note are staged for the next quiet cycle.
</ToastDescription>
<ToastTitle>{toastCopy.title}</ToastTitle>
<ToastDescription>{toastCopy.description}</ToastDescription>
<ToastAction altText="Open desk">Open desk</ToastAction>
<ToastClose />
</Toast>
@@ -718,7 +1183,7 @@ const meta = {
docs: {
description: {
component:
"Release Workspace is a realistic composition story that shows how Cadence UI behaves when the design system stops being a component shelf and becomes an actual operations surface. It combines layered decisions, contextual overlays, empty states, routing controls, and transient feedback in one believable release desk."
"Release Workspace is a realistic composition story that shows how Cadence UI behaves when the design system stops being a component shelf and becomes an actual operations surface. It now uses a real routing table workflow to test search, sorting, selection, pagination, row actions, and overlay handoffs inside one believable release desk."
}
},
layout: "fullscreen"
+51 -8
View File
@@ -8,13 +8,14 @@ The current goal is modest:
- version `@ai-ui/ui` and `@ai-ui/tokens` deliberately
- keep release notes attached to the changes that caused them
- avoid inventing ad hoc version bumps when the component system evolves
- make release intent enforceable in CI before package work merges
## Current assumptions
- The repository root is private.
- Workspace packages currently use explicit package versions even when they are not yet published.
- `@ai-ui/docs` is a consumer app, not a releasable package, so it is ignored by Changesets.
- Publishing mechanics, registry credentials, and CI release automation are still to be added.
- Publishing mechanics and registry credentials are still to be added.
Because of that, this baseline is intentionally conservative.
@@ -44,6 +45,9 @@ You can usually skip a changeset for:
- test-only edits
- internal refactors with no consumer-visible behavior change
If CI would otherwise require a changeset for one of those exceptions, apply the
`no-changeset-needed` pull request label and explain the reason in the PR description.
## Versioning guidance
Use semver pragmatically:
@@ -100,7 +104,7 @@ keep the dependency graph coherent without requiring manual package edits.
### 4. Version the packages
When it is time to cut a release, run the Changesets version step:
When it is time to cut a release manually, run the Changesets version step:
```bash
pnpm changeset version
@@ -114,6 +118,10 @@ That step is expected to:
Review the resulting package diffs carefully before merging.
On `main`, the repository also runs a `Release Version PR` workflow that opens or updates a
version PR with the same command. That workflow is intentionally limited to version-file updates;
it does not publish packages.
### 5. Publish or tag
Publishing is not fully wired in this repo yet, so treat this step as pending infrastructure.
@@ -126,6 +134,45 @@ pnpm changeset publish
But until registry, auth, and CI behavior are explicit, do not assume publish is automated.
## CI workflows
### Pull request check
The `Changeset Status` workflow runs on pull requests and enforces a simple rule:
- if a PR changes `@ai-ui/ui` or `@ai-ui/tokens`, it should usually include a new
`.changeset/*.md` file
- if the work is intentionally non-releasable, maintainers can apply the
`no-changeset-needed` label
When a changeset is present, the workflow also runs:
```bash
pnpm changeset:status --since origin/main
```
This validates that pending changesets resolve cleanly against the base branch.
### Version PR workflow
The `Release Version PR` workflow runs on pushes to `main` and on manual dispatch. It:
- installs dependencies
- runs `pnpm changeset version`
- opens or updates a version PR using `changesets/action`
This is a versioning skeleton, not a publish pipeline. It is safe to enable before registry
credentials or publish commands exist.
## Future publish prerequisites
Before turning the version workflow into a publish workflow, the repo still needs:
- a root publish script, for example `pnpm changeset publish` or a guarded wrapper around it
- registry credentials such as `NPM_TOKEN`
- a decision on which workspace packages should actually be published versus versioned internally
- any extra validation that must run before publish is allowed
## Notes for maintainers
- Keep `packages/ui/src/index.ts` and package exports aligned with any release-worthy surface.
@@ -136,9 +183,5 @@ But until registry, auth, and CI behavior are explicit, do not assume publish is
## Main-thread follow-up still needed
This baseline adds config and process docs only. To make it operational, the repo still needs:
- `@changesets/cli` added to root `devDependencies`
- root scripts such as `changeset`, `version-packages`, or `release`
- a decision on whether private packages should be published, mirrored internally, or versioned only
- CI wiring for version PRs and/or publish jobs
This baseline now covers changeset intent checks in PRs and version PR creation on `main`.
What remains is the publish decision and any registry-specific automation.
+1
View File
@@ -32,6 +32,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -0,0 +1,242 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
import { DataTable, type DataTableColumn } from "./data-table";
type ReleaseRow = {
id: string;
lane: string;
note: string;
owner: string;
risk: number;
status: string;
};
const rows: ReleaseRow[] = [
{
id: "legal",
lane: "Legal",
note: "Footnote needs one more pass before the customer note goes out.",
owner: "Ava",
risk: 3,
status: "Pending"
},
{
id: "support",
lane: "Support",
note: "Macro pack is staged and aligned with the migration narrative.",
owner: "Nia",
risk: 1,
status: "Ready"
},
{
id: "engineering",
lane: "Engineering",
note: "Canary checks are green and ready for the 10% wave.",
owner: "Mika",
risk: 2,
status: "Watching"
},
{
id: "editorial",
lane: "Editorial",
note: "Copy is locked and the public note stays staged.",
owner: "Jun",
risk: 4,
status: "Staged"
}
];
const columns: DataTableColumn<ReleaseRow>[] = [
{
accessor: "lane",
header: "Lane",
id: "lane",
sortable: true
},
{
accessor: "owner",
header: "Owner",
id: "owner",
sortable: true
},
{
accessor: "status",
header: "Status",
id: "status"
},
{
accessor: "risk",
align: "end",
header: "Risk",
id: "risk",
sortable: true
},
{
cell: (row) => row.note,
header: "Narrative",
id: "note",
searchValue: (row) => row.note
}
];
function getBodyRows() {
return screen
.getAllByRole("row")
.filter((row) => row.closest("tbody") !== null);
}
function getRenderedLanes() {
return getBodyRows().map((row) => within(row).getAllByRole("cell")[0].textContent);
}
describe("DataTable", () => {
it("renders semantic table slots, toolbar search, and row actions", () => {
render(
<DataTable
columns={columns}
renderRowActions={(row) => <Button size="sm">Open {row.lane}</Button>}
rows={rows.slice(0, 2)}
toolbarActions={<Button size="sm" variant="secondary">Create lane</Button>}
/>
);
expect(screen.getByRole("table").closest('[data-slot="root"]')).toBeInTheDocument();
expect(screen.getByRole("searchbox", { name: "Search rows" })).toHaveAttribute(
"data-slot",
"input"
);
expect(
screen.getByRole("button", { name: "Create lane" }).closest('[data-slot="actions"]')
).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /lane/i })).toHaveAttribute(
"data-slot",
"header"
);
expect(
screen.getByRole("button", { name: "Open Legal" }).closest('[data-slot="actions"]')
).toBeInTheDocument();
});
it("sorts rows when a sortable header is activated", async () => {
const user = userEvent.setup();
render(<DataTable columns={columns} rows={rows.slice(0, 3)} />);
expect(getRenderedLanes()).toEqual(["Legal", "Support", "Engineering"]);
await user.click(within(screen.getByRole("columnheader", { name: /risk/i })).getByRole("button"));
expect(getRenderedLanes()).toEqual(["Support", "Engineering", "Legal"]);
await user.click(within(screen.getByRole("columnheader", { name: /risk/i })).getByRole("button"));
expect(getRenderedLanes()).toEqual(["Legal", "Engineering", "Support"]);
});
it("supports row selection and shows a bulk selection surface", async () => {
const user = userEvent.setup();
render(
<DataTable
columns={columns}
enableSelection
rows={rows.slice(0, 2)}
selectionActions={() => <Button size="sm">Assign owner</Button>}
/>
);
await user.click(screen.getByRole("checkbox", { name: "Select row 1" }));
expect(getBodyRows()[0]).toHaveAttribute("data-selected", "");
expect(screen.getByText("1 selected")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Assign owner" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Clear selection" }));
expect(screen.queryByText("1 selected")).not.toBeInTheDocument();
});
it("filters rows from the built-in search and falls back to the empty state", async () => {
const user = userEvent.setup();
render(<DataTable columns={columns} rows={rows.slice(0, 3)} />);
await user.type(screen.getByRole("searchbox", { name: "Search rows" }), "migration");
expect(
screen.getByText("Macro pack is staged and aligned with the migration narrative.")
).toBeInTheDocument();
expect(
screen.queryByText("Canary checks are green and ready for the 10% wave.")
).not.toBeInTheDocument();
await user.clear(screen.getByRole("searchbox", { name: "Search rows" }));
await user.type(screen.getByRole("searchbox", { name: "Search rows" }), "missing");
expect(screen.getByText("No matching rows")).toBeInTheDocument();
expect(
screen.getByText(
"Try another search, clear filters, or create a new record to repopulate this view."
)
).toBeInTheDocument();
});
it("paginates rows and updates the visible range", async () => {
const user = userEvent.setup();
render(
<DataTable
columns={columns}
defaultPageSize={2}
pageSizeOptions={[2]}
rows={rows}
/>
);
expect(screen.getByText("1-2 of 4")).toBeInTheDocument();
expect(getRenderedLanes()).toEqual(["Legal", "Support"]);
expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("3-4 of 4")).toBeInTheDocument();
expect(getRenderedLanes()).toEqual(["Engineering", "Editorial"]);
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
});
it("renders loading status without dropping the table chrome", () => {
render(<DataTable columns={columns} loading rows={rows} />);
expect(screen.getByRole("status")).toHaveTextContent("Loading rows");
expect(screen.getByRole("columnheader", { name: /lane/i })).toBeInTheDocument();
expect(screen.getByRole("table").closest('[data-slot="root"]')).toHaveAttribute(
"data-loading",
""
);
});
it("supports controlled sorting and selection callbacks", async () => {
const user = userEvent.setup();
const onSortingChange = vi.fn();
const onSelectionChange = vi.fn();
render(
<DataTable
columns={columns}
enableSelection
onSelectionChange={onSelectionChange}
onSortingChange={onSortingChange}
rows={rows.slice(0, 2)}
/>
);
await user.click(within(screen.getByRole("columnheader", { name: /lane/i })).getByRole("button"));
await user.click(screen.getByRole("checkbox", { name: "Select row 2" }));
expect(onSortingChange).toHaveBeenCalledWith([{ desc: false, id: "lane" }]);
expect(onSelectionChange).toHaveBeenCalledWith({ support: true });
});
});
+952
View File
@@ -0,0 +1,952 @@
import {
type CellContext,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type HeaderContext,
useReactTable,
type ColumnDef,
type FilterFn,
type PaginationState,
type RowSelectionState,
type SortingState
} from "@tanstack/react-table";
import {
forwardRef,
useEffect,
useState,
type ComponentPropsWithoutRef,
type CSSProperties,
type ForwardedRef,
type JSX,
type ReactNode
} from "react";
import { Button } from "./button";
import { Checkbox } from "./checkbox";
import {
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateHeader,
EmptyStateTitle
} from "./empty-state";
import { Input, type InputProps } from "./input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
import { Skeleton } from "./skeleton";
import { Spinner } from "./spinner";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
import {
dataTableBodyVariants,
dataTableCellVariants,
dataTableContentVariants,
dataTableHeaderCellVariants,
dataTableHeaderVariants,
dataTablePaginationVariants,
dataTableRootVariants,
dataTableRowVariants,
dataTableSearchContainerVariants,
dataTableSelectionBarVariants,
dataTableStatusVariants,
dataTableTableVariants,
dataTableToolbarVariants
} from "./data-table.variants";
export type DataTableAlignment = "start" | "center" | "end";
export type DataTableSort = {
desc?: boolean;
id: string;
};
export type DataTableColumnContext<TData> = {
column: DataTableColumn<TData>;
sorted: "asc" | "desc" | false;
};
export type DataTableColumn<TData> = {
accessor?: keyof TData | ((row: TData) => unknown);
align?: DataTableAlignment;
cell?: (row: TData) => ReactNode;
header: ReactNode | ((column: DataTableColumnContext<TData>) => ReactNode);
id: string;
searchValue?: (row: TData) => string;
searchable?: boolean;
sortable?: boolean;
width?: number | string;
};
export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
columns: DataTableColumn<TData>[];
defaultPageIndex?: number;
defaultPageSize?: number;
defaultSearchValue?: string;
defaultSelection?: Record<string, boolean>;
defaultSorting?: DataTableSort[];
empty?: ReactNode;
enableSelection?: boolean;
getRowId?: (row: TData, index: number) => string;
loading?: boolean;
loadingRowCount?: number;
onPageIndexChange?: (pageIndex: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onSearchValueChange?: (searchValue: string) => void;
onSelectionChange?: (selection: Record<string, boolean>) => void;
onSortingChange?: (sorting: DataTableSort[]) => void;
pageIndex?: number;
pageSize?: number;
pageSizeOptions?: number[];
renderRowActions?: (row: TData) => ReactNode;
rows: TData[];
searchLabel?: string;
searchPlaceholder?: string;
searchValue?: string;
selection?: Record<string, boolean>;
selectionLabel?: ReactNode | ((rows: TData[]) => ReactNode);
selectionActions?: (rows: TData[]) => ReactNode;
sorting?: DataTableSort[];
tableLabel?: string;
toolbarActions?: ReactNode;
};
type InternalColumnMeta<TData> = {
align: DataTableAlignment;
sourceColumn?: DataTableColumn<TData>;
width?: number | string;
};
function useControllableState<T>({
controlledValue,
defaultValue,
onChange
}: {
controlledValue: T | undefined;
defaultValue: T;
onChange?: (value: T) => void;
}) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
const value = controlledValue ?? uncontrolledValue;
const setValue = (nextValue: T | ((currentValue: T) => T)) => {
const resolvedValue =
typeof nextValue === "function"
? (nextValue as (currentValue: T) => T)(value)
: nextValue;
if (controlledValue === undefined) {
setUncontrolledValue(resolvedValue);
}
onChange?.(resolvedValue);
};
return [value, setValue] as const;
}
function stringifySearchValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (Array.isArray(value)) {
return value.map((item) => stringifySearchValue(item)).join(" ");
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "object") {
return Object.values(value as Record<string, unknown>)
.map((item) => stringifySearchValue(item))
.join(" ");
}
return String(value);
}
function getColumnAccessorValue<TData>(row: TData, column: DataTableColumn<TData>) {
if (typeof column.accessor === "function") {
return column.accessor(row);
}
if (typeof column.accessor === "string") {
return row[column.accessor];
}
return undefined;
}
function getColumnSearchText<TData>(row: TData, column: DataTableColumn<TData>) {
if (column.searchValue) {
return column.searchValue(row);
}
return stringifySearchValue(getColumnAccessorValue(row, column));
}
function getColumnWidthStyle(width?: number | string): CSSProperties | undefined {
if (width === undefined) {
return undefined;
}
return {
width: typeof width === "number" ? `${width}px` : width
};
}
function getHeaderLabel<TData>(column: DataTableColumn<TData>) {
return typeof column.header === "string" ? column.header : column.id;
}
function DataTableInner<TData>(
{
className,
columns,
defaultPageIndex = 0,
defaultPageSize = 5,
defaultSearchValue = "",
defaultSelection = {},
defaultSorting = [],
empty,
enableSelection = false,
getRowId,
loading = false,
loadingRowCount = 5,
onPageIndexChange,
onPageSizeChange,
onSearchValueChange,
onSelectionChange,
onSortingChange,
pageIndex,
pageSize,
pageSizeOptions = [5, 10, 20],
renderRowActions,
rows,
searchLabel = "Search rows",
searchPlaceholder = "Search rows",
searchValue,
selection,
selectionLabel,
selectionActions,
sorting,
tableLabel = "Data table",
toolbarActions,
...props
}: DataTableProps<TData>,
ref: ForwardedRef<HTMLDivElement>
) {
const selectionEnabled = enableSelection || selection !== undefined || onSelectionChange !== undefined;
const searchableColumns = columns.filter(
(column) => (column.searchable ?? false) || column.searchValue || column.accessor
);
const [currentSearchValue, setCurrentSearchValue] = useControllableState({
controlledValue: searchValue,
defaultValue: defaultSearchValue,
onChange: onSearchValueChange
});
const [currentSorting, setCurrentSorting] = useControllableState<DataTableSort[]>({
controlledValue: sorting,
defaultValue: defaultSorting,
onChange: onSortingChange
});
const [currentSelection, setCurrentSelection] = useControllableState<Record<string, boolean>>({
controlledValue: selection,
defaultValue: defaultSelection,
onChange: onSelectionChange
});
const [currentPageIndex, setCurrentPageIndex] = useControllableState({
controlledValue: pageIndex,
defaultValue: defaultPageIndex,
onChange: onPageIndexChange
});
const [currentPageSize, setCurrentPageSize] = useControllableState({
controlledValue: pageSize,
defaultValue: defaultPageSize,
onChange: onPageSizeChange
});
const resolvedPageSizeOptions = [...new Set([...pageSizeOptions, currentPageSize])].sort(
(left, right) => left - right
);
const globalFilterFn: FilterFn<TData> = (row, _columnId, filterValue) => {
const query = String(filterValue ?? "").trim().toLowerCase();
if (query.length === 0) {
return true;
}
return searchableColumns.some((column) =>
getColumnSearchText(row.original, column).toLowerCase().includes(query)
);
};
const tableColumns: ColumnDef<TData>[] = [
...(selectionEnabled
? [
{
cell: ({ row }) => (
<Checkbox
aria-label={`Select row ${row.index + 1}`}
checked={row.getIsSelected()}
onCheckedChange={(checked) => {
row.toggleSelected(Boolean(checked));
}}
/>
),
enableGlobalFilter: false,
enableHiding: false,
enableSorting: false,
header: ({ table }) => (
<Checkbox
aria-label="Select all rows"
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(checked) => {
table.toggleAllPageRowsSelected(Boolean(checked));
}}
/>
),
id: "__select",
meta: {
align: "center",
width: 56
} satisfies InternalColumnMeta<TData>
} satisfies ColumnDef<TData>
]
: []),
...columns.map((column) => ({
accessorFn: column.accessor
? (row: TData) => getColumnAccessorValue(row, column)
: undefined,
cell: ({ row }: CellContext<TData, unknown>) =>
column.cell
? column.cell(row.original)
: stringifySearchValue(getColumnAccessorValue(row.original, column)),
enableGlobalFilter: searchableColumns.includes(column),
enableSorting: column.sortable ?? false,
header: ({ column: tanstackColumn }: HeaderContext<TData, unknown>) =>
typeof column.header === "function"
? column.header({
column,
sorted: tanstackColumn.getIsSorted()
})
: column.header,
id: column.id,
meta: {
align: column.align ?? "start",
sourceColumn: column,
width: column.width
} satisfies InternalColumnMeta<TData>,
sortDescFirst: false
})),
...(renderRowActions
? [
{
cell: ({ row }) => (
<div {...createSlot("actions")} className="flex items-center justify-end">
{renderRowActions(row.original)}
</div>
),
enableGlobalFilter: false,
enableHiding: false,
enableSorting: false,
header: () => <span className="sr-only">Row actions</span>,
id: "__actions",
meta: {
align: "end",
width: 88
} satisfies InternalColumnMeta<TData>
} satisfies ColumnDef<TData>
]
: [])
];
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table is the intentional row-model engine behind this source-owned component.
const table = useReactTable({
autoResetPageIndex: true,
columns: tableColumns,
data: rows,
enableRowSelection: selectionEnabled,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row, index) => {
if (getRowId) {
return getRowId(row, index);
}
const candidateId = (row as { id?: unknown }).id;
if (typeof candidateId === "string" || typeof candidateId === "number") {
return String(candidateId);
}
return String(index);
},
getSortedRowModel: getSortedRowModel(),
globalFilterFn,
onPaginationChange: (updater) => {
const nextValue =
typeof updater === "function"
? updater({
pageIndex: currentPageIndex,
pageSize: currentPageSize
})
: updater;
setCurrentPageIndex(nextValue.pageIndex);
setCurrentPageSize(nextValue.pageSize);
},
onRowSelectionChange: (updater) => {
const nextValue =
typeof updater === "function"
? updater(currentSelection as RowSelectionState)
: updater;
setCurrentSelection(nextValue);
},
onSortingChange: (updater) => {
const nextValue =
typeof updater === "function"
? updater(currentSorting as SortingState)
: updater;
setCurrentSorting(nextValue.map((item) => ({ desc: item.desc, id: item.id })));
},
state: {
globalFilter: currentSearchValue,
pagination: {
pageIndex: currentPageIndex,
pageSize: currentPageSize
} satisfies PaginationState,
rowSelection: currentSelection,
sorting: currentSorting as SortingState
}
});
const totalColumns = table.getAllLeafColumns().length;
const filteredRowCount = table.getFilteredRowModel().rows.length;
const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
const pageCount = table.getPageCount();
const isEmpty = !loading && filteredRowCount === 0;
const shouldRenderSearch = searchableColumns.length > 0;
const shouldRenderToolbar = shouldRenderSearch || toolbarActions !== undefined;
const pageStart =
filteredRowCount === 0 ? 0 : currentPageIndex * currentPageSize + 1;
const pageEnd = Math.min((currentPageIndex + 1) * currentPageSize, filteredRowCount);
useEffect(() => {
const maxPageIndex = Math.max(pageCount - 1, 0);
if (currentPageIndex > maxPageIndex) {
setCurrentPageIndex(maxPageIndex);
}
}, [currentPageIndex, pageCount, setCurrentPageIndex]);
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
empty: isEmpty,
loading,
selected: selectedRows.length > 0
})}
className={cn(dataTableRootVariants(), className)}
ref={ref}
>
{shouldRenderToolbar ? (
<DataTableToolbar>
<div className="flex flex-1 flex-wrap items-center gap-3">
{shouldRenderSearch ? (
<div className={dataTableSearchContainerVariants()}>
<DataTableSearch
aria-label={searchLabel}
onChange={(event) => {
setCurrentSearchValue(event.currentTarget.value);
setCurrentPageIndex(0);
}}
placeholder={searchPlaceholder}
value={currentSearchValue}
/>
</div>
) : null}
</div>
{toolbarActions ? <DataTableFilters>{toolbarActions}</DataTableFilters> : null}
</DataTableToolbar>
) : null}
{selectionEnabled && selectedRows.length > 0 ? (
<DataTableSelectionBar>
<div className="text-sm font-medium text-[var(--color-foreground)]">
{typeof selectionLabel === "function"
? selectionLabel(selectedRows)
: selectionLabel ?? `${selectedRows.length} selected`}
</div>
<div {...createSlot("actions")} className="flex flex-wrap items-center gap-2">
{selectionActions?.(selectedRows)}
<Button
size="sm"
variant="ghost"
onClick={() => {
table.resetRowSelection();
}}
>
Clear selection
</Button>
</div>
</DataTableSelectionBar>
) : null}
<DataTableContent loading={loading}>
{loading ? <DataTableLoading className="sr-only min-h-0 px-0 py-0" /> : null}
<div className="overflow-x-auto">
<DataTableTable aria-label={tableLabel}>
<DataTableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as InternalColumnMeta<TData> | undefined;
const sortState = header.column.getIsSorted();
const align: DataTableAlignment = meta?.align ?? "start";
return (
<DataTableHeaderCell
key={header.id}
align={align}
aria-sort={
sortState === "asc"
? "ascending"
: sortState === "desc"
? "descending"
: undefined
}
scope="col"
sortable={header.column.getCanSort()}
sort={sortState}
style={getColumnWidthStyle(meta?.width)}
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<button
className={[
"inline-flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5",
"outline-none transition-colors duration-200 hover:bg-[var(--color-surface)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
align === "end" ? "justify-end" : align === "center" ? "justify-center" : "justify-start"
].join(" ")}
onClick={header.column.getToggleSortingHandler()}
type="button"
>
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
<span
aria-hidden="true"
className="text-xs text-[var(--color-muted-foreground)]"
>
{sortState === "asc" ? "↑" : sortState === "desc" ? "↓" : "↕"}
</span>
<span className="sr-only">
Sort by{" "}
{meta?.sourceColumn ? getHeaderLabel(meta.sourceColumn) : header.column.id}
</span>
</button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</DataTableHeaderCell>
);
})}
</tr>
))}
</DataTableHeader>
<DataTableBody>
{loading
? Array.from({ length: loadingRowCount }, (_, index) => (
<DataTableRow
interactive={false}
key={`loading-row-${index}`}
>
{Array.from({ length: totalColumns }, (_, cellIndex) => (
<DataTableCell key={`loading-cell-${cellIndex}`}>
<Skeleton
className={cellIndex === totalColumns - 1 ? "ml-auto w-12" : undefined}
/>
</DataTableCell>
))}
</DataTableRow>
))
: isEmpty
? (
<tr>
<td colSpan={totalColumns}>
{empty ?? (
<DataTableEmpty
description="Try another search, clear filters, or create a new record to repopulate this view."
title="No matching rows"
/>
)}
</td>
</tr>
)
: table.getRowModel().rows.map((row) => (
<DataTableRow
data-selected={row.getIsSelected() ? "" : undefined}
key={row.id}
selected={row.getIsSelected()}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as InternalColumnMeta<TData> | undefined;
const align: DataTableAlignment = meta?.align ?? "start";
return (
<DataTableCell
align={align}
key={cell.id}
style={getColumnWidthStyle(meta?.width)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCell>
);
})}
</DataTableRow>
))}
</DataTableBody>
</DataTableTable>
</div>
<DataTablePagination>
<div className="text-sm text-[var(--color-muted-foreground)]">
{pageStart}-{pageEnd} of {filteredRowCount}
</div>
<div className="flex flex-wrap items-center gap-3">
{resolvedPageSizeOptions.length > 1 ? (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--color-muted-foreground)]">Rows</span>
<Select
value={String(currentPageSize)}
onValueChange={(value) => {
setCurrentPageSize(Number(value));
setCurrentPageIndex(0);
}}
>
<SelectTrigger aria-label="Rows per page" className="w-[5.5rem]">
<SelectValue placeholder={String(currentPageSize)} />
</SelectTrigger>
<SelectContent>
{resolvedPageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="text-sm text-[var(--color-muted-foreground)]">
Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)}
</div>
<div className="flex items-center gap-2">
<Button
disabled={!table.getCanPreviousPage()}
size="sm"
variant="ghost"
onClick={() => {
table.previousPage();
}}
>
Previous
</Button>
<Button
disabled={!table.getCanNextPage()}
size="sm"
variant="ghost"
onClick={() => {
table.nextPage();
}}
>
Next
</Button>
</div>
</div>
</DataTablePagination>
</DataTableContent>
</div>
);
}
type DataTableComponent = <TData>(
props: DataTableProps<TData> & { ref?: ForwardedRef<HTMLDivElement> }
) => JSX.Element;
export const DataTable = forwardRef(DataTableInner) as DataTableComponent;
export type DataTableToolbarProps = ComponentPropsWithoutRef<"div">;
export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps>(
function DataTableToolbar({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("toolbar")}
className={cn(dataTableToolbarVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableFiltersProps = ComponentPropsWithoutRef<"div">;
export const DataTableFilters = forwardRef<HTMLDivElement, DataTableFiltersProps>(
function DataTableFilters({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
className={cn("flex flex-wrap items-center gap-2", className)}
ref={ref}
/>
);
}
);
export type DataTableSearchProps = Omit<InputProps, "size">;
export const DataTableSearch = forwardRef<HTMLInputElement, DataTableSearchProps>(
function DataTableSearch({ className, type = "search", ...props }, ref) {
return <Input {...props} className={className} ref={ref} type={type} />;
}
);
export type DataTableContentProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof dataTableContentVariants>;
export const DataTableContent = forwardRef<HTMLDivElement, DataTableContentProps>(
function DataTableContent({ className, loading, ...props }, ref) {
return (
<div
{...props}
{...createSlot("content")}
{...createDataAttributes({ loading })}
className={cn(dataTableContentVariants({ loading }), className)}
ref={ref}
/>
);
}
);
export type DataTableTableProps = ComponentPropsWithoutRef<"table">;
export const DataTableTable = forwardRef<HTMLTableElement, DataTableTableProps>(
function DataTableTable({ className, ...props }, ref) {
return (
<table
{...props}
{...createSlot("table")}
className={cn(dataTableTableVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableHeaderProps = ComponentPropsWithoutRef<"thead">;
export const DataTableHeader = forwardRef<HTMLTableSectionElement, DataTableHeaderProps>(
function DataTableHeader({ className, ...props }, ref) {
return (
<thead
{...props}
{...createSlot("header")}
className={cn(dataTableHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableHeaderCellProps = Omit<ComponentPropsWithoutRef<"th">, "align"> &
VariantProps<typeof dataTableHeaderCellVariants> & {
sort?: "asc" | "desc" | false;
};
export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHeaderCellProps>(
function DataTableHeaderCell(
{ align = "start", className, sort = false, sortable = false, ...props },
ref
) {
return (
<th
{...props}
{...createSlot("header")}
{...createDataAttributes({
sort: sort || undefined
})}
className={cn(dataTableHeaderCellVariants({ align, sortable }), className)}
ref={ref}
/>
);
}
);
export type DataTableBodyProps = ComponentPropsWithoutRef<"tbody">;
export const DataTableBody = forwardRef<HTMLTableSectionElement, DataTableBodyProps>(
function DataTableBody({ className, ...props }, ref) {
return (
<tbody
{...props}
className={cn(dataTableBodyVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableRowProps = ComponentPropsWithoutRef<"tr"> &
VariantProps<typeof dataTableRowVariants>;
export const DataTableRow = forwardRef<HTMLTableRowElement, DataTableRowProps>(
function DataTableRow(
{ className, interactive = true, selected = false, ...props },
ref
) {
return (
<tr
{...props}
{...createSlot("row")}
{...createDataAttributes({
selected
})}
className={cn(dataTableRowVariants({ interactive, selected }), className)}
ref={ref}
/>
);
}
);
export type DataTableCellProps = Omit<ComponentPropsWithoutRef<"td">, "align"> &
VariantProps<typeof dataTableCellVariants>;
export const DataTableCell = forwardRef<HTMLTableCellElement, DataTableCellProps>(
function DataTableCell({ align = "start", className, ...props }, ref) {
return (
<td
{...props}
{...createSlot("cell")}
className={cn(dataTableCellVariants({ align }), className)}
ref={ref}
/>
);
}
);
export type DataTableEmptyProps = ComponentPropsWithoutRef<"div"> & {
actions?: ReactNode;
description?: ReactNode;
title?: ReactNode;
};
export const DataTableEmpty = forwardRef<HTMLDivElement, DataTableEmptyProps>(
function DataTableEmpty(
{
actions,
className,
description = "No rows match the current search or filter state.",
title = "Nothing to show",
...props
},
ref
) {
return (
<div
{...props}
{...createSlot("empty")}
className={cn(dataTableStatusVariants(), className)}
ref={ref}
>
<EmptyState className="w-full border-none bg-transparent p-0 shadow-none">
<EmptyStateHeader>
<EmptyStateTitle>{title}</EmptyStateTitle>
<EmptyStateDescription>{description}</EmptyStateDescription>
</EmptyStateHeader>
{actions ? <EmptyStateActions>{actions}</EmptyStateActions> : null}
</EmptyState>
</div>
);
}
);
export type DataTableLoadingProps = ComponentPropsWithoutRef<"div"> & {
description?: ReactNode;
label?: ReactNode;
};
export const DataTableLoading = forwardRef<HTMLDivElement, DataTableLoadingProps>(
function DataTableLoading(
{
className,
description = "Preparing the next set of rows and controls.",
label = "Loading rows",
...props
},
ref
) {
return (
<div
{...props}
className={cn(dataTableStatusVariants(), className)}
ref={ref}
role="status"
>
<div className="grid justify-items-center gap-3 text-center">
<Spinner aria-label={String(label)} size="lg" tone="primary" />
<div className="space-y-1">
<p className="text-sm font-medium text-[var(--color-foreground)]">{label}</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
{description}
</p>
</div>
</div>
</div>
);
}
);
export type DataTablePaginationProps = ComponentPropsWithoutRef<"div">;
export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginationProps>(
function DataTablePagination({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("pagination")}
className={cn(dataTablePaginationVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableSelectionBarProps = ComponentPropsWithoutRef<"div">;
export const DataTableSelectionBar = forwardRef<HTMLDivElement, DataTableSelectionBarProps>(
function DataTableSelectionBar({ className, ...props }, ref) {
return (
<div
{...props}
className={cn(dataTableSelectionBarVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,119 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const dataTableRootVariants = cva("grid gap-4 text-[var(--color-foreground)]");
export const dataTableToolbarVariants = cva(
"flex flex-wrap items-center justify-between gap-3"
);
export const dataTableContentVariants = cva(
[
"overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)]",
"bg-[var(--color-card)] shadow-[var(--shadow-sm)]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
loading: {
false: "",
true: "opacity-90"
}
},
defaultVariants: {
loading: false
}
}
);
export const dataTableTableVariants = cva("min-w-full border-collapse align-middle");
export const dataTableHeaderVariants = cva(
"bg-[color-mix(in_oklch,var(--color-surface)_74%,var(--color-card))]"
);
export const dataTableHeaderCellVariants = cva(
[
"px-4 py-3 text-sm font-medium uppercase tracking-[var(--tracking-caps)]",
"text-[var(--color-muted-foreground)]"
],
{
variants: {
align: {
start: "text-left",
center: "text-center",
end: "text-right"
},
sortable: {
false: "",
true: "select-none"
}
},
defaultVariants: {
align: "start",
sortable: false
}
}
);
export const dataTableBodyVariants = cva("");
export const dataTableRowVariants = cva(
[
"border-t border-[color-mix(in_oklch,var(--color-border)_88%,transparent)]",
"transition-colors duration-200"
],
{
variants: {
interactive: {
false: "",
true: "hover:bg-[color-mix(in_oklch,var(--color-surface)_72%,var(--color-card))]"
},
selected: {
false: "",
true: "bg-[color-mix(in_oklch,var(--color-primary)_8%,var(--color-card))]"
}
},
defaultVariants: {
interactive: true,
selected: false
}
}
);
export const dataTableCellVariants = cva(
"px-4 py-3 text-sm leading-6 text-[var(--color-card-foreground)]",
{
variants: {
align: {
start: "text-left",
center: "text-center",
end: "text-right"
}
},
defaultVariants: {
align: "start"
}
}
);
export const dataTableSearchContainerVariants = cva(
"w-full max-w-[22rem] min-w-[14rem]"
);
export const dataTablePaginationVariants = cva(
"flex flex-wrap items-center justify-between gap-3 px-4 py-3"
);
export const dataTableSelectionBarVariants = cva(
[
"flex flex-wrap items-center justify-between gap-3 rounded-[var(--radius-md)]",
"border border-[color-mix(in_oklch,var(--color-primary)_24%,var(--color-border))]",
"bg-[color-mix(in_oklch,var(--color-primary)_7%,var(--color-card))] px-4 py-3",
"shadow-[var(--shadow-xs)]"
]
);
export const dataTableStatusVariants = cva(
"grid min-h-52 place-items-center px-6 py-8"
);
+50
View File
@@ -53,6 +53,56 @@ export {
} from "./components/card.variants";
export { Checkbox, type CheckboxProps } from "./components/checkbox";
export { checkboxVariants } from "./components/checkbox.variants";
export {
DataTable,
DataTableBody,
DataTableCell,
DataTableContent,
DataTableEmpty,
DataTableFilters,
DataTableHeader,
DataTableHeaderCell,
DataTableLoading,
DataTablePagination,
DataTableRow,
DataTableSearch,
DataTableSelectionBar,
DataTableTable,
DataTableToolbar,
type DataTableAlignment,
type DataTableCellProps,
type DataTableColumn,
type DataTableColumnContext,
type DataTableContentProps,
type DataTableEmptyProps,
type DataTableFiltersProps,
type DataTableHeaderCellProps,
type DataTableHeaderProps,
type DataTableLoadingProps,
type DataTablePaginationProps,
type DataTableProps,
type DataTableRowProps,
type DataTableSearchProps,
type DataTableSelectionBarProps,
type DataTableSort,
type DataTableTableProps,
type DataTableToolbarProps
} from "./components/data-table";
export {
dataTableBodyVariants,
dataTableCellVariants,
dataTableContentVariants,
dataTableHeaderCellVariants,
dataTableHeaderVariants,
dataTablePaginationVariants,
dataTableRootVariants,
dataTableRowVariants,
dataTableSearchContainerVariants,
dataTableSelectionBarVariants,
dataTableStatusVariants,
dataTableTableVariants,
dataTableToolbarVariants
} from "./components/data-table.variants";
export {
Combobox,
type ComboboxItem,
+22
View File
@@ -157,6 +157,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -1721,6 +1724,17 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@testing-library/dom@10.4.0':
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
engines: {node: '>=18'}
@@ -5144,6 +5158,14 @@ snapshots:
tailwindcss: 4.2.2
vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)
'@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/table-core': 8.21.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/table-core@8.21.3': {}
'@testing-library/dom@10.4.0':
dependencies:
'@babel/code-frame': 7.29.0