diff --git a/.changeset/README.md b/.changeset/README.md
index c2ab957..77626b6 100644
--- a/.changeset/README.md
+++ b/.changeset/README.md
@@ -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.
diff --git a/.changeset/strong-tables-obey.md b/.changeset/strong-tables-obey.md
new file mode 100644
index 0000000..855dc92
--- /dev/null
+++ b/.changeset/strong-tables-obey.md
@@ -0,0 +1,5 @@
+---
+"@ai-ui/ui": minor
+---
+
+Add the first DataTable implementation with sorting, selection, search, pagination, loading, empty states, and row actions.
diff --git a/.github/workflows/changeset-status.yml b/.github/workflows/changeset-status.yml
new file mode 100644
index 0000000..336e16a
--- /dev/null
+++ b/.github/workflows/changeset-status.yml
@@ -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}"
diff --git a/.github/workflows/release-version-pr.yml b/.github/workflows/release-version-pr.yml
new file mode 100644
index 0000000..2ff9c8e
--- /dev/null
+++ b/.github/workflows/release-version-pr.yml
@@ -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 }}
diff --git a/apps/docs/src/release-workspace.stories.tsx b/apps/docs/src/release-workspace.stories.tsx
index 71726c8..af13012 100644
--- a/apps/docs/src/release-workspace.stories.tsx
+++ b/apps/docs/src/release-workspace.stories.tsx
@@ -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 (
@@ -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 (
+
+
+
+ Actions
+
+
+
+ {
+ onOpenSettings(row);
+ }}
+ >
+ Open lane settings
+
+ {
+ onQueueGate(row);
+ }}
+ >
+ Queue next gate
+
+ {
+ onSendDigest(row);
+ }}
+ >
+ Send digest
+
+
+
+ );
+}
+
function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
+ const [activeLane, setActiveLane] = useState
(null);
const [dialogOpen, setDialogOpen] = useState(false);
+ const [dialogTarget, setDialogTarget] = useState(null);
+ const [routingFilter, setRoutingFilter] = useState(quietMode ? "quiet" : "all");
+ const [routingLoading, setRoutingLoading] = useState(false);
+ const [routingSearch, setRoutingSearch] = useState("");
+ const [routingSelection, setRoutingSelection] = useState>({});
+ const [routingSorting, setRoutingSorting] = useState([
+ { 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({
- 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[] = [
+ {
+ accessor: "lane",
+ cell: (row: RoutingLaneRow) => (
+
+
+ {row.lane}
+ {row.audience}
+
+
+ {row.nextGate} next gate
+
+
+ ),
+ header: "Lane",
+ id: "lane",
+ sortable: true
+ },
+ {
+ accessor: "owner",
+ cell: (row: RoutingLaneRow) => (
+
+ {row.owner}
+ {row.ownerEmail}
+
+ ),
+ header: "Owner",
+ id: "owner",
+ sortable: true
+ },
+ {
+ accessor: "state",
+ cell: (row: RoutingLaneRow) => (
+
+
+ {row.state}
+
+
+ Risk {row.signalScore}
+
+
+ ),
+ header: "Signal",
+ id: "state",
+ sortable: true
+ },
+ {
+ accessor: "note",
+ cell: (row: RoutingLaneRow) => (
+
+ {row.note}
+
+ ),
+ header: "Routing note",
+ id: "note"
+ }
+ ];
+
return (
@@ -222,7 +505,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
{
- setDialogOpen(true);
+ queueRoutingGate();
}}
>
Queue rollout wave
@@ -230,7 +513,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
{
- setSheetOpen(true);
+ openRoutingSheet();
}}
>
Edit routing lane
@@ -285,8 +568,8 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
-
-
+
+
@@ -306,14 +589,14 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
note staged, and avoid turning a calm rollout into a noisy one.
-
+
{timelineStops.map((item) => (
-
-
+
+
{item.title}
@@ -345,7 +628,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
{
- setSheetOpen(true);
+ openRoutingSheet();
}}
>
Review routing
@@ -354,7 +637,7 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
) : (
-
+
Support pulse
@@ -404,55 +687,201 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
-
- {reviewerColumns.map((column, index) => (
-
-
-
- {column.lane}
-
- {column.state}
-
-
- {column.note}
-
-
- ))}
+
+
+
+
+
+ Routing principle
+
+
+ Keep one owner visible, one fallback explicit, and one customer note staged.
+
+
+
+ {visibleRoutingRows.length} visible lanes
+ {
+ openRoutingSheet(selectedRoutingRows[0]);
+ }}
+ >
+ Adjust lane settings
+
+
+
+
+ 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.
+
+
+
+
+
+ Watching and holding lanes should stay visible until the next quiet cycle clears.
+
+
+ {selectedRoutingCount > 0
+ ? `${nextOwner} currently anchors the first selected lane.`
+ : "Use row selection to brief multiple owners without leaving the desk."}
+
+
-
-
-
-
- Routing principle
-
-
- Keep one owner visible, one fallback explicit, and one customer note staged.
-
+
+
+
+
+
+ Routing lanes
+
+
+ One table for owners, notes, gates, and the next quiet-cycle decision.
+
+
+
+ Search, sort, select, paginate
+ Row actions stay out of the grid
+
-
{
- setSheetOpen(true);
- }}
- >
- Adjust lane settings
-
-
+
+
+
+
+
+
+ No visible routing lanes
+ The current desk view is intentionally sparse.
+
+ Clear the search or switch the lane filter if you need a wider view before queueing.
+
+
+
+ {
+ setRoutingFilter("all");
+ setRoutingSearch("");
+ setRoutingSelection({});
+ }}
+ >
+ Reset table view
+
+
+
+ }
+ 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={() => (
+ <>
+ {
+ openRoutingSheet(selectedRoutingRows[0]);
+ }}
+ >
+ Reassign first owner
+
+ {
+ showToast(
+ "Digest queued",
+ `${selectedRoutingCount} routing lane${selectedRoutingCount === 1 ? "" : "s"} will be summarized for the next quiet cycle.`
+ );
+ }}
+ >
+ Queue digest
+
+ >
+ )}
+ enableSelection
+ renderRowActions={(row: RoutingLaneRow) => (
+ {
+ 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={
+ <>
+ {
+ setRoutingFilter(value as RoutingFilter);
+ setRoutingSelection({});
+ }}
+ >
+
+
+
+
+ {routingFilterOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ {
+ setRoutingFilter(quietMode ? "quiet" : "all");
+ setRoutingSearch("");
+ setRoutingSelection({});
+ setRoutingSorting([{ desc: false, id: "lane" }]);
+ }}
+ >
+ Reset desk
+
+ {
+ refreshRoutingSignals();
+ }}
+ >
+ Refresh signals
+
+ >
+ }
+ />
+
-
+
Audience framing
@@ -552,13 +981,26 @@ function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
-
+ {
+ setDialogOpen(open);
+ if (!open) {
+ setDialogTarget(null);
+ }
+ }}
+ open={dialogOpen}
+ >
- Queue the 10% wave?
+
+ {dialogTarget
+ ? `Queue ${dialogTarget.lane} / ${dialogTarget.audience}?`
+ : "Queue the 10% wave?"}
+
- 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."}
@@ -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"}
-
+ {
+ setSheetOpen(open);
+ if (!open) {
+ setActiveLane(null);
+ }
+ }}
+ open={sheetOpen}
+ >
- Routing lane settings
+
+ {activeLane ? `${activeLane.lane} lane settings` : "Routing lane settings"}
+
- 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."}
@@ -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);
})}
>
@@ -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) {
- Routing updated
-
- The lane owner and release note are staged for the next quiet cycle.
-
+ {toastCopy.title}
+ {toastCopy.description}
Open desk
@@ -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"
diff --git a/docs/releasing.md b/docs/releasing.md
index dce75e7..32e8673 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -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.
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 1e7bf7f..0cfe66b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -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",
diff --git a/packages/ui/src/components/data-table.test.tsx b/packages/ui/src/components/data-table.test.tsx
new file mode 100644
index 0000000..5494803
--- /dev/null
+++ b/packages/ui/src/components/data-table.test.tsx
@@ -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[] = [
+ {
+ 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(
+ Open {row.lane} }
+ rows={rows.slice(0, 2)}
+ toolbarActions={Create lane }
+ />
+ );
+
+ 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( );
+
+ 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(
+ Assign owner }
+ />
+ );
+
+ 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( );
+
+ 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(
+
+ );
+
+ 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( );
+
+ 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(
+
+ );
+
+ 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 });
+ });
+});
diff --git a/packages/ui/src/components/data-table.tsx b/packages/ui/src/components/data-table.tsx
new file mode 100644
index 0000000..c3e63b7
--- /dev/null
+++ b/packages/ui/src/components/data-table.tsx
@@ -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 = {
+ column: DataTableColumn;
+ sorted: "asc" | "desc" | false;
+};
+
+export type DataTableColumn = {
+ accessor?: keyof TData | ((row: TData) => unknown);
+ align?: DataTableAlignment;
+ cell?: (row: TData) => ReactNode;
+ header: ReactNode | ((column: DataTableColumnContext) => ReactNode);
+ id: string;
+ searchValue?: (row: TData) => string;
+ searchable?: boolean;
+ sortable?: boolean;
+ width?: number | string;
+};
+
+export type DataTableProps = Omit, "children"> & {
+ columns: DataTableColumn[];
+ defaultPageIndex?: number;
+ defaultPageSize?: number;
+ defaultSearchValue?: string;
+ defaultSelection?: Record;
+ 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) => 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;
+ selectionLabel?: ReactNode | ((rows: TData[]) => ReactNode);
+ selectionActions?: (rows: TData[]) => ReactNode;
+ sorting?: DataTableSort[];
+ tableLabel?: string;
+ toolbarActions?: ReactNode;
+};
+
+type InternalColumnMeta = {
+ align: DataTableAlignment;
+ sourceColumn?: DataTableColumn;
+ width?: number | string;
+};
+
+function useControllableState({
+ 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)
+ .map((item) => stringifySearchValue(item))
+ .join(" ");
+ }
+
+ return String(value);
+}
+
+function getColumnAccessorValue(row: TData, column: DataTableColumn) {
+ if (typeof column.accessor === "function") {
+ return column.accessor(row);
+ }
+
+ if (typeof column.accessor === "string") {
+ return row[column.accessor];
+ }
+
+ return undefined;
+}
+
+function getColumnSearchText(row: TData, column: DataTableColumn) {
+ 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(column: DataTableColumn) {
+ return typeof column.header === "string" ? column.header : column.id;
+}
+
+function DataTableInner(
+ {
+ 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,
+ ref: ForwardedRef
+) {
+ 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({
+ controlledValue: sorting,
+ defaultValue: defaultSorting,
+ onChange: onSortingChange
+ });
+ const [currentSelection, setCurrentSelection] = useControllableState>({
+ 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 = (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[] = [
+ ...(selectionEnabled
+ ? [
+ {
+ cell: ({ row }) => (
+ {
+ row.toggleSelected(Boolean(checked));
+ }}
+ />
+ ),
+ enableGlobalFilter: false,
+ enableHiding: false,
+ enableSorting: false,
+ header: ({ table }) => (
+ {
+ table.toggleAllPageRowsSelected(Boolean(checked));
+ }}
+ />
+ ),
+ id: "__select",
+ meta: {
+ align: "center",
+ width: 56
+ } satisfies InternalColumnMeta
+ } satisfies ColumnDef
+ ]
+ : []),
+ ...columns.map((column) => ({
+ accessorFn: column.accessor
+ ? (row: TData) => getColumnAccessorValue(row, column)
+ : undefined,
+ cell: ({ row }: CellContext) =>
+ column.cell
+ ? column.cell(row.original)
+ : stringifySearchValue(getColumnAccessorValue(row.original, column)),
+ enableGlobalFilter: searchableColumns.includes(column),
+ enableSorting: column.sortable ?? false,
+ header: ({ column: tanstackColumn }: HeaderContext) =>
+ 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,
+ sortDescFirst: false
+ })),
+ ...(renderRowActions
+ ? [
+ {
+ cell: ({ row }) => (
+
+ {renderRowActions(row.original)}
+
+ ),
+ enableGlobalFilter: false,
+ enableHiding: false,
+ enableSorting: false,
+ header: () => Row actions ,
+ id: "__actions",
+ meta: {
+ align: "end",
+ width: 88
+ } satisfies InternalColumnMeta
+ } satisfies ColumnDef
+ ]
+ : [])
+ ];
+
+ // 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 (
+ 0
+ })}
+ className={cn(dataTableRootVariants(), className)}
+ ref={ref}
+ >
+ {shouldRenderToolbar ? (
+
+
+ {shouldRenderSearch ? (
+
+ {
+ setCurrentSearchValue(event.currentTarget.value);
+ setCurrentPageIndex(0);
+ }}
+ placeholder={searchPlaceholder}
+ value={currentSearchValue}
+ />
+
+ ) : null}
+
+ {toolbarActions ? {toolbarActions} : null}
+
+ ) : null}
+
+ {selectionEnabled && selectedRows.length > 0 ? (
+
+
+ {typeof selectionLabel === "function"
+ ? selectionLabel(selectedRows)
+ : selectionLabel ?? `${selectedRows.length} selected`}
+
+
+ {selectionActions?.(selectedRows)}
+ {
+ table.resetRowSelection();
+ }}
+ >
+ Clear selection
+
+
+
+ ) : null}
+
+
+ {loading ? : null}
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ const meta = header.column.columnDef.meta as InternalColumnMeta | undefined;
+ const sortState = header.column.getIsSorted();
+ const align: DataTableAlignment = meta?.align ?? "start";
+
+ return (
+
+ {header.isPlaceholder ? null : header.column.getCanSort() ? (
+
+ {flexRender(header.column.columnDef.header, header.getContext())}
+
+ {sortState === "asc" ? "↑" : sortState === "desc" ? "↓" : "↕"}
+
+
+ Sort by{" "}
+ {meta?.sourceColumn ? getHeaderLabel(meta.sourceColumn) : header.column.id}
+
+
+ ) : (
+ flexRender(header.column.columnDef.header, header.getContext())
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+ {loading
+ ? Array.from({ length: loadingRowCount }, (_, index) => (
+
+ {Array.from({ length: totalColumns }, (_, cellIndex) => (
+
+
+
+ ))}
+
+ ))
+ : isEmpty
+ ? (
+
+
+ {empty ?? (
+
+ )}
+
+
+ )
+ : table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => {
+ const meta = cell.column.columnDef.meta as InternalColumnMeta | undefined;
+ const align: DataTableAlignment = meta?.align ?? "start";
+
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ );
+ })}
+
+ ))}
+
+
+
+
+
+
+ {pageStart}-{pageEnd} of {filteredRowCount}
+
+
+
+ {resolvedPageSizeOptions.length > 1 ? (
+
+ Rows
+ {
+ setCurrentPageSize(Number(value));
+ setCurrentPageIndex(0);
+ }}
+ >
+
+
+
+
+ {resolvedPageSizeOptions.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+ ) : null}
+
+
+ Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)}
+
+
+
+ {
+ table.previousPage();
+ }}
+ >
+ Previous
+
+ {
+ table.nextPage();
+ }}
+ >
+ Next
+
+
+
+
+
+
+ );
+}
+
+type DataTableComponent = (
+ props: DataTableProps & { ref?: ForwardedRef }
+) => JSX.Element;
+
+export const DataTable = forwardRef(DataTableInner) as DataTableComponent;
+
+export type DataTableToolbarProps = ComponentPropsWithoutRef<"div">;
+
+export const DataTableToolbar = forwardRef(
+ function DataTableToolbar({ className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableFiltersProps = ComponentPropsWithoutRef<"div">;
+
+export const DataTableFilters = forwardRef(
+ function DataTableFilters({ className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableSearchProps = Omit;
+
+export const DataTableSearch = forwardRef(
+ function DataTableSearch({ className, type = "search", ...props }, ref) {
+ return ;
+ }
+);
+
+export type DataTableContentProps = ComponentPropsWithoutRef<"div"> &
+ VariantProps;
+
+export const DataTableContent = forwardRef(
+ function DataTableContent({ className, loading, ...props }, ref) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableTableProps = ComponentPropsWithoutRef<"table">;
+
+export const DataTableTable = forwardRef(
+ function DataTableTable({ className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableHeaderProps = ComponentPropsWithoutRef<"thead">;
+
+export const DataTableHeader = forwardRef(
+ function DataTableHeader({ className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableHeaderCellProps = Omit, "align"> &
+ VariantProps & {
+ sort?: "asc" | "desc" | false;
+ };
+
+export const DataTableHeaderCell = forwardRef(
+ function DataTableHeaderCell(
+ { align = "start", className, sort = false, sortable = false, ...props },
+ ref
+ ) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableBodyProps = ComponentPropsWithoutRef<"tbody">;
+
+export const DataTableBody = forwardRef(
+ function DataTableBody({ className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableRowProps = ComponentPropsWithoutRef<"tr"> &
+ VariantProps;
+
+export const DataTableRow = forwardRef(
+ function DataTableRow(
+ { className, interactive = true, selected = false, ...props },
+ ref
+ ) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableCellProps = Omit, "align"> &
+ VariantProps;
+
+export const DataTableCell = forwardRef(
+ function DataTableCell({ align = "start", className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableEmptyProps = ComponentPropsWithoutRef<"div"> & {
+ actions?: ReactNode;
+ description?: ReactNode;
+ title?: ReactNode;
+};
+
+export const DataTableEmpty = forwardRef(
+ function DataTableEmpty(
+ {
+ actions,
+ className,
+ description = "No rows match the current search or filter state.",
+ title = "Nothing to show",
+ ...props
+ },
+ ref
+ ) {
+ return (
+
+
+
+ {title}
+ {description}
+
+ {actions ? {actions} : null}
+
+
+ );
+ }
+);
+
+export type DataTableLoadingProps = ComponentPropsWithoutRef<"div"> & {
+ description?: ReactNode;
+ label?: ReactNode;
+};
+
+export const DataTableLoading = forwardRef(
+ function DataTableLoading(
+ {
+ className,
+ description = "Preparing the next set of rows and controls.",
+ label = "Loading rows",
+ ...props
+ },
+ ref
+ ) {
+ return (
+
+
+
+
+
{label}
+
+ {description}
+
+
+
+
+ );
+ }
+);
+
+export type DataTablePaginationProps = ComponentPropsWithoutRef<"div">;
+
+export const DataTablePagination = forwardRef(
+ function DataTablePagination({ className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
+
+export type DataTableSelectionBarProps = ComponentPropsWithoutRef<"div">;
+
+export const DataTableSelectionBar = forwardRef(
+ function DataTableSelectionBar({ className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
diff --git a/packages/ui/src/components/data-table.variants.ts b/packages/ui/src/components/data-table.variants.ts
new file mode 100644
index 0000000..de186f4
--- /dev/null
+++ b/packages/ui/src/components/data-table.variants.ts
@@ -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"
+);
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 86e6258..6db3d8b 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -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,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4d68c7b..afae651 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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