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 ( + + + + + + { + 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) {
-
-
+
+
@@ -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) { +
+
+

+ 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 +
-
-
+ + + + + + + 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. + + + + + + + } + 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={() => ( + <> + + + + )} + 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={ + <> + + + + + } + /> + - + 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( + } + rows={rows.slice(0, 2)} + toolbarActions={} + /> + ); + + 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( + } + /> + ); + + 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)} + +
+
+ ) : 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()) + )} + + ); + })} + + ))} + + + + {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 + +
+ ) : null} + +
+ Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)} +
+ +
+ + +
+
+
+
+
+ ); +} + +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 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 DataTableBodyProps = ComponentPropsWithoutRef<"tbody">; + +export const DataTableBody = forwardRef( + function DataTableBody({ 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