feat: add data table and release checks
This commit is contained in:
+25
-1
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@ai-ui/ui": minor
|
||||
---
|
||||
|
||||
Add the first DataTable implementation with sorting, selection, search, pagination, loading, empty states, and row actions.
|
||||
@@ -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}"
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
Generated
+22
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user