From a5d75f42e98b84ab86ca7e086dbbf7ae85558820 Mon Sep 17 00:00:00 2001 From: kurihada Date: Wed, 25 Mar 2026 19:49:49 +0800 Subject: [PATCH] feat(ui): add analytics primitives and layout patterns --- README.md | 2 +- .../2026-03-25-challenge-progress-pattern.md | 62 + docs/exec-plans/2026-03-25-chart-pattern.md | 77 ++ ...2026-03-25-dashboard-contract-hardening.md | 71 ++ docs/exec-plans/2026-03-25-gauge-component.md | 59 + docs/exec-plans/2026-03-25-grid-primitives.md | 73 ++ .../2026-03-25-input-group-affixes.md | 74 ++ .../2026-03-25-layout-patterns-layer.md | 68 + .../2026-03-25-segmented-control.md | 72 ++ .../2026-03-25-sparkbar-primitive.md | 72 ++ .../2026-03-25-stat-and-metric-cards.md | 65 + .../2026-03-25-two-factor-setup-pattern.md | 59 + .../2026-03-25-value-field-component.md | 66 + .../2026-03-25-workspace-toolbar-pattern.md | 65 + packages/ui/src/components/chart.test.tsx | 227 ++++ packages/ui/src/components/chart.tsx | 1105 +++++++++++++++++ packages/ui/src/components/chart.variants.ts | 101 ++ packages/ui/src/components/gauge.test.tsx | 131 ++ packages/ui/src/components/gauge.tsx | 604 +++++++++ packages/ui/src/components/gauge.variants.ts | 96 ++ packages/ui/src/components/grid.test.tsx | 52 + packages/ui/src/components/grid.tsx | 191 +++ packages/ui/src/components/grid.variants.ts | 52 + .../ui/src/components/input-group.test.tsx | 68 + packages/ui/src/components/input-group.tsx | 110 ++ .../ui/src/components/input-group.variants.ts | 56 + .../ui/src/components/metric-card.test.tsx | 131 ++ packages/ui/src/components/metric-card.tsx | 158 +++ .../ui/src/components/metric-card.variants.ts | 135 ++ .../src/components/segmented-control.test.tsx | 76 ++ .../ui/src/components/segmented-control.tsx | 161 +++ .../components/segmented-control.variants.ts | 40 + packages/ui/src/components/sparkbar.test.tsx | 98 ++ packages/ui/src/components/sparkbar.tsx | 223 ++++ .../ui/src/components/sparkbar.variants.ts | 60 + packages/ui/src/components/stat-card.test.tsx | 94 ++ packages/ui/src/components/stat-card.tsx | 141 +++ .../ui/src/components/stat-card.variants.ts | 81 ++ .../ui/src/components/value-field.test.tsx | 68 + packages/ui/src/components/value-field.tsx | 155 +++ .../ui/src/components/value-field.variants.ts | 48 + packages/ui/src/index.ts | 334 +++++ packages/ui/src/lib/contracts.ts | 16 + packages/ui/src/patterns/app-shell.test.tsx | 35 + packages/ui/src/patterns/app-shell.tsx | 107 ++ .../ui/src/patterns/app-shell.variants.ts | 49 + .../src/patterns/challenge-progress.test.tsx | 78 ++ .../ui/src/patterns/challenge-progress.tsx | 277 +++++ .../patterns/challenge-progress.variants.ts | 104 ++ packages/ui/src/patterns/page-footer.test.tsx | 40 + packages/ui/src/patterns/page-footer.tsx | 107 ++ .../ui/src/patterns/page-footer.variants.ts | 46 + packages/ui/src/patterns/page-header.test.tsx | 56 + packages/ui/src/patterns/page-header.tsx | 203 +++ .../ui/src/patterns/page-header.variants.ts | 141 +++ packages/ui/src/patterns/sidebar-nav.test.tsx | 49 + packages/ui/src/patterns/sidebar-nav.tsx | 202 +++ .../ui/src/patterns/sidebar-nav.variants.ts | 79 ++ .../src/patterns/workspace-toolbar.test.tsx | 39 + .../ui/src/patterns/workspace-toolbar.tsx | 123 ++ .../patterns/workspace-toolbar.variants.ts | 36 + registry/index.json | 281 +++++ roadmap.md | 4 +- 63 files changed, 7751 insertions(+), 2 deletions(-) create mode 100644 docs/exec-plans/2026-03-25-challenge-progress-pattern.md create mode 100644 docs/exec-plans/2026-03-25-chart-pattern.md create mode 100644 docs/exec-plans/2026-03-25-dashboard-contract-hardening.md create mode 100644 docs/exec-plans/2026-03-25-gauge-component.md create mode 100644 docs/exec-plans/2026-03-25-grid-primitives.md create mode 100644 docs/exec-plans/2026-03-25-input-group-affixes.md create mode 100644 docs/exec-plans/2026-03-25-layout-patterns-layer.md create mode 100644 docs/exec-plans/2026-03-25-segmented-control.md create mode 100644 docs/exec-plans/2026-03-25-sparkbar-primitive.md create mode 100644 docs/exec-plans/2026-03-25-stat-and-metric-cards.md create mode 100644 docs/exec-plans/2026-03-25-two-factor-setup-pattern.md create mode 100644 docs/exec-plans/2026-03-25-value-field-component.md create mode 100644 docs/exec-plans/2026-03-25-workspace-toolbar-pattern.md create mode 100644 packages/ui/src/components/chart.test.tsx create mode 100644 packages/ui/src/components/chart.tsx create mode 100644 packages/ui/src/components/chart.variants.ts create mode 100644 packages/ui/src/components/gauge.test.tsx create mode 100644 packages/ui/src/components/gauge.tsx create mode 100644 packages/ui/src/components/gauge.variants.ts create mode 100644 packages/ui/src/components/grid.test.tsx create mode 100644 packages/ui/src/components/grid.tsx create mode 100644 packages/ui/src/components/grid.variants.ts create mode 100644 packages/ui/src/components/input-group.test.tsx create mode 100644 packages/ui/src/components/input-group.tsx create mode 100644 packages/ui/src/components/input-group.variants.ts create mode 100644 packages/ui/src/components/metric-card.test.tsx create mode 100644 packages/ui/src/components/metric-card.tsx create mode 100644 packages/ui/src/components/metric-card.variants.ts create mode 100644 packages/ui/src/components/segmented-control.test.tsx create mode 100644 packages/ui/src/components/segmented-control.tsx create mode 100644 packages/ui/src/components/segmented-control.variants.ts create mode 100644 packages/ui/src/components/sparkbar.test.tsx create mode 100644 packages/ui/src/components/sparkbar.tsx create mode 100644 packages/ui/src/components/sparkbar.variants.ts create mode 100644 packages/ui/src/components/stat-card.test.tsx create mode 100644 packages/ui/src/components/stat-card.tsx create mode 100644 packages/ui/src/components/stat-card.variants.ts create mode 100644 packages/ui/src/components/value-field.test.tsx create mode 100644 packages/ui/src/components/value-field.tsx create mode 100644 packages/ui/src/components/value-field.variants.ts create mode 100644 packages/ui/src/patterns/app-shell.test.tsx create mode 100644 packages/ui/src/patterns/app-shell.tsx create mode 100644 packages/ui/src/patterns/app-shell.variants.ts create mode 100644 packages/ui/src/patterns/challenge-progress.test.tsx create mode 100644 packages/ui/src/patterns/challenge-progress.tsx create mode 100644 packages/ui/src/patterns/challenge-progress.variants.ts create mode 100644 packages/ui/src/patterns/page-footer.test.tsx create mode 100644 packages/ui/src/patterns/page-footer.tsx create mode 100644 packages/ui/src/patterns/page-footer.variants.ts create mode 100644 packages/ui/src/patterns/page-header.test.tsx create mode 100644 packages/ui/src/patterns/page-header.tsx create mode 100644 packages/ui/src/patterns/page-header.variants.ts create mode 100644 packages/ui/src/patterns/sidebar-nav.test.tsx create mode 100644 packages/ui/src/patterns/sidebar-nav.tsx create mode 100644 packages/ui/src/patterns/sidebar-nav.variants.ts create mode 100644 packages/ui/src/patterns/workspace-toolbar.test.tsx create mode 100644 packages/ui/src/patterns/workspace-toolbar.tsx create mode 100644 packages/ui/src/patterns/workspace-toolbar.variants.ts diff --git a/README.md b/README.md index f52b371..5b76e45 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ default styling with its own tokens, motion recipes, and component contract. ## Current status - The foundation, token layer, authoring contract, Storybook docs, and unit coverage are in place. -- The public UI surface now includes the core form and overlay set plus advanced patterns such as `DataTable`, `Command`, `Combobox`, `Sheet`, and `EmptyState`. +- The public UI surface now includes the core form and overlay set plus advanced patterns such as `Chart`, `DataTable`, `Command`, `Combobox`, `Sheet`, and `EmptyState`. - The default distribution path is package-first: `@ai-ui/ui` and `@ai-ui/tokens` are versioned and validated for package consumption. - The internal source-copy registry flow remains available as an optional mode for teams that want local ownership of copied component source. - The active visual direction is documented in [DESIGN.md](/Users/xd/project/cadence-ui/DESIGN.md): a single Material You inspired language with dynamic color, tonal surfaces, large radii, and one shared motion system. diff --git a/docs/exec-plans/2026-03-25-challenge-progress-pattern.md b/docs/exec-plans/2026-03-25-challenge-progress-pattern.md new file mode 100644 index 0000000..2b91a06 --- /dev/null +++ b/docs/exec-plans/2026-03-25-challenge-progress-pattern.md @@ -0,0 +1,62 @@ +# Challenge Progress Pattern + +- Status: `completed` +- Owner: `Codex` +- Date: `2026-03-25` + +## Goal + +Add a reusable `ChallengeProgress` pattern to `packages/ui` that recreates the stacked challenge +target panel shown in the reference, while staying inside Cadence UI's own tonal, rounded, and +component-first visual language. + +## Scope + +- In scope: + - add a new `ChallengeProgress` pattern for multi-row target progress panels + - compose the pattern from existing Cadence UI primitives where practical, especially `Badge`, + `Progress`, and `Spinner` + - add tests and Storybook docs for the new pattern + - export the pattern from `@ai-ui/ui` +- Out of scope: + - changing the base `Progress` contract or segment-count limits + - adding a new shared icon package or icon primitives + - registry metadata refresh while the worktree already carries unrelated registry changes + +## Constraints + +- Keep the pattern visually aligned with `DESIGN.md`, not the dark reference palette. +- Prefer stable slots and data attributes over story-local styling glue. +- Preserve the dirty worktree and avoid rewriting unrelated in-flight edits. +- Use the smallest new API surface that can still support multiple challenge rows. + +## Affected Surfaces + +- `packages/ui/src/patterns/challenge-progress.tsx` +- `packages/ui/src/patterns/challenge-progress.variants.ts` +- `packages/ui/src/patterns/challenge-progress.test.tsx` +- `packages/ui/src/index.ts` +- `apps/docs/src/patterns/challenge-progress.stories.tsx` + +## Plan + +1. Define the pattern API and slot structure around a title plus repeated challenge rows. +2. Implement the pattern styling with existing tokens and compose row status from existing + primitives. +3. Add unit coverage and Storybook docs showing the reference-inspired layout in Cadence UI's own + palette. +4. Run targeted validation for the new pattern, exports, and docs surface. + +## Validation + +- `pnpm --filter @ai-ui/ui typecheck` +- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/patterns/challenge-progress.test.tsx --config ../../vitest.config.ts` +- `pnpm exec eslint apps/docs/src/patterns/challenge-progress.stories.tsx packages/ui/src/patterns/challenge-progress.tsx packages/ui/src/patterns/challenge-progress.variants.ts packages/ui/src/patterns/challenge-progress.test.tsx packages/ui/src/index.ts` +- `pnpm harness:validate:docs` + +## Status Log + +- `2026-03-25 19:44` Read the required system-of-record files, inspected the existing `Progress`, `Badge`, `Spinner`, and pattern authoring conventions, and scoped the work as a new reusable pattern instead of a one-off story. +- `2026-03-25 20:20` Implemented the `ChallengeProgress` pattern and variants around a title plus repeated challenge rows, composing row chips from `Badge`, row meters from segmented `Progress`, and loading affordances from `Spinner`. +- `2026-03-25 20:24` Added unit coverage, Storybook docs, and package exports for the new pattern. +- `2026-03-25 20:26` Verified `pnpm --filter @ai-ui/ui typecheck`, targeted `vitest`, targeted `eslint`, and `pnpm harness:validate:docs`. diff --git a/docs/exec-plans/2026-03-25-chart-pattern.md b/docs/exec-plans/2026-03-25-chart-pattern.md new file mode 100644 index 0000000..0d2d63f --- /dev/null +++ b/docs/exec-plans/2026-03-25-chart-pattern.md @@ -0,0 +1,77 @@ +# Chart Pattern + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add a reusable `Chart` pattern to `@ai-ui/ui` that can render the kind of dashboard trend panel +shown in the supplied reference: multi-series lines, soft area shading, an active point summary, +and a composable card-like review surface that still follows Cadence UI's current visual language. + +## Scope + +- In scope: + - add a new `Chart` component to `packages/ui` + - keep the first release focused on cartesian trend charts for dashboard analytics surfaces + - support multi-series rendering, optional area fills, axes, legend, and active-point callouts + - add Storybook stories, including a `Sales Impact`-style showcase + - add unit coverage for the public contract and interactive active-point behavior + - refresh repo status docs to reflect the shipped pattern +- Out of scope: + - introducing a third-party charting dependency + - building a full visualization framework with bars, pies, heatmaps, and pivoting + - server-driven drilldown, zooming, panning, or data transforms outside the component + - adding the new story to the curated browser harness unless it becomes a required regression + surface + +## Constraints + +- Follow `DESIGN.md` over the raw screenshot when visual details conflict. +- Reuse existing tokens, motion rules, and card-like surface styling instead of hardcoded brand + treatments. +- Keep the API source-owned and narrow, similar to the `DataTable` philosophy. +- Preserve stable `data-slot` and `data-*` hooks so docs and tests can target the component + predictably. + +## Affected Surfaces + +- `docs/exec-plans/2026-03-25-chart-pattern.md` +- `packages/ui/src/components/chart.tsx` +- `packages/ui/src/components/chart.variants.ts` +- `packages/ui/src/components/chart.test.tsx` +- `packages/ui/src/index.ts` +- `apps/docs/src/components/chart.stories.tsx` +- `README.md` +- `roadmap.md` +- `registry/index.json` + +## Plan + +1. Add an execution plan and update repo status docs for the new public pattern. +2. Implement a source-owned `Chart` component that supports multi-series dashboard trend charts + without adding an external chart library. +3. Add Storybook stories that explain anatomy and demonstrate a `Sales Impact`-style analytics + panel. +4. Add unit tests for slots, legend rendering, active-point updates, and controlled state. +5. Run the narrowest useful validation suites, then record any remaining gaps. + +## Validation + +- `pnpm test -- --runInBand` +- `pnpm harness:validate:docs` +- `pnpm registry:build` + +## Orchestration Task Sketch + +- `T1`: implement the Chart component and export surface +- `T2 -> T1`: add docs stories and validate the rendered showcase +- `T3 -> T1`: add tests and registry updates + +## Status Log + +- `2026-03-25 19:08` Read the system-of-record files and confirmed the repo does not yet ship a chart pattern. +- `2026-03-25 19:18` Started the chart implementation plan for a new dashboard-focused analytics pattern. +- `2026-03-25 19:42` Implemented the new `Chart` component, public exports, stories, tests, and status-doc updates. +- `2026-03-25 19:55` Validated `packages/ui` typecheck, targeted chart tests, package builds, Storybook docs build, and registry generation. `pnpm harness:validate:changed` still failed because the dirty worktree already contains unrelated `apps/docs/src/revenue-dashboard.stories.tsx` type errors and existing `react-refresh/only-export-components` warnings in `packages/ui/src/components/context-menu.tsx`. diff --git a/docs/exec-plans/2026-03-25-dashboard-contract-hardening.md b/docs/exec-plans/2026-03-25-dashboard-contract-hardening.md new file mode 100644 index 0000000..c06aee9 --- /dev/null +++ b/docs/exec-plans/2026-03-25-dashboard-contract-hardening.md @@ -0,0 +1,71 @@ +# Dashboard Contract Hardening + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Harden the new dashboard-oriented component contracts so the `Revenue Dashboard` scene no longer +needs to bypass them for high-emphasis KPI cards or for custom chart header layouts. + +## Scope + +- In scope: + - extend `MetricCard` with high-emphasis tones suitable for dark or hero KPI panels + - add structured `MetricCard` header slots for leading and aside content + - make the `Chart` header composable without breaking the current convenience props + - refresh docs stories, tests, and the revenue dashboard consumer to use the stronger contracts +- Out of scope: + - introducing a full dashboard layout or navigation system + - expanding the chart surface beyond the existing cartesian trend pattern + - extracting new sparkline or sparkbar primitives in this slice + +## Constraints + +- Preserve the current Material-inspired design direction in `DESIGN.md`. +- Keep the API additive and source-owned instead of replacing existing stories with one-off markup. +- Preserve the stable slot and `data-*` review surface so tests and docs stay predictable. +- Keep the current `title` / `description` / `value` / `valueChange` chart props working while + adding the more composable header path. + +## Affected Surfaces + +- `docs/exec-plans` +- `packages/ui/src/components/metric-card.tsx` +- `packages/ui/src/components/metric-card.variants.ts` +- `packages/ui/src/components/chart.tsx` +- `packages/ui/src/components/chart.variants.ts` +- `packages/ui/src/index.ts` +- `packages/ui/src/components/*.test.tsx` +- `apps/docs/src/components/*.stories.tsx` +- `apps/docs/src/revenue-dashboard.stories.tsx` + +## Plan + +1. Extend `MetricCard` with `inverse` and `hero` visual tones plus stable `leading` and `aside` + header slots. +2. Refactor `Chart` to accept a composed header while keeping the current prop-based header API as + a convenience fallback. +3. Update stories and the revenue dashboard scene so the new contracts are exercised by real + compositions instead of only isolated tests. +4. Run focused component and docs validation, then record any residual issues. + +## Validation + +- `pnpm --filter @ai-ui/ui test -- metric-card chart` +- `pnpm --filter @ai-ui/ui typecheck` +- `pnpm build:docs` + +## Orchestration Task Sketch + +- `T1`: harden `MetricCard` tone and header composition contract +- `T2 -> T1`: harden `Chart` header composition contract +- `T3 -> T2`: refresh stories, revenue dashboard usage, and focused validation + +## Status Log + +- `2026-03-25 20:36` started the contract-hardening slice after reviewing the revenue dashboard and confirming that `Sales Growth` and `Sales Impact` still rely on API workarounds rather than first-class component affordances +- `2026-03-25 20:49` extended `MetricCard` with `inverse` and `hero` tones plus `leading` and `aside` header slots, then refactored the docs story and revenue dashboard consumer to use them +- `2026-03-25 20:56` refactored `Chart` to support a composed header path while preserving the legacy prop-driven header contract, and updated stories and tests to cover both paths +- `2026-03-25 20:59` validated focused `MetricCard` and `Chart` tests plus `build:docs`; `pnpm --filter @ai-ui/ui typecheck` is still blocked by unrelated pre-existing `packages/ui/src/components/sparkbar.tsx` type errors outside this slice diff --git a/docs/exec-plans/2026-03-25-gauge-component.md b/docs/exec-plans/2026-03-25-gauge-component.md new file mode 100644 index 0000000..c8134f3 --- /dev/null +++ b/docs/exec-plans/2026-03-25-gauge-component.md @@ -0,0 +1,59 @@ +# Gauge Component + +- Status: `completed` +- Owner: `Codex` +- Date: `2026-03-25` + +## Goal + +Add a true radial `Gauge` component to `packages/ui` so the system supports meter-style KPI +visuals in addition to linear `Progress` and segmented progress bars. + +## Scope + +- In scope: + - add a new `Gauge` component with radial SVG rendering + - support dial and semi-circle shapes + - cover the component with tests and Storybook docs + - export the component from `@ai-ui/ui` +- Out of scope: + - registry metadata refresh while the worktree already contains unrelated registry edits + - retrofitting local draft dashboard stories to use the new component + - animated needle / pointer gauges + +## Constraints + +- `Gauge` should use `role="meter"` rather than overloading `Progress`. +- Keep `variant` semantic and reserve geometry differences for a dedicated shape dimension. +- Follow the current Cadence UI tonal and motion language. +- Preserve the existing dirty worktree and avoid rewriting unrelated draft files. + +## Affected Surfaces + +- `packages/ui/src/components/gauge.tsx` +- `packages/ui/src/components/gauge.variants.ts` +- `packages/ui/src/components/gauge.test.tsx` +- `packages/ui/src/skins.css` +- `packages/ui/src/index.ts` +- `apps/docs/src/components/gauge.stories.tsx` + +## Plan + +1. Define the Gauge API, rendering geometry, and stable slots. +2. Implement the component with skin tokens and SVG arc math. +3. Add tests and Storybook docs for geometry, states, and accessibility guidance. +4. Run targeted validation for the package surface and changed files. + +## Validation + +- `pnpm --filter @ai-ui/ui typecheck` +- `pnpm --filter @ai-ui/ui build` +- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/gauge.test.tsx --config ../../vitest.config.ts` +- `pnpm exec eslint apps/docs/src/components/gauge.stories.tsx packages/ui/src/components/gauge.tsx packages/ui/src/components/gauge.variants.ts packages/ui/src/components/gauge.test.tsx packages/ui/src/index.ts` + +## Status Log + +- `2026-03-25 18:30` Read the closest dashboard-oriented component and story patterns, and scoped Gauge as a separate meter primitive rather than an extension of Progress. +- `2026-03-25 18:42` Implemented the `Gauge` component with radial SVG geometry, dial and semi-circle shapes, meter semantics, and stable slots. +- `2026-03-25 18:47` Added package exports, skin tokens, unit coverage, and a dedicated Storybook docs page for Gauge. +- `2026-03-25 18:51` Verified `pnpm --filter @ai-ui/ui typecheck`, `pnpm --filter @ai-ui/ui build`, `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/gauge.test.tsx --config ../../vitest.config.ts`, targeted `eslint`, and `pnpm harness:validate:docs`. diff --git a/docs/exec-plans/2026-03-25-grid-primitives.md b/docs/exec-plans/2026-03-25-grid-primitives.md new file mode 100644 index 0000000..2176f74 --- /dev/null +++ b/docs/exec-plans/2026-03-25-grid-primitives.md @@ -0,0 +1,73 @@ +# Grid Primitives + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add Cadence UI source-owned `Row` / `Col` grid primitives so product and docs surfaces can build +responsive split layouts and dashboard sections without repeating ad hoc width math, alignment +fixes, or one-off `grid-cols-*` strings. + +## Scope + +- In scope: + - add public `Row` and `Col` primitives to `packages/ui` + - support a 12-column responsive layout model with tokenized gaps + - support base plus breakpoint-specific `span` and `offset` placement on `Col` + - expose stable slot and `data-*` hooks + - document the primitives in Storybook + - add unit coverage for the main layout contract +- Out of scope: + - nested layout orchestration helpers such as `SplitPanel` or dialog-specific shells + - CSS container-query driven layout APIs + - ordering, push/pull, or visual reordering props that can drift from DOM order + - replacing every existing docs layout in this pass + +## Constraints + +- Keep the API small and familiar to teams coming from Ant Design / Element style `Row` / `Col`. +- Stay token-first for spacing instead of baking new hardcoded per-story gutters. +- Preserve DOM order as the semantic order; layout props should not encourage accessibility drift. +- Avoid introducing an external layout framework or dependency for this capability. + +## Affected Surfaces + +- `packages/ui/src/components` +- `packages/ui/src/index.ts` +- `packages/ui/src/lib/contracts.ts` +- `packages/ui/src/skins.css` +- `apps/docs/src/components` +- `docs/exec-plans` +- `registry/index.json` + +## Plan + +1. Add the execution plan and define a minimal Cadence UI grid contract. +2. Implement `Row` and `Col` with a 12-column base, tokenized gap sizes, and responsive + `span` / `offset` placement. +3. Export the primitives on the public surface and document them in Storybook with usage, + anatomy, and accessibility guidance. +4. Add focused unit coverage, run narrow validation, then rebuild registry metadata for the + new public entrypoint. + +## Validation + +- `pnpm --filter @ai-ui/ui test -- grid` +- `pnpm build:docs` +- `pnpm registry:build` + +## Orchestration Task Sketch + +- `T1`: implement the grid primitives, skins, and public exports +- `T2 -> T1`: add Storybook docs, tests, and rebuild registry metadata + +## Status Log + +- `2026-03-25 16:18` started after confirming the repo has many direct grid utility usages but no + reusable source-owned layout primitive for responsive row/column composition +- `2026-03-25 18:54` implemented public `Row` / `Col` primitives, exported them from `@ai-ui/ui`, + added Storybook coverage, and registered the new source-owned `grid` entrypoint +- `2026-03-25 18:56` validated with `pnpm --filter @ai-ui/ui test -- grid`, + `pnpm --filter @ai-ui/ui build`, `pnpm build:docs`, and `pnpm registry:build` diff --git a/docs/exec-plans/2026-03-25-input-group-affixes.md b/docs/exec-plans/2026-03-25-input-group-affixes.md new file mode 100644 index 0000000..3516930 --- /dev/null +++ b/docs/exec-plans/2026-03-25-input-group-affixes.md @@ -0,0 +1,74 @@ +# Input Group Affixes + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add a reusable input-affix composition layer so search icons, badges, and suffix actions stop +being page-level absolute-position wrappers and instead ship as a stable Cadence UI pattern. + +## Scope + +- In scope: + - add a public `InputGroup` companion pattern with prefix and suffix slots + - keep the existing `Input` API intact while letting it compose cleanly inside the new group + - update at least one real docs surface that currently hand-rolls affix layout + - add Storybook coverage and unit tests for the new slot and field wiring behavior +- Out of scope: + - redesigning `Field` or introducing a second label/help-text abstraction + - broad visual restyling of all text-entry surfaces + - migrating every existing search field in one pass + +## Constraints + +- Preserve the existing `Input` ref and prop contract for non-grouped usage. +- Follow the current `Field` state inheritance rules for disabled, invalid, readonly, and + required behavior. +- Expose stable `data-slot` hooks for the new affix anatomy. +- Keep the new pattern token-driven and visually aligned with the current input chrome. + +## Affected Surfaces + +- `docs/exec-plans/2026-03-25-input-group-affixes.md` +- `packages/ui/src/lib/contracts.ts` +- `packages/ui/src/components/input.tsx` +- `packages/ui/src/components/input-group.tsx` +- `packages/ui/src/components/input-group.variants.ts` +- `packages/ui/src/components/input-group.test.tsx` +- `packages/ui/src/components/data-table.tsx` +- `packages/ui/src/components/command.tsx` +- `packages/ui/src/components/command.test.tsx` +- `packages/ui/src/index.ts` +- `apps/docs/src/components/input-group.stories.tsx` +- `apps/docs/src/revenue-dashboard.stories.tsx` +- `registry/index.json` + +## Plan + +1. Add the execution plan and define the new input-affix pattern against the existing + `Input`/`Field` contract. +2. Implement `InputGroup` plus grouped-input behavior without regressing standalone `Input`. +3. Migrate the most obvious repeated search wrapper surfaces to the new composition layer. +4. Add stories, tests, and registry updates, then run the narrowest useful validation passes. + +## Validation + +- `pnpm --filter @ai-ui/ui test -- --run packages/ui/src/components/input-group.test.tsx packages/ui/src/components/input.test.tsx packages/ui/src/components/command.test.tsx` +- `pnpm --filter @ai-ui/ui typecheck` +- `pnpm harness:validate:docs` +- `pnpm registry:build` + +## Orchestration Task Sketch + +- `T1`: implement the input group component and integrate standalone input behavior +- `T2 -> T1`: migrate docs usage and command/data-table composition to the new slots +- `T3 -> T1`: add tests, stories, and registry updates + +## Status Log + +- `2026-03-25 14:29` Read the system-of-record files and confirmed the current `Input` contract has no affix composition layer. +- `2026-03-25 14:35` Chose a sibling `InputGroup` pattern over mutating `Input` into a wrapped root so the existing input API stays stable outside grouped usage. +- `2026-03-25 15:17` Implemented `InputGroup`, grouped input behavior, and the first consumer migrations in `CommandInput`, `DataTableSearch`, and the revenue dashboard story. +- `2026-03-25 15:24` Verified `pnpm --filter @ai-ui/ui test -- --run packages/ui/src/components/input-group.test.tsx packages/ui/src/components/input.test.tsx packages/ui/src/components/command.test.tsx packages/ui/src/components/data-table.test.tsx`, `pnpm --filter @ai-ui/ui typecheck`, `pnpm harness:validate:docs`, and `pnpm registry:build`. diff --git a/docs/exec-plans/2026-03-25-layout-patterns-layer.md b/docs/exec-plans/2026-03-25-layout-patterns-layer.md new file mode 100644 index 0000000..8e6fcda --- /dev/null +++ b/docs/exec-plans/2026-03-25-layout-patterns-layer.md @@ -0,0 +1,68 @@ +# Layout Patterns Layer + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add a dedicated `patterns` layer inside `@ai-ui/ui` for reusable page and shell structures that +sit above base components: `SidebarNav`, `PageHeader`, `AppShell`, and `PageFooter`. + +## Scope + +- In scope: + - create `packages/ui/src/patterns/` + - implement `SidebarNav`, `PageHeader`, `AppShell`, and `PageFooter` + - export the new patterns from `@ai-ui/ui` + - add Storybook docs under `Patterns/*` + - refactor the revenue dashboard scene to consume the new patterns where they fit + - add focused tests for the public slot and state contract +- Out of scope: + - splitting a second npm package for patterns + - adding router-aware or data-source-aware app shells + - replacing every existing layout composition across the repo in one slice + +## Constraints + +- Keep `components` as the lower layer and allow `patterns` to depend on `components`, not the reverse. +- Follow `DESIGN.md` and reuse the existing token and motion system instead of inventing a separate layout skin. +- Keep APIs compound and slot-oriented so different pages can restyle the same patterns. +- Use Storybook titles under `Patterns/*` so the review surface reflects the new layer clearly. + +## Affected Surfaces + +- `packages/ui/src/patterns` +- `packages/ui/src/index.ts` +- `apps/docs/src/patterns` +- `apps/docs/src/revenue-dashboard.stories.tsx` +- `docs/exec-plans` + +## Plan + +1. Create the new `patterns` directory and implement the four shared layout patterns with stable + slots and restrained variant surfaces. +2. Export the patterns from `@ai-ui/ui` without moving the existing base components. +3. Add docs stories under `Patterns/*` that explain when to use each pattern. +4. Refactor the revenue dashboard scene to consume `SidebarNav`, `PageHeader`, and `AppShell`, and + use `PageFooter` where it adds real value. +5. Add focused tests and run the narrowest useful validation suites. + +## Validation + +- `pnpm --filter @ai-ui/ui test -- sidebar-nav page-header page-footer app-shell` +- `pnpm --filter @ai-ui/ui typecheck` +- `pnpm build:docs` + +## Orchestration Task Sketch + +- `T1`: implement the new `patterns` layer and exports +- `T2 -> T1`: add docs stories for the four patterns +- `T3 -> T2`: refactor the revenue dashboard consumer and run focused validation + +## Status Log + +- `2026-03-25 16:48` started the patterns-layer slice after confirming the repo has component-internal headers and footers but no dedicated page-shell layer for shared layout structures +- `2026-03-25 17:06` implemented the new `packages/ui/src/patterns/` layer with `SidebarNav`, `PageHeader`, `PageFooter`, and `AppShell`, then exported the new public surface from `@ai-ui/ui` +- `2026-03-25 17:10` added Storybook docs under `Patterns/*` and refactored the revenue dashboard scene to consume the new shell and page patterns +- `2026-03-25 17:12` validated exact new pattern tests, workspace typecheck, and Storybook production build; the broader `pnpm --filter @ai-ui/ui test -- ...` entrypoint still pulls in unrelated in-flight `combobox` and `command` failures from concurrent work diff --git a/docs/exec-plans/2026-03-25-segmented-control.md b/docs/exec-plans/2026-03-25-segmented-control.md new file mode 100644 index 0000000..9463cbc --- /dev/null +++ b/docs/exec-plans/2026-03-25-segmented-control.md @@ -0,0 +1,72 @@ +# Segmented Control + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add a compact `SegmentedControl` component to `@ai-ui/ui` for lightweight dashboard-style view +switching so consumers do not need to stretch `Tabs` into every small top-of-panel toggle. + +## Scope + +- In scope: + - add a new `SegmentedControl` component to `packages/ui` + - expose the component from the package entrypoint + - add unit coverage for controlled/uncontrolled behavior and stable contract hooks + - add Storybook docs that explain when to choose `SegmentedControl` instead of `Tabs` + - update the revenue dashboard story to use the new control for its contribution switcher + - refresh `registry/index.json` so source-copy consumers can install the new component +- Out of scope: + - redesigning the existing `Tabs` API + - replacing every current `Tabs` usage across docs + - adding icon-only, multi-select, or overflow handling variants in the first pass + +## Constraints + +- Keep the API narrower than `Tabs`; this should solve lightweight switching, not become another + generic navigation primitive. +- Reuse the current token and motion language from `DESIGN.md` instead of introducing a new visual + style. +- Preserve stable `data-slot` and `data-*` hooks for docs and tests. +- Do not add a new dependency if the existing Radix stack is sufficient. + +## Affected Surfaces + +- `docs/exec-plans/2026-03-25-segmented-control.md` +- `packages/ui/src/components/segmented-control.tsx` +- `packages/ui/src/components/segmented-control.variants.ts` +- `packages/ui/src/components/segmented-control.test.tsx` +- `packages/ui/src/index.ts` +- `apps/docs/src/components/segmented-control.stories.tsx` +- `apps/docs/src/revenue-dashboard.stories.tsx` +- `registry/index.json` + +## Plan + +1. Add the execution plan and confirm the public contract for a compact single-select control. +2. Implement `SegmentedControl` in `packages/ui` with stable slots, disabled state, and controlled + or uncontrolled value support. +3. Add Storybook docs that explain the intended usage boundary against `Tabs`. +4. Replace the revenue dashboard contribution switcher with `SegmentedControl` to prove the + dashboard-friendly use case. +5. Run targeted validation and refresh registry metadata. + +## Validation + +- `pnpm test -- --runInBand segmented-control tabs` +- `pnpm harness:validate:docs` +- `pnpm registry:build` + +## Orchestration Task Sketch + +- `T1`: implement `SegmentedControl` source, variants, and exports +- `T2 -> T1`: add docs stories and migrate the revenue dashboard example +- `T3 -> T1`: add tests and refresh registry metadata + +## Status Log + +- `2026-03-25 20:07` Read the system-of-record files and confirmed that `Tabs` is currently the only compact peer-switching primitive in the public surface. +- `2026-03-25 20:18` Started the segmented-control slice to cover lightweight dashboard toggles without expanding the `Tabs` API. +- `2026-03-25 23:32` Shipped the new `SegmentedControl`, migrated the revenue dashboard contribution switcher, refreshed `registry/index.json`, and cleared the existing `InputGroup` type blocker so build and docs validation could pass again. diff --git a/docs/exec-plans/2026-03-25-sparkbar-primitive.md b/docs/exec-plans/2026-03-25-sparkbar-primitive.md new file mode 100644 index 0000000..e5aef19 --- /dev/null +++ b/docs/exec-plans/2026-03-25-sparkbar-primitive.md @@ -0,0 +1,72 @@ +# Sparkbar Primitive + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add a dedicated `Sparkbar` micro-visual primitive to `@ai-ui/ui` so compact KPI cards and +dashboard tiles can render tiny bar-based trend summaries without hand-written DOM. + +## Scope + +- In scope: + - add a new `Sparkbar` component to `packages/ui` + - keep the API narrow and card-focused: small size, low configuration, embedded usage + - support simple bar highlighting for active ranges without turning the component into a full + charting surface + - add unit tests and Storybook docs for the public contract + - replace the hand-written sparkbar markup in `apps/docs/src/revenue-dashboard.stories.tsx` + and the related metric-card docs story +- Out of scope: + - introducing a full `Sparkline` API in the same slice + - adding axes, tooltips, interaction, or data transforms that belong to `Chart` + - expanding `Chart` itself to absorb micro-visual responsibilities + - broader dashboard contract cleanup outside the bar micro-vis use cases + +## Constraints + +- Follow `DESIGN.md` and keep the visual language tonal, rounded, and quiet enough for card media. +- Preserve a stable slot and `data-*` contract so docs and tests can target the primitive. +- Keep the public API additive and intentionally smaller than `Chart`. +- Avoid rewriting unrelated dirty-worktree changes while touching shared files like docs stories and + package exports. + +## Affected Surfaces + +- `docs/exec-plans/2026-03-25-sparkbar-primitive.md` +- `packages/ui/src/components/sparkbar.tsx` +- `packages/ui/src/components/sparkbar.variants.ts` +- `packages/ui/src/components/sparkbar.test.tsx` +- `packages/ui/src/index.ts` +- `packages/ui/src/skins.css` +- `apps/docs/src/components/sparkbar.stories.tsx` +- `apps/docs/src/components/metric-card.stories.tsx` +- `apps/docs/src/revenue-dashboard.stories.tsx` +- `registry/index.json` + +## Plan + +1. Define a narrow `Sparkbar` API for embedded numeric bar trends and active-range emphasis. +2. Implement the component with stable slots, tone/variant styling, and decorative-by-default + accessibility behavior. +3. Add Storybook stories that explain when to use `Sparkbar` instead of `Chart`. +4. Replace the existing hand-written bar maps in docs consumers with the new primitive. +5. Run focused package and docs validation, then record any remaining gaps. + +## Validation + +- `pnpm --filter @ai-ui/ui typecheck` +- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/sparkbar.test.tsx --config ../../vitest.config.ts` +- `pnpm exec eslint apps/docs/src/components/sparkbar.stories.tsx apps/docs/src/components/metric-card.stories.tsx apps/docs/src/revenue-dashboard.stories.tsx packages/ui/src/components/sparkbar.tsx packages/ui/src/components/sparkbar.variants.ts packages/ui/src/components/sparkbar.test.tsx packages/ui/src/index.ts` +- `pnpm harness:validate:docs` +- `pnpm registry:build` + +## Status Log + +- `2026-03-25 21:15` Read the system-of-record files and confirmed the repo currently has a full `Chart` pattern but no dedicated micro bar-trend primitive. +- `2026-03-25 21:22` Scoped the new component as `Sparkbar`: low-config, non-interactive, card-embedded, and explicitly separate from `Chart`. +- `2026-03-25 23:18` Implemented `Sparkbar`, tokenized its size/tone/variant styling, exported it from `@ai-ui/ui`, and added unit coverage for slots, emphasis, and accessibility behavior. +- `2026-03-25 23:28` Added dedicated Storybook docs and migrated the handwritten spark bars in the revenue dashboard and metric-card stories to the new primitive. +- `2026-03-25 23:34` Verified `pnpm --filter @ai-ui/ui typecheck`, targeted Sparkbar Vitest coverage, targeted ESLint, `pnpm harness:validate:docs`, and `pnpm registry:build`. diff --git a/docs/exec-plans/2026-03-25-stat-and-metric-cards.md b/docs/exec-plans/2026-03-25-stat-and-metric-cards.md new file mode 100644 index 0000000..9a54391 --- /dev/null +++ b/docs/exec-plans/2026-03-25-stat-and-metric-cards.md @@ -0,0 +1,65 @@ +# Stat And Metric Cards + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add two dashboard-oriented composition components to `@ai-ui/ui`: a compact `StatCard` +for the common label/value/delta/description KPI pattern, and a richer `MetricCard` +that extends the same pattern with media, footer, and action regions for analytics +panels. + +## Scope + +- In scope: + - add `StatCard` and `MetricCard` component source, variants, exports, tests, and docs + - expose stable slot and state metadata consistent with the repo contract + - refactor the new revenue dashboard scene to consume the components where they fit +- Out of scope: + - introducing a full dashboard layout system + - replacing all existing card-based compositions across the repo + - expanding the chart system beyond what already exists + +## Constraints + +- Follow the active Material-inspired design direction in `DESIGN.md`. +- Keep the API small and slot-oriented rather than introducing a single oversized props surface. +- Reuse existing card tokens and motion recipes instead of creating a parallel styling layer. +- Preserve enough flexibility that richer panels can still compose custom chart and footer content. + +## Affected Surfaces + +- `packages/ui/src/components` +- `packages/ui/src/index.ts` +- `apps/docs/src/components` +- `apps/docs/src/revenue-dashboard.stories.tsx` +- `docs/exec-plans` + +## Plan + +1. Implement `StatCard` as a compact KPI card with semantic slots for eyebrow, label, value, delta, and description. +2. Implement `MetricCard` on the same visual and slot contract, adding media, footer, and actions regions plus layout variants. +3. Add focused tests and Storybook stories that document the standard usage, anatomy, and review guidance. +4. Refactor the revenue dashboard scene to consume the new components for representative panels. +5. Run focused validation and record any unrelated repository issues that remain. + +## Validation + +- `pnpm --filter @ai-ui/ui typecheck` +- `pnpm --filter @ai-ui/ui test` +- `pnpm build:docs` + +## Orchestration Task Sketch + +- `T1`: implement `StatCard` and `MetricCard` source plus exports +- `T2 -> T1`: add stories/tests and refactor the revenue dashboard scene +- `T3 -> T2`: run focused validation and document residual issues + +## Status Log + +- `2026-03-25 16:16` started the stat/metric card slice after confirming the current dashboard scene still relies on repeated KPI glue code +- `2026-03-25 16:27` implemented `StatCard` and `MetricCard`, exported them from `@ai-ui/ui`, added tests and Storybook stories, and refactored representative revenue-dashboard panels onto the new components +- `2026-03-25 16:31` validated `pnpm --filter @ai-ui/ui typecheck`, `pnpm --filter @ai-ui/ui test`, `pnpm build`, and `pnpm build:docs` +- `2026-03-25 16:33` noted that the running Storybook dev session still carries unrelated development noise from other in-flight docs slices (`progress.stories.tsx` index warnings and an `InputGroup` runtime export mismatch on the dashboard iframe), but the package build and Storybook production build both pass diff --git a/docs/exec-plans/2026-03-25-two-factor-setup-pattern.md b/docs/exec-plans/2026-03-25-two-factor-setup-pattern.md new file mode 100644 index 0000000..3691872 --- /dev/null +++ b/docs/exec-plans/2026-03-25-two-factor-setup-pattern.md @@ -0,0 +1,59 @@ +# Two-Factor Setup Pattern + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add a new Storybook pattern for a two-factor setup flow that captures the structure of the +reference UI while staying inside Cadence UI's lighter Material-tonal design direction and +reusing the existing component set instead of introducing a new public package component. + +## Scope + +- In scope: + - add a new docs pattern under `apps/docs/src/patterns/` + - compose the scene with existing components such as `Badge`, `Button`, `Card`, `Field`, `Input`, + and `InputGroup` + - document why this belongs in `Patterns` instead of `Components` +- Out of scope: + - adding a new public `packages/ui` export + - introducing a dedicated OTP primitive or authentication package + - wiring real QR generation, API calls, or clipboard guarantees + +## Constraints + +- Keep the visual direction aligned with `DESIGN.md`: tonal, light, rounded, and calm. +- Avoid introducing a black, neon, or high-contrast security-console aesthetic. +- Reuse existing components wherever practical and keep one-off styling local to the story. +- Do not disturb unrelated in-flight work in the dirty worktree. + +## Affected Surfaces + +- `apps/docs/src/patterns` +- `docs/exec-plans` + +## Plan + +1. Add the execution plan and confirm this slice should live in `Patterns`. +2. Implement a new `Patterns/TwoFactorSetup` story that composes existing UI primitives into a + modal-ready 2FA setup scene. +3. Add anatomy and accessibility-oriented review stories so placement and usage are explicit. +4. Run focused docs validation and record the result. + +## Validation + +- `pnpm build:docs` + +## Orchestration Task Sketch + +- `T1`: add the docs-only two-factor setup pattern story +- `T2 -> T1`: run focused docs validation and record outcomes + +## Status Log + +- `2026-03-25 22:10` started the slice after confirming the reference is better treated as a composed auth pattern than as a new base component or dialog variant +- `2026-03-25 22:18` added `Patterns/TwoFactorSetup` as a docs-only pattern story built from existing components with a lighter tonal treatment instead of the reference's dark security-console styling +- `2026-03-25 22:20` validated the new story with `pnpm build:docs` +- `2026-03-25 19:02` migrated the scene's split layout and accessibility review layout onto the shared `Row` / `Col` grid primitives so the pattern no longer depends on local fixed desktop column math diff --git a/docs/exec-plans/2026-03-25-value-field-component.md b/docs/exec-plans/2026-03-25-value-field-component.md new file mode 100644 index 0000000..5bb7ffe --- /dev/null +++ b/docs/exec-plans/2026-03-25-value-field-component.md @@ -0,0 +1,66 @@ +# Value Field Component + +- Status: `in_progress` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add a reusable `ValueField` base component for single-line, read-only value presentation so product +flows do not have to misuse `Input` for non-editable display values such as backup codes, reference +ids, tokens, or generated labels. + +## Scope + +- In scope: + - add a new public `ValueField` component to `packages/ui` + - support a minimal composable surface for value display plus optional prefix/suffix content + - integrate with `Field` context for state and description wiring + - document the component in Storybook + - cover key behavior with unit tests + - use the new component in the docs-only two-factor setup pattern +- Out of scope: + - multiline or rich-text display values + - formatted masking, async copy state, or built-in clipboard APIs + - replacing every read-only input in the repo + +## Constraints + +- Keep the component token-first and aligned with the existing input/card/panel visual language. +- Do not create a second form-field system parallel to `Field`. +- Keep the public API small and composable. +- Prefer stable slot names and `data-*` state exposure over one-off booleans. + +## Affected Surfaces + +- `packages/ui/src/components` +- `packages/ui/src/index.ts` +- `packages/ui/src/lib/contracts.ts` +- `apps/docs/src/components` +- `apps/docs/src/patterns` +- `docs/exec-plans` + +## Plan + +1. Add the execution plan and confirm the API should be a small read-only display primitive rather + than a typography helper. +2. Implement `ValueField` in `packages/ui` with stable slots for root/value/prefix/suffix and + Field-context aware ids and state attributes. +3. Add Storybook stories and update the two-factor setup pattern to use `ValueField` for the manual + backup code. +4. Add unit coverage and run targeted validation for component and docs surfaces. + +## Validation + +- `pnpm build:docs` +- targeted component test run for `ValueField` + +## Orchestration Task Sketch + +- `T1`: implement the `ValueField` component and exports +- `T2 -> T1`: add docs stories, update the two-factor pattern, and validate + +## Status Log + +- `2026-03-25 23:40` started after confirming the repo exposes read-only input styling but no + dedicated single-line value display component diff --git a/docs/exec-plans/2026-03-25-workspace-toolbar-pattern.md b/docs/exec-plans/2026-03-25-workspace-toolbar-pattern.md new file mode 100644 index 0000000..7c596f1 --- /dev/null +++ b/docs/exec-plans/2026-03-25-workspace-toolbar-pattern.md @@ -0,0 +1,65 @@ +# Workspace Toolbar Pattern + +- Status: `completed` +- Owner: `codex` +- Date: `2026-03-25` + +## Goal + +Add a reusable `WorkspaceToolbar` pattern above the base component layer so workspace and desk-style +screens can share one stable contract for search, filters, status chips, and action groups instead +of hand-rolling a new top control row in each story or scene. + +## Scope + +- In scope: + - add a new `WorkspaceToolbar` pattern under `packages/ui/src/patterns/` + - export the new pattern from `@ai-ui/ui` + - add Storybook docs under `Patterns/WorkspaceToolbar` + - refactor the revenue dashboard scene to consume the new pattern + - add focused tests for the slot and variant contract +- Out of scope: + - moving `DataTable` internal toolbar logic into the patterns layer + - adding router-aware behavior, sticky behavior, or data-source behavior + - introducing a second page-header-like title system inside the toolbar + +## Constraints + +- Keep the API slot-oriented and composable instead of baking in search or filter behavior. +- Reuse the existing token and motion system from `DESIGN.md`. +- Keep the public variant surface restrained. +- Do not disturb unrelated in-flight work in the dirty worktree. + +## Affected Surfaces + +- `packages/ui/src/patterns` +- `packages/ui/src/index.ts` +- `apps/docs/src/patterns` +- `apps/docs/src/revenue-dashboard.stories.tsx` +- `docs/exec-plans` + +## Plan + +1. Add the new pattern implementation, variants, and focused tests in `packages/ui/src/patterns/`. +2. Export the pattern from `@ai-ui/ui` and add Storybook docs under `Patterns/*`. +3. Refactor the revenue dashboard toolbar composition to consume the shared pattern. +4. Run focused validation and record any skipped or blocked checks. + +## Validation + +- `pnpm --filter @ai-ui/ui test -- workspace-toolbar` +- `pnpm --filter @ai-ui/ui typecheck` +- `pnpm build:docs` + +## Orchestration Task Sketch + +- `T1`: implement the new pattern and public exports +- `T2 -> T1`: add docs stories and refactor the dashboard consumer +- `T3 -> T2`: run focused validation and record outcomes + +## Status Log + +- `2026-03-25 19:13` started the workspace-toolbar slice after confirming the current patterns layer stops at shell and page framing, while the revenue dashboard still hand-rolls a workspace control row +- `2026-03-25 19:20` implemented `WorkspaceToolbar` with slot-based search, filters, status, and actions regions, then exported the new public pattern from `@ai-ui/ui` +- `2026-03-25 19:24` added `Patterns/WorkspaceToolbar` Storybook docs and refactored the revenue dashboard scene to consume the shared toolbar pattern +- `2026-03-25 19:26` validated the change with `pnpm --filter @ai-ui/ui test -- workspace-toolbar`, `pnpm --filter @ai-ui/ui typecheck`, and `pnpm build:docs` diff --git a/packages/ui/src/components/chart.test.tsx b/packages/ui/src/components/chart.test.tsx new file mode 100644 index 0000000..d1d2d3d --- /dev/null +++ b/packages/ui/src/components/chart.test.tsx @@ -0,0 +1,227 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { + Chart, + ChartChange, + ChartDescription, + ChartHeader, + ChartHeaderAside, + ChartHeaderLeading, + ChartMetrics, + ChartTitle, + ChartValue, + type ChartSeries +} from "./chart"; + +type RevenueDatum = { + gross: number; + month: string; + revenue: number; +}; + +const rows: RevenueDatum[] = [ + { gross: 61200, month: "Jan", revenue: 84200 }, + { gross: 73400, month: "Feb", revenue: 96350 }, + { gross: 82500, month: "Mar", revenue: 118420 }, + { gross: 78220, month: "Apr", revenue: 109860 } +]; + +function formatCurrency(value: number) { + return new Intl.NumberFormat("en-US", { + currency: "USD", + maximumFractionDigits: 0, + style: "currency" + }).format(value); +} + +const series: ChartSeries[] = [ + { + getValue: (datum) => datum.revenue, + id: "revenue", + label: "Revenue", + style: "line-area", + tone: "primary", + valueFormatter: formatCurrency + }, + { + getValue: (datum) => datum.gross, + id: "gross", + label: "Gross", + strokeDasharray: "6 8", + tone: "neutral", + valueFormatter: formatCurrency + } +]; + +describe("Chart", () => { + it("renders the composed header, plot, tooltip, and legend surfaces", () => { + const { container } = render( + `${datum.month} 2025`} + getXAxisLabel={(datum) => datum.month} + header={ + + + Sales Impact + + Revenue compared to gross margin over the current month set. + + + + + {formatCurrency(96350)} + Live forecast + + + + } + series={series} + showLegend + title="Sales Impact" + /> + ); + + expect(screen.getByText("Sales Impact").closest('[data-slot="label"]')).toBeInTheDocument(); + expect( + screen.getByText("Revenue compared to gross margin over the current month set.") + ).toHaveAttribute("data-slot", "description"); + const tooltip = document.querySelector('[data-slot="tooltip"]'); + const legend = document.querySelector('[data-slot="legend"]'); + + expect(tooltip).not.toBeNull(); + expect(legend).not.toBeNull(); + expect(screen.getByText("Sales Impact").closest('[data-slot="leading"]')).toBeInTheDocument(); + expect(screen.getByText("Live forecast").closest('[data-slot="aside"]')).toBeInTheDocument(); + expect(within(tooltip as HTMLElement).getByText("Feb 2025")).toBeInTheDocument(); + expect(within(legend as HTMLElement).getByText("Revenue").closest('[data-slot="item"]')).toBeInTheDocument(); + expect(within(legend as HTMLElement).getByText("Gross")).toBeInTheDocument(); + expect(container.querySelector('line[stroke-width="1.5"]')).toBeInTheDocument(); + expect(container.querySelectorAll("svg circle")).toHaveLength(4); + }); + + it("keeps the legacy convenience header props working", () => { + const longValueChange = + "Legend values follow the active point when the chart is interactive."; + + render( + `${datum.month} 2025`} + getXAxisLabel={(datum) => datum.month} + series={series} + title="Legacy chart" + value={formatCurrency(96350)} + valueChange={longValueChange} + /> + ); + + expect(screen.getByText("Legacy chart")).toHaveAttribute("data-slot", "label"); + expect(screen.getByText(longValueChange)).toHaveAttribute("data-slot", "delta"); + expect(screen.getByText(longValueChange)).toHaveClass("max-w-full"); + expect( + screen + .getAllByText(formatCurrency(96350)) + .find((element) => element.getAttribute("data-slot") === "value") + ).toBeInTheDocument(); + }); + + it("updates the active point summary when a chart point is hovered", async () => { + const user = userEvent.setup(); + + render( + `${datum.month} 2025`} + getXAxisLabel={(datum) => datum.month} + series={series} + title="Hoverable chart" + /> + ); + + const tooltip = () => document.querySelector('[data-slot="tooltip"]') as HTMLElement; + + expect(within(tooltip()).getByText("Jan 2025")).toBeInTheDocument(); + + await user.hover(screen.getByRole("button", { name: /mar 2025/i })); + + expect(within(tooltip()).getByText("Mar 2025")).toBeInTheDocument(); + expect(within(tooltip()).getByText(formatCurrency(118420))).toBeInTheDocument(); + }); + + it("supports controlled active state and only emits callbacks on interaction", async () => { + const user = userEvent.setup(); + const onActiveIndexChange = vi.fn(); + + render( + `${datum.month} 2025`} + getXAxisLabel={(datum) => datum.month} + onActiveIndexChange={onActiveIndexChange} + series={series} + title="Controlled chart" + /> + ); + + await user.hover(screen.getByRole("button", { name: /apr 2025/i })); + + expect(onActiveIndexChange).toHaveBeenCalledWith(3); + const tooltip = document.querySelector('[data-slot="tooltip"]') as HTMLElement; + + expect(within(tooltip).getByText("Jan 2025")).toBeInTheDocument(); + expect(within(tooltip).queryByText("Apr 2025")).not.toBeInTheDocument(); + }); + + it("keeps the active comparison usable when document motion is static", async () => { + const user = userEvent.setup(); + + document.documentElement.dataset.motion = "static"; + + render( + `${datum.month} 2025`} + getXAxisLabel={(datum) => datum.month} + series={series} + title="Static motion chart" + /> + ); + + const tooltip = () => document.querySelector('[data-slot="tooltip"]') as HTMLElement; + + expect(within(tooltip()).getByText("Jan 2025")).toBeInTheDocument(); + + await user.hover(screen.getByRole("button", { name: /apr 2025/i })); + + expect(within(tooltip()).getByText("Apr 2025")).toBeInTheDocument(); + + delete document.documentElement.dataset.motion; + }); + + it("renders the empty contract without collapsing the panel", () => { + render( + ""} + series={series} + title="Empty chart" + /> + ); + + expect(screen.getByText("Empty chart")).toBeInTheDocument(); + expect(screen.getByText("No series are visible in the current scope.")).toHaveAttribute( + "data-slot", + "empty" + ); + }); +}); diff --git a/packages/ui/src/components/chart.tsx b/packages/ui/src/components/chart.tsx new file mode 100644 index 0000000..6d8d6cb --- /dev/null +++ b/packages/ui/src/components/chart.tsx @@ -0,0 +1,1105 @@ +import { + forwardRef, + useEffect, + useId, + useState, + type ComponentPropsWithoutRef, + type CSSProperties, + type ForwardedRef, + type JSX, + type ReactNode +} from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { + chartCanvasVariants, + chartChangeVariants, + chartDescriptionVariants, + chartEmptyStateVariants, + chartEyebrowVariants, + chartHeaderAsideVariants, + chartHeaderLeadingVariants, + chartHeaderVariants, + chartLegendItemVariants, + chartLegendVariants, + chartMetricGroupVariants, + chartTitleVariants, + chartTooltipVariants, + chartValueVariants, + chartVariants +} from "./chart.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +const DEFAULT_VIEWBOX_WIDTH = 720; +const DEFAULT_HEIGHT = 320; +const DEFAULT_TICK_COUNT = 5; +const compactNumberFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 1, + notation: "compact" +}); + +function clampNumber(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function formatCompactNumber(value: number) { + if (value === 0) { + return "0"; + } + + return compactNumberFormatter.format(value); +} + +function normalizeValue(value: number | null | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + + return value; +} + +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 useChartMotionDisabled() { + const prefersReducedMotion = useReducedMotion(); + const [isStaticMotion, setIsStaticMotion] = useState(false); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + const syncMotionMode = () => { + setIsStaticMotion(document.documentElement.dataset.motion === "static"); + }; + + syncMotionMode(); + + const observer = new MutationObserver(syncMotionMode); + observer.observe(document.documentElement, { + attributeFilter: ["data-motion"] + }); + + return () => observer.disconnect(); + }, []); + + return prefersReducedMotion || isStaticMotion; +} + +function getNiceStep(range: number, targetTickCount: number) { + const roughStep = range / Math.max(targetTickCount, 1); + const exponent = Math.floor(Math.log10(roughStep || 1)); + const fraction = roughStep / 10 ** exponent; + + if (fraction <= 1) { + return 1 * 10 ** exponent; + } + + if (fraction <= 2) { + return 2 * 10 ** exponent; + } + + if (fraction <= 5) { + return 5 * 10 ** exponent; + } + + return 10 ** (exponent + 1); +} + +function getTickValues(min: number, max: number, targetTickCount: number) { + const safeMax = max <= min ? min + 1 : max; + const step = getNiceStep(safeMax - min, Math.max(targetTickCount, 1)); + const niceMin = Math.floor(min / step) * step; + const niceMax = Math.ceil(safeMax / step) * step; + const ticks: number[] = []; + + for (let value = niceMin; value <= niceMax + step / 2; value += step) { + ticks.push(Number(value.toFixed(4))); + } + + return ticks; +} + +function getSeriesColor(tone: ChartSeriesTone) { + switch (tone) { + case "accent": + return "var(--color-accent-foreground)"; + case "success": + return "var(--color-success)"; + case "warning": + return "var(--color-warning)"; + case "neutral": + return "var(--color-border-strong)"; + case "primary": + default: + return "var(--color-primary)"; + } +} + +function getPointAriaLabel(context: ChartTooltipContext) { + const valueSummary = context.series + .map((entry) => `${entry.label}: ${entry.formattedValue}`) + .join(", "); + + return `${context.activeLabel}, ${valueSummary}`; +} + +type ChartPoint = { + index: number; + value: number; + x: number; + y: number; +}; + +function buildSmoothPath(points: ChartPoint[]) { + if (points.length === 0) { + return ""; + } + + if (points.length === 1) { + const point = points[0]; + return `M ${point.x} ${point.y}`; + } + + let path = `M ${points[0].x} ${points[0].y}`; + + for (let index = 0; index < points.length - 1; index += 1) { + const previous = points[index - 1] ?? points[index]; + const current = points[index]; + const next = points[index + 1]; + const afterNext = points[index + 2] ?? next; + const controlPointOneX = current.x + (next.x - previous.x) / 6; + const controlPointOneY = current.y + (next.y - previous.y) / 6; + const controlPointTwoX = next.x - (afterNext.x - current.x) / 6; + const controlPointTwoY = next.y - (afterNext.y - current.y) / 6; + + path += ` C ${controlPointOneX} ${controlPointOneY} ${controlPointTwoX} ${controlPointTwoY} ${next.x} ${next.y}`; + } + + return path; +} + +function buildAreaPath(points: ChartPoint[], baselineY: number) { + if (points.length === 0) { + return ""; + } + + if (points.length === 1) { + const point = points[0]; + return `M ${point.x} ${baselineY} L ${point.x} ${point.y} L ${point.x} ${baselineY} Z`; + } + + return `${buildSmoothPath(points)} L ${points[points.length - 1].x} ${baselineY} L ${points[0].x} ${baselineY} Z`; +} + +function getXPosition(index: number, totalPoints: number, left: number, plotWidth: number) { + if (totalPoints <= 1) { + return left + plotWidth / 2; + } + + return left + (plotWidth / (totalPoints - 1)) * index; +} + +export type ChartHeaderProps = ComponentPropsWithoutRef<"div">; + +export const ChartHeader = forwardRef(function ChartHeader( + { className, ...props }, + ref +) { + return ( +
+ ); +}); + +export type ChartHeaderLeadingProps = ComponentPropsWithoutRef<"div">; + +export const ChartHeaderLeading = forwardRef( + function ChartHeaderLeading({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type ChartHeaderAsideProps = ComponentPropsWithoutRef<"div">; + +export const ChartHeaderAside = forwardRef( + function ChartHeaderAside({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type ChartEyebrowProps = ComponentPropsWithoutRef<"p">; + +export const ChartEyebrow = forwardRef( + function ChartEyebrow({ className, ...props }, ref) { + return ( +

+ ); + } +); + +export type ChartTitleProps = ComponentPropsWithoutRef<"h3">; + +export const ChartTitle = forwardRef(function ChartTitle( + { className, ...props }, + ref +) { + return ( +

+ ); +}); + +export type ChartDescriptionProps = ComponentPropsWithoutRef<"p">; + +export const ChartDescription = forwardRef( + function ChartDescription({ className, ...props }, ref) { + return ( +

+ ); + } +); + +export type ChartMetricsProps = ComponentPropsWithoutRef<"div">; + +export const ChartMetrics = forwardRef(function ChartMetrics( + { className, ...props }, + ref +) { + return ( +

+ ); +}); + +export type ChartValueProps = ComponentPropsWithoutRef<"div">; + +export const ChartValue = forwardRef(function ChartValue( + { className, ...props }, + ref +) { + return ( +
+ ); +}); + +export type ChartChangeProps = ComponentPropsWithoutRef<"div">; + +export const ChartChange = forwardRef(function ChartChange( + { className, ...props }, + ref +) { + return ( +
+ ); +}); + +export type ChartSeriesTone = "primary" | "accent" | "success" | "warning" | "neutral"; +export type ChartSeriesStyle = "line" | "line-area"; +export type ChartLegendValueMode = "active" | "last" | "none"; + +export type ChartSeries = { + getValue: (datum: TData, index: number) => number | null | undefined; + id: string; + label: string; + strokeDasharray?: string; + strokeWidth?: number; + style?: ChartSeriesStyle; + tone?: ChartSeriesTone; + valueFormatter?: (value: number, datum: TData, index: number) => string; +}; + +export type ChartTooltipValue = { + color: string; + datum: TData; + formattedValue: string; + id: string; + index: number; + label: string; + tone: ChartSeriesTone; + value: number; +}; + +export type ChartTooltipContext = { + activeIndex: number; + activeLabel: string; + datum: TData; + series: ChartTooltipValue[]; +}; + +export type ChartProps = Omit, "children"> & + VariantProps & { + activeIndex?: number; + data: TData[]; + defaultActiveIndex?: number; + description?: ReactNode; + empty?: ReactNode; + eyebrow?: ReactNode; + header?: ReactNode; + getActiveLabel?: (datum: TData, index: number) => string; + getPointAriaLabel?: (context: ChartTooltipContext) => string; + getXAxisLabel: (datum: TData, index: number) => string; + height?: number; + interactive?: boolean; + legendValueMode?: ChartLegendValueMode; + maxValue?: number; + minValue?: number; + onActiveIndexChange?: (activeIndex: number) => void; + renderTooltip?: (context: ChartTooltipContext) => ReactNode; + series: ChartSeries[]; + showActiveGuide?: boolean; + showGrid?: boolean; + showLegend?: boolean; + showTooltip?: boolean; + showXAxis?: boolean; + showYAxis?: boolean; + title?: ReactNode; + value?: ReactNode; + valueChange?: ReactNode; + yAxisTickCount?: number; + yAxisValueFormatter?: (value: number) => string; + }; + +function ChartInner( + { + activeIndex, + className, + data, + defaultActiveIndex, + description, + empty, + eyebrow, + header, + getActiveLabel, + getPointAriaLabel: getPointAriaLabelProp, + getXAxisLabel, + height = DEFAULT_HEIGHT, + interactive = true, + legendValueMode = "active", + maxValue, + minValue, + onActiveIndexChange, + renderTooltip, + series, + showActiveGuide = true, + showGrid = true, + showLegend = false, + showTooltip = true, + showXAxis = true, + showYAxis = true, + title, + tone, + value, + valueChange, + yAxisTickCount = DEFAULT_TICK_COUNT, + yAxisValueFormatter = formatCompactNumber, + ...props + }: ChartProps, + ref: ForwardedRef +) { + const instanceId = useId().replace(/:/g, ""); + const disableMotion = useChartMotionDisabled(); + const leftPadding = showYAxis ? 62 : 20; + const rightPadding = 24; + const topPadding = 22; + const bottomPadding = showXAxis ? 44 : 20; + const plotWidth = DEFAULT_VIEWBOX_WIDTH - leftPadding - rightPadding; + const plotHeight = height - topPadding - bottomPadding; + const baselineY = topPadding + plotHeight; + const safeInitialIndex = + data.length === 0 ? 0 : clampNumber(defaultActiveIndex ?? data.length - 1, 0, data.length - 1); + const [currentActiveIndex, setCurrentActiveIndex] = useControllableState({ + controlledValue: activeIndex, + defaultValue: safeInitialIndex, + onChange: onActiveIndexChange + }); + + const resolvedSeries = series.map((entry) => ({ + ...entry, + strokeWidth: entry.strokeWidth ?? 3, + style: entry.style ?? "line", + tone: entry.tone ?? "primary" + })); + + const numericValues = resolvedSeries.flatMap((entry) => + data + .map((datum, index) => normalizeValue(entry.getValue(datum, index))) + .filter((value): value is number => value !== null) + ); + + const hasRenderableData = data.length > 0 && numericValues.length > 0; + const rawMinValue = + minValue ?? + (numericValues.length === 0 ? 0 : numericValues.every((value) => value >= 0) ? 0 : Math.min(...numericValues)); + const rawMaxValue = + maxValue ?? + (numericValues.length === 0 + ? 1 + : Math.max( + ...numericValues, + minValue ?? 0, + rawMinValue === 0 ? 1 : rawMinValue + Math.abs(rawMinValue) * 0.1 + )); + const ticks = getTickValues(rawMinValue, rawMaxValue, yAxisTickCount); + const domainMin = ticks[0] ?? rawMinValue; + const domainMax = ticks[ticks.length - 1] ?? rawMaxValue; + const safeRange = domainMax - domainMin || 1; + const safeActiveIndex = hasRenderableData + ? clampNumber(currentActiveIndex, 0, data.length - 1) + : -1; + + const seriesPoints = resolvedSeries.map((entry) => { + const points = data.flatMap((datum, index) => { + const valueForPoint = normalizeValue(entry.getValue(datum, index)); + + if (valueForPoint === null) { + return []; + } + + const x = getXPosition(index, data.length, leftPadding, plotWidth); + const y = topPadding + ((domainMax - valueForPoint) / safeRange) * plotHeight; + + return [ + { + index, + value: valueForPoint, + x, + y + } satisfies ChartPoint + ]; + }); + + return { + color: getSeriesColor(entry.tone), + definition: entry, + points + }; + }); + + const activeDatum = safeActiveIndex >= 0 ? data[safeActiveIndex] : undefined; + const activeLabel = + activeDatum !== undefined && safeActiveIndex >= 0 + ? getActiveLabel?.(activeDatum, safeActiveIndex) ?? + getXAxisLabel(activeDatum, safeActiveIndex) + : ""; + const activeSeriesValues = + activeDatum !== undefined && safeActiveIndex >= 0 + ? resolvedSeries.flatMap((entry) => { + const valueForPoint = normalizeValue(entry.getValue(activeDatum, safeActiveIndex)); + + if (valueForPoint === null) { + return []; + } + + return [ + { + color: getSeriesColor(entry.tone), + datum: activeDatum, + formattedValue: + entry.valueFormatter?.(valueForPoint, activeDatum, safeActiveIndex) ?? + yAxisValueFormatter(valueForPoint), + id: entry.id, + index: safeActiveIndex, + label: entry.label, + tone: entry.tone, + value: valueForPoint + } satisfies ChartTooltipValue + ]; + }) + : []; + const activeContext = + activeDatum !== undefined && safeActiveIndex >= 0 + ? ({ + activeIndex: safeActiveIndex, + activeLabel, + datum: activeDatum, + series: activeSeriesValues + } satisfies ChartTooltipContext) + : null; + const tooltipAnchor = + safeActiveIndex >= 0 + ? seriesPoints + .flatMap((entry) => entry.points) + .filter((point) => point.index === safeActiveIndex) + .reduce<{ x: number; y: number } | null>((closest, point) => { + if (closest === null || point.y < closest.y) { + return { x: point.x, y: point.y }; + } + + return closest; + }, null) + : null; + + const tooltipStyle: CSSProperties | undefined = + tooltipAnchor === null + ? undefined + : { + left: `${(tooltipAnchor.x / DEFAULT_VIEWBOX_WIDTH) * 100}%`, + top: `${clampNumber(((tooltipAnchor.y - 16) / height) * 100, 24, 78)}%`, + transform: + tooltipAnchor.x > DEFAULT_VIEWBOX_WIDTH * 0.66 + ? "translate(-100%, -100%)" + : tooltipAnchor.x < DEFAULT_VIEWBOX_WIDTH * 0.34 + ? "translate(0%, -100%)" + : "translate(-50%, -100%)" + }; + const activeGuideTransition = { + duration: disableMotion ? 0.01 : 0.16, + ease: [0.22, 1, 0.36, 1] + } as const; + const markerTransition = { + duration: disableMotion ? 0.01 : 0.15, + ease: [0.16, 1, 0.3, 1] + } as const; + const tooltipEnterState = disableMotion + ? { opacity: 1, scale: 1, y: 0 } + : { opacity: 0, scale: 0.985, y: 8 }; + const tooltipAnimateState = { opacity: 1, scale: 1, y: 0 }; + const tooltipExitState = disableMotion + ? { opacity: 1, scale: 1, y: 0 } + : { opacity: 0, scale: 0.99, y: 5 }; + const tooltipTransition = { + duration: disableMotion ? 0.01 : 0.18, + ease: [0.16, 1, 0.3, 1] + } as const; + + const legendIndex = + legendValueMode === "none" + ? -1 + : legendValueMode === "last" + ? data.length - 1 + : safeActiveIndex; + const legendDatum = legendIndex >= 0 ? data[legendIndex] : undefined; + const hasCustomHeader = header !== undefined && header !== null; + const shouldRenderLegacyHeader = + eyebrow !== undefined || + title !== undefined || + description !== undefined || + value !== undefined || + valueChange !== undefined; + const shouldRenderHeader = hasCustomHeader || shouldRenderLegacyHeader; + const fallbackHeader = shouldRenderLegacyHeader ? ( + + + {eyebrow ? {eyebrow} : null} + {title ? {title} : null} + {description ? {description} : null} + + {value !== undefined || valueChange !== undefined ? ( + + + {value !== undefined ? {value} : null} + {valueChange !== undefined ? {valueChange} : null} + + + ) : null} + + ) : null; + + return ( +
+ {shouldRenderHeader ? (hasCustomHeader ? header : fallbackHeader) : null} + +
+
+ {hasRenderableData ? ( + <> + + + {showTooltip && activeContext && tooltipStyle ? ( +
+ + + {renderTooltip ? ( + renderTooltip(activeContext) + ) : ( +
+
+ {activeContext.activeLabel} +
+
+ {activeContext.series.map((entry) => ( +
+ + + {entry.label} + + + {entry.formattedValue} + +
+ ))} +
+
+ )} +
+
+
+ ) : null} + + {interactive ? ( +
+ {data.map((datum, index) => { + const x = getXPosition(index, data.length, leftPadding, plotWidth); + const previousX = + index === 0 + ? leftPadding + : getXPosition(index - 1, data.length, leftPadding, plotWidth); + const nextX = + index === data.length - 1 + ? DEFAULT_VIEWBOX_WIDTH - rightPadding + : getXPosition(index + 1, data.length, leftPadding, plotWidth); + const leftBoundary = index === 0 ? leftPadding : (previousX + x) / 2; + const rightBoundary = + index === data.length - 1 + ? DEFAULT_VIEWBOX_WIDTH - rightPadding + : (x + nextX) / 2; + const buttonContext = + activeContext !== null && safeActiveIndex === index + ? activeContext + : (() => { + const datumForButton = data[index]; + const label = + getActiveLabel?.(datumForButton, index) ?? + getXAxisLabel(datumForButton, index); + const seriesValues = resolvedSeries.flatMap((entry) => { + const valueForPoint = normalizeValue(entry.getValue(datumForButton, index)); + + if (valueForPoint === null) { + return []; + } + + return [ + { + color: getSeriesColor(entry.tone), + datum: datumForButton, + formattedValue: + entry.valueFormatter?.(valueForPoint, datumForButton, index) ?? + yAxisValueFormatter(valueForPoint), + id: entry.id, + index, + label: entry.label, + tone: entry.tone, + value: valueForPoint + } satisfies ChartTooltipValue + ]; + }); + + return { + activeIndex: index, + activeLabel: label, + datum: datumForButton, + series: seriesValues + } satisfies ChartTooltipContext; + })(); + + return ( + + ); + })} +
+ ) : null} + + ) : ( +
+ {empty ?? "No chart data available for the current view."} +
+ )} +
+ + {showLegend ? ( +
+ {resolvedSeries.map((entry) => { + const referenceDatum = legendDatum ?? data[data.length - 1]; + const referenceIndex = legendDatum !== undefined ? legendIndex : data.length - 1; + const referenceValue = + referenceDatum !== undefined && referenceIndex >= 0 + ? normalizeValue(entry.getValue(referenceDatum, referenceIndex)) + : null; + + return ( +
= 0 && legendValueMode === "active" + })} + {...createSlot("item")} + className={chartLegendItemVariants()} + key={entry.id} + > + + + {entry.label} + + {legendValueMode !== "none" && + referenceValue !== null && + referenceDatum !== undefined ? ( + + {entry.valueFormatter?.(referenceValue, referenceDatum, referenceIndex) ?? + yAxisValueFormatter(referenceValue)} + + ) : null} +
+ ); + })} +
+ ) : null} +
+
+ ); +} + +type ChartComponent = ( + props: ChartProps & { ref?: ForwardedRef } +) => JSX.Element; + +export const Chart = forwardRef(ChartInner) as ChartComponent; diff --git a/packages/ui/src/components/chart.variants.ts b/packages/ui/src/components/chart.variants.ts new file mode 100644 index 0000000..0d9219b --- /dev/null +++ b/packages/ui/src/components/chart.variants.ts @@ -0,0 +1,101 @@ +import { cva } from "../lib/cva"; +import { getMotionRecipeClassNames } from "../lib/motion"; + +export const chartVariants = cva( + [ + "grid gap-5 rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]", + "px-5 py-5 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] sm:px-6 sm:py-6", + getMotionRecipeClassNames("transition", "ring") + ], + { + variants: { + tone: { + default: + "border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)]", + accent: + "border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)]" + } + }, + defaultVariants: { + tone: "default" + } + } +); + +export const chartHeaderVariants = cva( + "grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(13rem,18rem)] md:items-start" +); + +export const chartHeaderLeadingVariants = cva("grid min-w-0 gap-2"); + +export const chartHeaderAsideVariants = cva( + "grid min-w-0 gap-3 justify-self-start md:justify-self-end md:justify-items-end" +); + +export const chartEyebrowVariants = cva( + "text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]" +); + +export const chartTitleVariants = cva( + "text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)] sm:text-[1.375rem]" +); + +export const chartDescriptionVariants = cva( + "max-w-[42rem] text-sm leading-6 text-[var(--color-muted-foreground)]" +); + +export const chartMetricGroupVariants = cva("grid min-w-0 gap-1 md:justify-items-end"); + +export const chartValueVariants = cva( + "max-w-full text-2xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)] md:text-right md:text-[2rem]" +); + +export const chartChangeVariants = cva( + "flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-[var(--color-muted-foreground)] md:max-w-[18rem] md:justify-end md:text-right" +); + +export const chartCanvasVariants = cva( + [ + "relative overflow-hidden rounded-[calc(var(--ui-card-radius)-0.375rem)] border [border-width:var(--ui-card-border-width)]", + "border-[color-mix(in_oklch,var(--ui-card-default-border)_84%,transparent)]", + "bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%)_0%,color-mix(in_oklch,var(--color-surface-bright)_72%,var(--color-surface-container-low)_28%)_100%)]" + ], + { + variants: { + interactive: { + false: "", + true: + "supports-[backdrop-filter:blur(0px)]:backdrop-blur-[2px]" + } + }, + defaultVariants: { + interactive: true + } + } +); + +export const chartTooltipVariants = cva( + [ + "min-w-[10.5rem] max-w-[14rem] rounded-[calc(var(--ui-card-radius)-0.5rem)] origin-bottom will-change-transform", + "border border-[color-mix(in_oklch,var(--ui-card-default-border)_88%,transparent)]", + "bg-[color-mix(in_oklch,var(--color-surface-bright)_88%,white_12%)] px-3 py-2.5", + "shadow-[0_18px_42px_color-mix(in_oklch,var(--color-primary)_14%,transparent)]", + "backdrop-blur-sm" + ] +); + +export const chartLegendVariants = cva( + "flex flex-wrap items-center gap-2.5" +); + +export const chartLegendItemVariants = cva( + [ + "inline-flex min-h-9 items-center gap-2.5 rounded-[var(--radius-full)] border px-3 py-2", + "border-[color-mix(in_oklch,var(--color-border)_72%,transparent)]", + "bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)] text-sm" + ] +); + +export const chartEmptyStateVariants = cva( + "grid min-h-72 place-items-center px-6 py-10 text-center text-sm leading-6 text-[var(--color-muted-foreground)]" +); diff --git a/packages/ui/src/components/gauge.test.tsx b/packages/ui/src/components/gauge.test.tsx new file mode 100644 index 0000000..a6ba240 --- /dev/null +++ b/packages/ui/src/components/gauge.test.tsx @@ -0,0 +1,131 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Gauge } from "./gauge"; + +const STARTUP_ANIMATION_DURATION = 520; + +describe("Gauge", () => { + beforeEach(() => { + document.documentElement.dataset.motion = "static"; + }); + + afterEach(() => { + delete document.documentElement.dataset.motion; + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("renders a dial meter with the expected slots and aria contract", () => { + render( + + ); + + const gauge = screen.getByRole("meter", { name: "Forecast confidence" }); + const canvas = gauge.querySelector('[data-slot="canvas"]'); + const indicator = gauge.querySelector('[data-slot="indicator"]'); + const value = gauge.querySelector('[data-slot="value"]'); + const ticks = gauge.querySelectorAll('[data-slot="tick"]'); + + expect(gauge).toHaveAttribute("data-slot", "root"); + expect(gauge).toHaveAttribute("data-shape", "dial"); + expect(gauge).toHaveAttribute("data-state", "value"); + expect(gauge).toHaveAttribute("aria-valuemin", "0"); + expect(gauge).toHaveAttribute("aria-valuemax", "100"); + expect(gauge).toHaveAttribute("aria-valuenow", "72"); + expect(canvas).toHaveAttribute("data-slot", "canvas"); + expect(indicator).toHaveAttribute("data-variant", "default"); + expect(indicator).toHaveAttribute("stroke-dasharray", "72 100"); + expect(value).toHaveTextContent("72%"); + expect(ticks).toHaveLength(0); + }); + + it("supports semi-circle geometry, semantic variants, and tick activation", () => { + render( + + ); + + const gauge = screen.getByRole("meter", { name: "Capacity utilization" }); + const svg = gauge.querySelector('[data-slot="svg"]'); + const indicator = gauge.querySelector('[data-slot="indicator"]'); + const activeTicks = gauge.querySelectorAll('[data-slot="tick"][data-active]'); + + expect(gauge).toHaveAttribute("data-shape", "semi"); + expect(gauge).toHaveAttribute("data-tone", "accent"); + expect(gauge).toHaveAttribute("data-variant", "warning"); + expect(gauge).toHaveAttribute("aria-valuemax", "80"); + expect(gauge).toHaveAttribute("aria-valuenow", "40"); + expect(svg).toHaveAttribute("viewBox", "0 0 120 76"); + expect(indicator).toHaveAttribute("stroke-dasharray", "50 100"); + expect(activeTicks).toHaveLength(4); + }); + + it("renders an empty state when no numeric value is available", () => { + render(); + + const gauge = screen.getByRole("meter", { name: "Pipeline health" }); + const indicator = gauge.querySelector('[data-slot="indicator"]'); + const ticks = gauge.querySelectorAll('[data-slot="tick"]'); + + expect(gauge).toHaveAttribute("data-state", "empty"); + expect(gauge).not.toHaveAttribute("aria-valuenow"); + expect(gauge.querySelector('[data-slot="value"]')).toHaveTextContent("—"); + expect(indicator).toHaveAttribute("data-state", "empty"); + expect(indicator).toHaveAttribute("stroke-dasharray", "0 100"); + expect(ticks).toHaveLength(0); + }); + + it("clamps values beyond the provided range and marks the max state", () => { + render(); + + const gauge = screen.getByRole("meter", { name: "Budget saturation" }); + const indicator = gauge.querySelector('[data-slot="indicator"]'); + + expect(gauge).toHaveAttribute("data-state", "max"); + expect(gauge).toHaveAttribute("aria-valuenow", "120"); + expect(indicator).toHaveAttribute("stroke-dasharray", "100 100"); + expect(gauge.querySelector('[data-slot="value"]')).toHaveTextContent("120"); + }); + + it("stages the sweep and value count-up when motion is enabled", () => { + delete document.documentElement.dataset.motion; + vi.useFakeTimers(); + + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + return window.setTimeout(() => callback(performance.now()), 16); + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation((handle) => { + window.clearTimeout(handle); + }); + + render(); + + const gauge = screen.getByRole("meter", { name: "Forecast confidence" }); + const indicator = gauge.querySelector('[data-slot="indicator"]'); + const value = gauge.querySelector('[data-slot="value"]'); + + expect(gauge).toHaveAttribute("data-animating", ""); + expect(indicator).toHaveAttribute("stroke-dasharray", "0 100"); + expect(value).toHaveTextContent("0%"); + + act(() => { + vi.advanceTimersByTime(STARTUP_ANIMATION_DURATION + 80); + }); + + expect(gauge).not.toHaveAttribute("data-animating"); + expect(indicator).toHaveAttribute("stroke-dasharray", "72 100"); + expect(value).toHaveTextContent("72%"); + }); +}); diff --git a/packages/ui/src/components/gauge.tsx b/packages/ui/src/components/gauge.tsx new file mode 100644 index 0000000..ca20e41 --- /dev/null +++ b/packages/ui/src/components/gauge.tsx @@ -0,0 +1,604 @@ +import { + forwardRef, + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithoutRef, + type ReactNode +} from "react"; + +import { + gaugeCanvasVariants, + gaugeDescriptionVariants, + gaugeIndicatorVariants, + gaugeLabelVariants, + gaugeSvgVariants, + gaugeTickVariants, + gaugeTicksVariants, + gaugeTrackVariants, + gaugeValueVariants, + gaugeVariants +} from "./gauge.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +const DEFAULT_MAX = 100; +const DEFAULT_MIN = 0; +const DEFAULT_TICK_COUNTS = { + dial: 0, + semi: 0 +} as const; +const MIN_TICK_COUNT = 0; +const MAX_TICK_COUNT = 24; +const numberFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 1 +}); +const INITIAL_SWEEP_DURATION = 520; +const UPDATE_SWEEP_DURATION = 320; + +type GaugeShape = "dial" | "semi"; + +type GaugeGeometry = { + centerX: number; + centerY: number; + endAngle: number; + radius: number; + startAngle: number; + tickInnerRadius: number; + tickOuterRadius: number; + viewBox: string; +}; + +const GAUGE_GEOMETRY: Record = { + dial: { + centerX: 60, + centerY: 60, + endAngle: 495, + radius: 42, + startAngle: 225, + tickInnerRadius: 47, + tickOuterRadius: 54, + viewBox: "0 0 120 120" + }, + semi: { + centerX: 60, + centerY: 60, + endAngle: 450, + radius: 42, + startAngle: 270, + tickInnerRadius: 47, + tickOuterRadius: 54, + viewBox: "0 0 120 76" + } +}; + +export type GaugeValueFormatterContext = { + max: number; + min: number; + percentage: number | null; + value: number | null; +}; + +function normalizeNumber(value: number | null | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + + return value; +} + +function getResolvedMinMax(min: number | undefined, max: number | undefined) { + const resolvedMin = Number.isFinite(min) ? (min as number) : DEFAULT_MIN; + const resolvedMaxCandidate = Number.isFinite(max) ? (max as number) : DEFAULT_MAX; + const resolvedMax = + resolvedMaxCandidate > resolvedMin ? resolvedMaxCandidate : resolvedMin + 1; + + return { + max: resolvedMax, + min: resolvedMin + }; +} + +function clampValue(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function getState(value: number | null | undefined, min: number, max: number) { + const normalizedValue = normalizeNumber(value); + + if (normalizedValue == null) { + return "empty"; + } + + return clampValue(normalizedValue, min, max) >= max ? "max" : "value"; +} + +function getPercentage(value: number | null | undefined, min: number, max: number) { + const normalizedValue = normalizeNumber(value); + + if (normalizedValue == null) { + return null; + } + + return ((clampValue(normalizedValue, min, max) - min) / (max - min)) * 100; +} + +function getResolvedTickCount(shape: GaugeShape, tickCount: number | undefined) { + if (!Number.isFinite(tickCount)) { + return DEFAULT_TICK_COUNTS[shape]; + } + + return Math.min(Math.max(Math.round(tickCount as number), MIN_TICK_COUNT), MAX_TICK_COUNT); +} + +function polarToCartesian( + centerX: number, + centerY: number, + radius: number, + angleInDegrees: number +) { + const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180; + + return { + x: centerX + radius * Math.cos(angleInRadians), + y: centerY + radius * Math.sin(angleInRadians) + }; +} + +function describeArc( + centerX: number, + centerY: number, + radius: number, + startAngle: number, + endAngle: number +) { + const start = polarToCartesian(centerX, centerY, radius, endAngle); + const end = polarToCartesian(centerX, centerY, radius, startAngle); + const sweep = Math.abs(endAngle - startAngle); + const largeArcFlag = sweep <= 180 ? "0" : "1"; + + return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`; +} + +function describeTick( + centerX: number, + centerY: number, + innerRadius: number, + outerRadius: number, + angle: number +) { + const start = polarToCartesian(centerX, centerY, outerRadius, angle); + const end = polarToCartesian(centerX, centerY, innerRadius, angle); + + return `M ${start.x} ${start.y} L ${end.x} ${end.y}`; +} + +function getActiveTickCount(percentage: number | null, tickCount: number) { + if (percentage == null || percentage <= 0 || tickCount <= 0) { + return 0; + } + + if (percentage >= 100) { + return tickCount; + } + + return Math.max(1, Math.round((tickCount - 1) * (percentage / 100)) + 1); +} + +function defaultValueFormatter({ + max, + min, + percentage, + value +}: GaugeValueFormatterContext) { + if (value == null || percentage == null) { + return "—"; + } + + const clampedValue = clampValue(value, min, max); + + if (min === 0 && max === 100) { + return `${Math.round(clampedValue)}%`; + } + + return numberFormatter.format(clampedValue); +} + +function easeOutCubic(value: number) { + return 1 - (1 - value) ** 3; +} + +function motionIsDisabled() { + if (typeof document !== "undefined" && document.documentElement.dataset.motion === "static") { + return true; + } + + if (typeof window !== "undefined" && typeof window.matchMedia === "function") { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } + + return false; +} + +type LegacyMediaQueryList = MediaQueryList & { + addListener?: (listener: (event: MediaQueryListEvent) => void) => void; + removeListener?: (listener: (event: MediaQueryListEvent) => void) => void; +}; + +export type GaugeProps = Omit, "children"> & + VariantProps & { + description?: ReactNode; + label?: ReactNode; + max?: number; + min?: number; + tickCount?: number; + value?: number | null; + valueFormatter?: (context: GaugeValueFormatterContext) => ReactNode; + }; + +export const Gauge = forwardRef(function Gauge( + { + "aria-describedby": ariaDescribedBy, + "aria-label": ariaLabelProp, + className, + description, + label, + max, + min, + shape = "dial", + size, + tickCount, + tone, + value, + valueFormatter, + variant, + ...props + }, + ref +) { + const resolvedShape: GaugeShape = shape ?? "dial"; + const resolvedSize = size ?? "md"; + const resolvedTone = tone ?? "default"; + const resolvedVariant = variant ?? "default"; + const { max: resolvedMax, min: resolvedMin } = getResolvedMinMax(min, max); + const state = getState(value, resolvedMin, resolvedMax); + const percentage = getPercentage(value, resolvedMin, resolvedMax); + const resolvedTickCount = getResolvedTickCount(resolvedShape, tickCount); + const geometry = GAUGE_GEOMETRY[resolvedShape]; + const normalizedValue = normalizeNumber(value); + const formattedValue = (valueFormatter ?? defaultValueFormatter)({ + max: resolvedMax, + min: resolvedMin, + percentage, + value: normalizedValue + }); + const gradientId = `gauge-gradient-${useId().replace(/:/g, "")}`; + const trackPath = describeArc( + geometry.centerX, + geometry.centerY, + geometry.radius, + geometry.startAngle, + geometry.endAngle + ); + const ticks = + resolvedTickCount > 0 + ? Array.from({ length: resolvedTickCount }, (_, index) => { + const ratio = + resolvedTickCount === 1 ? 1 : index / (resolvedTickCount - 1); + const angle = + geometry.startAngle + + (geometry.endAngle - geometry.startAngle) * ratio; + + return { + index, + path: describeTick( + geometry.centerX, + geometry.centerY, + geometry.tickInnerRadius, + geometry.tickOuterRadius, + angle + ) + }; + }) + : []; + const computedAriaLabel = + ariaLabelProp ?? (typeof label === "string" ? label : undefined); + const computedAriaValueNow = + normalizedValue == null + ? undefined + : clampValue(normalizedValue, resolvedMin, resolvedMax); + const computedAriaValueText = + typeof formattedValue === "string" || typeof formattedValue === "number" + ? String(formattedValue) + : undefined; + const [disableMotion, setDisableMotion] = useState(motionIsDisabled); + const [displayPercentage, setDisplayPercentage] = useState(() => + motionIsDisabled() ? percentage ?? 0 : 0 + ); + const [displayValue, setDisplayValue] = useState(() => + motionIsDisabled() + ? computedAriaValueNow ?? null + : computedAriaValueNow == null + ? null + : resolvedMin + ); + const [isAnimating, setIsAnimating] = useState(false); + const hasAnimatedRef = useRef(false); + const previousPercentageRef = useRef(percentage ?? 0); + const previousValueRef = useRef(computedAriaValueNow ?? null); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + const syncMotionMode = () => { + setDisableMotion(motionIsDisabled()); + }; + + syncMotionMode(); + + const observer = new MutationObserver(syncMotionMode); + observer.observe(document.documentElement, { + attributeFilter: ["data-motion"] + }); + + const mediaQuery: LegacyMediaQueryList | null = + typeof window !== "undefined" && typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)") + : null; + + if (mediaQuery) { + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", syncMotionMode); + } else { + mediaQuery.addListener?.(syncMotionMode); + } + } + + return () => { + observer.disconnect(); + + if (mediaQuery) { + if (typeof mediaQuery.removeEventListener === "function") { + mediaQuery.removeEventListener("change", syncMotionMode); + } else { + mediaQuery.removeListener?.(syncMotionMode); + } + } + }; + }, []); + + useEffect(() => { + const targetPercentage = percentage ?? 0; + const targetValue = computedAriaValueNow ?? null; + + if ( + disableMotion || + targetValue == null || + percentage == null || + (hasAnimatedRef.current && + previousPercentageRef.current === targetPercentage && + previousValueRef.current === targetValue) + ) { + setDisplayPercentage(targetPercentage); + setDisplayValue(targetValue); + setIsAnimating(false); + previousPercentageRef.current = targetPercentage; + previousValueRef.current = targetValue; + + if (targetValue != null) { + hasAnimatedRef.current = true; + } + + return; + } + + const fromPercentage = hasAnimatedRef.current ? previousPercentageRef.current : 0; + const fromValue = hasAnimatedRef.current + ? previousValueRef.current ?? resolvedMin + : resolvedMin; + const duration = hasAnimatedRef.current ? UPDATE_SWEEP_DURATION : INITIAL_SWEEP_DURATION; + let frame = 0; + let startTime: number | null = null; + + setDisplayPercentage(fromPercentage); + setDisplayValue(fromValue); + setIsAnimating(true); + + const tick = (timestamp: number) => { + if (startTime == null) { + startTime = timestamp; + } + + const progress = Math.min((timestamp - startTime) / duration, 1); + const easedProgress = easeOutCubic(progress); + + setDisplayPercentage(fromPercentage + (targetPercentage - fromPercentage) * easedProgress); + setDisplayValue(fromValue + (targetValue - fromValue) * easedProgress); + + if (progress < 1) { + frame = window.requestAnimationFrame(tick); + return; + } + + setDisplayPercentage(targetPercentage); + setDisplayValue(targetValue); + setIsAnimating(false); + previousPercentageRef.current = targetPercentage; + previousValueRef.current = targetValue; + hasAnimatedRef.current = true; + }; + + frame = window.requestAnimationFrame(tick); + + return () => { + window.cancelAnimationFrame(frame); + }; + }, [computedAriaValueNow, disableMotion, percentage, resolvedMin]); + + const renderedPercentage = disableMotion ? percentage ?? 0 : displayPercentage; + const renderedValue = + disableMotion || computedAriaValueNow == null ? computedAriaValueNow ?? null : displayValue; + const renderedActiveTickCount = getActiveTickCount(renderedPercentage, resolvedTickCount); + const renderedFormattedValue = (valueFormatter ?? defaultValueFormatter)({ + max: resolvedMax, + min: resolvedMin, + percentage: computedAriaValueNow == null ? null : renderedPercentage, + value: renderedValue + }); + + return ( +
+
+ + +
+ {renderedFormattedValue} +
+
+ + {label ? ( +

+ {label} +

+ ) : null} + + {description ? ( +

+ {description} +

+ ) : null} +
+ ); +}); diff --git a/packages/ui/src/components/gauge.variants.ts b/packages/ui/src/components/gauge.variants.ts new file mode 100644 index 0000000..f21026c --- /dev/null +++ b/packages/ui/src/components/gauge.variants.ts @@ -0,0 +1,96 @@ +import { cva } from "../lib/cva"; + +export const gaugeVariants = cva("inline-grid justify-items-center text-center", { + variants: { + size: { + sm: + "gap-2.5 [--ui-gauge-dial-width:7.75rem] [--ui-gauge-semi-width:10rem] [--ui-gauge-value-font-size:1.55rem] [--ui-gauge-value-height:2.5rem] [--ui-gauge-value-padding-inline:0.8rem] [--ui-gauge-label-font-size:0.9rem] [--ui-gauge-description-font-size:0.78rem]", + md: + "gap-3 [--ui-gauge-dial-width:9.5rem] [--ui-gauge-semi-width:12rem] [--ui-gauge-value-font-size:1.95rem] [--ui-gauge-value-height:2.9rem] [--ui-gauge-value-padding-inline:1rem] [--ui-gauge-label-font-size:0.96rem] [--ui-gauge-description-font-size:0.84rem]", + lg: + "gap-3.5 [--ui-gauge-dial-width:12rem] [--ui-gauge-semi-width:15rem] [--ui-gauge-value-font-size:2.45rem] [--ui-gauge-value-height:3.5rem] [--ui-gauge-value-padding-inline:1.15rem] [--ui-gauge-label-font-size:1.05rem] [--ui-gauge-description-font-size:0.92rem]" + }, + shape: { + dial: "w-[var(--ui-gauge-dial-width)] [--ui-gauge-value-bottom:18%]", + semi: "w-[var(--ui-gauge-semi-width)] [--ui-gauge-value-bottom:2%]" + }, + tone: { + default: + "[--ui-gauge-center-bg:var(--ui-gauge-center-default-bg)] [--ui-gauge-center-shadow:var(--ui-gauge-center-default-shadow)]", + subtle: + "[--ui-gauge-center-bg:var(--ui-gauge-center-subtle-bg)] [--ui-gauge-center-shadow:var(--ui-gauge-center-subtle-shadow)]", + accent: + "[--ui-gauge-center-bg:var(--ui-gauge-center-accent-bg)] [--ui-gauge-center-shadow:var(--ui-gauge-center-accent-shadow)]" + }, + variant: { + default: + "[--ui-gauge-indicator-start:var(--ui-gauge-indicator-default-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-default-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-default-end)]", + success: + "[--ui-gauge-indicator-start:var(--ui-gauge-indicator-success-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-success-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-success-end)]", + warning: + "[--ui-gauge-indicator-start:var(--ui-gauge-indicator-warning-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-warning-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-warning-end)]", + destructive: + "[--ui-gauge-indicator-start:var(--ui-gauge-indicator-destructive-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-destructive-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-destructive-end)]" + } + }, + defaultVariants: { + shape: "dial", + size: "md", + tone: "default", + variant: "default" + } +}); + +export const gaugeCanvasVariants = cva( + [ + "relative isolate w-full", + "before:pointer-events-none before:absolute before:inset-x-[20%] before:top-[12%] before:h-[38%] before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--ui-gauge-indicator-start)_26%,transparent),transparent_72%)] before:opacity-0 before:blur-xl before:content-['']", + "data-[animating]:before:opacity-100 data-[animating]:before:animate-[aiui-breathe_620ms_var(--ease-standard)_1]" + ], + { + variants: { + shape: { + dial: "aspect-square", + semi: "aspect-[120/76]" + } + }, + defaultVariants: { + shape: "dial" + } +} +); + +export const gaugeSvgVariants = cva("h-full w-full overflow-visible"); + +export const gaugeTrackVariants = cva("fill-none stroke-[var(--ui-gauge-track-stroke)]"); + +export const gaugeIndicatorVariants = cva( + [ + "fill-none transition-[stroke-dasharray,opacity,filter] duration-[var(--dur-slow)] ease-[var(--ease-emphasized)]", + "data-[state=empty]:opacity-0", + "data-[animating]:[filter:drop-shadow(0_0_0.9rem_color-mix(in_oklch,var(--ui-gauge-indicator-end)_18%,transparent))]" + ] +); + +export const gaugeTicksVariants = cva("fill-none"); + +export const gaugeTickVariants = cva( + "fill-none stroke-[var(--ui-gauge-tick-stroke)] opacity-60 transition-[stroke,opacity] duration-[var(--dur-base)] ease-[var(--ease-standard)] data-[active]:stroke-[var(--ui-gauge-active-tick-stroke)] data-[active]:opacity-100" +); + +export const gaugeValueVariants = cva( + [ + "absolute inset-x-[18%] bottom-[var(--ui-gauge-value-bottom)] inline-flex min-h-[var(--ui-gauge-value-height)] items-center justify-center rounded-[var(--radius-full)]", + "bg-[var(--ui-gauge-center-bg)] px-[var(--ui-gauge-value-padding-inline)] text-[var(--ui-gauge-value-font-size)] font-semibold leading-none tracking-[var(--tracking-tight)] text-[var(--color-foreground)]", + "shadow-[var(--ui-gauge-center-shadow)] transition-[transform,opacity,box-shadow] duration-[var(--dur-slow)] ease-[var(--ease-emphasized)] will-change-transform", + "data-[animating]:animate-[aiui-breathe_620ms_var(--ease-standard)_1]" + ] +); + +export const gaugeLabelVariants = cva( + "text-[var(--ui-gauge-label-font-size)] font-medium text-[var(--color-foreground)]" +); + +export const gaugeDescriptionVariants = cva( + "max-w-[24rem] text-[var(--ui-gauge-description-font-size)] leading-6 text-[var(--color-muted-foreground)]" +); diff --git a/packages/ui/src/components/grid.test.tsx b/packages/ui/src/components/grid.test.tsx new file mode 100644 index 0000000..0ddbb5a --- /dev/null +++ b/packages/ui/src/components/grid.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Col, Row } from "./grid"; + +describe("Grid", () => { + it("renders row and item slots", () => { + render( + + Content + + ); + + expect(screen.getByTestId("row")).toHaveAttribute("data-slot", "root"); + expect(screen.getByTestId("row")).toHaveAttribute("data-gap", "md"); + expect(screen.getByTestId("col")).toHaveAttribute("data-slot", "item"); + expect(screen.getByTestId("col")).toHaveAttribute("data-span", "full"); + }); + + it("applies tokenized gap and alignment classes on the row", () => { + render( + + Metric + + ); + + expect(screen.getByTestId("row")).toHaveClass("items-center"); + expect(screen.getByTestId("row")).toHaveClass("gap-[var(--ui-grid-gap-lg)]"); + expect(screen.getByTestId("row")).toHaveClass("gap-x-[var(--ui-grid-gap-xl)]"); + expect(screen.getByTestId("row")).toHaveClass("gap-y-[var(--ui-grid-gap-sm)]"); + }); + + it("writes base and responsive placement variables on columns", () => { + render( + + ); + + const column = screen.getByTestId("col"); + + expect(column.style.getPropertyValue("--ui-col-placement")).toBe("2 / span 6"); + expect(column.style.getPropertyValue("--ui-col-placement-lg")).toBe("3 / span 4"); + expect(column.style.getPropertyValue("--ui-col-placement-2xl")).toBe("auto"); + expect(column).toHaveAttribute("data-span", "6"); + expect(column).toHaveAttribute("data-offset", "1"); + }); +}); diff --git a/packages/ui/src/components/grid.tsx b/packages/ui/src/components/grid.tsx new file mode 100644 index 0000000..0065f4e --- /dev/null +++ b/packages/ui/src/components/grid.tsx @@ -0,0 +1,191 @@ +import { + forwardRef, + type ComponentPropsWithoutRef, + type CSSProperties +} from "react"; + +import { + colVariants, + rowGapClasses, + rowVariants, + rowXGapClasses, + rowYGapClasses +} from "./grid.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +export type GridSpan = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | "auto" | "full"; + +export type GridOffset = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; + +export type GridResponsiveValue = + | GridSpan + | { + offset?: GridOffset; + span?: GridSpan; + }; + +type BreakpointKey = "sm" | "md" | "lg" | "xl" | "xxl"; + +type GridPlacement = { + offset?: GridOffset; + span?: GridSpan; +}; + +type GridStyleVarName = + | "--ui-col-placement" + | "--ui-col-placement-sm" + | "--ui-col-placement-md" + | "--ui-col-placement-lg" + | "--ui-col-placement-xl" + | "--ui-col-placement-2xl"; + +type GridStyle = CSSProperties & + Partial>; + +const breakpointVars: Record = { + sm: "--ui-col-placement-sm", + md: "--ui-col-placement-md", + lg: "--ui-col-placement-lg", + xl: "--ui-col-placement-xl", + xxl: "--ui-col-placement-2xl" +}; + +const breakpointKeys = Object.keys(breakpointVars) as BreakpointKey[]; + +function normalizePlacement(value?: GridResponsiveValue): GridPlacement | undefined { + if (value === undefined) { + return undefined; + } + + if (typeof value === "number" || typeof value === "string") { + return { span: value }; + } + + return value; +} + +function resolvePlacementValue(placement?: GridPlacement) { + if (!placement) { + return undefined; + } + + const offset = placement.offset ?? 0; + + if (placement.span === undefined && offset === 0) { + return undefined; + } + + const span = placement.span ?? "full"; + + if (span === "full") { + return offset > 0 ? `${offset + 1} / -1` : "1 / -1"; + } + + if (span === "auto") { + return offset > 0 ? `${offset + 1} / auto` : "auto"; + } + + return offset > 0 ? `${offset + 1} / span ${span}` : `span ${span} / span ${span}`; +} + +export type RowGap = keyof typeof rowGapClasses; + +export type RowProps = ComponentPropsWithoutRef<"div"> & + VariantProps & { + gap?: RowGap; + xGap?: RowGap; + yGap?: RowGap; + }; + +export const Row = forwardRef(function Row( + { + align = "stretch", + className, + gap = "md", + xGap, + yGap, + ...props + }, + ref +) { + return ( +
+ ); +}); + +export type ColProps = ComponentPropsWithoutRef<"div"> & { + offset?: GridOffset; + span?: GridSpan; + sm?: GridResponsiveValue; + md?: GridResponsiveValue; + lg?: GridResponsiveValue; + xl?: GridResponsiveValue; + xxl?: GridResponsiveValue; +}; + +export const Col = forwardRef(function Col( + { + className, + offset, + span, + style, + sm, + md, + lg, + xl, + xxl, + ...props + }, + ref +) { + const resolvedStyle: GridStyle = { ...(style ?? {}) }; + const basePlacement = resolvePlacementValue({ offset, span }); + + if (basePlacement) { + resolvedStyle["--ui-col-placement"] = basePlacement; + } + + const responsiveValues = { sm, md, lg, xl, xxl } satisfies Record< + BreakpointKey, + GridResponsiveValue | undefined + >; + + for (const key of breakpointKeys) { + const placement = resolvePlacementValue(normalizePlacement(responsiveValues[key])); + + if (placement) { + resolvedStyle[breakpointVars[key]] = placement; + } + } + + return ( +
0 ? offset : undefined, + span: span ?? "full" + })} + className={cn(colVariants(), className)} + ref={ref} + style={resolvedStyle} + /> + ); +}); diff --git a/packages/ui/src/components/grid.variants.ts b/packages/ui/src/components/grid.variants.ts new file mode 100644 index 0000000..af70f33 --- /dev/null +++ b/packages/ui/src/components/grid.variants.ts @@ -0,0 +1,52 @@ +import { cva } from "../lib/cva"; + +export const rowGapClasses = { + none: "gap-0", + xs: "gap-[var(--ui-grid-gap-xs)]", + sm: "gap-[var(--ui-grid-gap-sm)]", + md: "gap-[var(--ui-grid-gap-md)]", + lg: "gap-[var(--ui-grid-gap-lg)]", + xl: "gap-[var(--ui-grid-gap-xl)]" +} as const; + +export const rowXGapClasses = { + none: "gap-x-0", + xs: "gap-x-[var(--ui-grid-gap-xs)]", + sm: "gap-x-[var(--ui-grid-gap-sm)]", + md: "gap-x-[var(--ui-grid-gap-md)]", + lg: "gap-x-[var(--ui-grid-gap-lg)]", + xl: "gap-x-[var(--ui-grid-gap-xl)]" +} as const; + +export const rowYGapClasses = { + none: "gap-y-0", + xs: "gap-y-[var(--ui-grid-gap-xs)]", + sm: "gap-y-[var(--ui-grid-gap-sm)]", + md: "gap-y-[var(--ui-grid-gap-md)]", + lg: "gap-y-[var(--ui-grid-gap-lg)]", + xl: "gap-y-[var(--ui-grid-gap-xl)]" +} as const; + +export const rowVariants = cva("grid w-full min-w-0 auto-rows-auto grid-cols-12", { + variants: { + align: { + start: "items-start", + center: "items-center", + end: "items-end", + stretch: "items-stretch" + } + }, + defaultVariants: { + align: "stretch" + } +}); + +export const colVariants = cva([ + "min-w-0", + "[grid-column:var(--ui-col-placement,1_/_-1)]", + "sm:[grid-column:var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1))]", + "md:[grid-column:var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1)))]", + "lg:[grid-column:var(--ui-col-placement-lg,var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1))))]", + "xl:[grid-column:var(--ui-col-placement-xl,var(--ui-col-placement-lg,var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1)))))]", + "2xl:[grid-column:var(--ui-col-placement-2xl,var(--ui-col-placement-xl,var(--ui-col-placement-lg,var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1))))))]" +]); diff --git a/packages/ui/src/components/input-group.test.tsx b/packages/ui/src/components/input-group.test.tsx new file mode 100644 index 0000000..beb1935 --- /dev/null +++ b/packages/ui/src/components/input-group.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Field, FieldControl, FieldDescription, FieldError } from "./field"; +import { InputGroup, InputGroupPrefix, InputGroupSuffix } from "./input-group"; +import { Input } from "./input"; +import { Label } from "./label"; + +describe("InputGroup", () => { + it("renders affix slots and propagates grouped state to the input", () => { + render( + + + + + + + ⌘K + + + ); + + const input = screen.getByRole("textbox", { name: "Search launches" }); + const control = input.closest('[data-slot="control"]'); + + expect(control).toHaveAttribute("data-slot", "control"); + expect(control).toHaveAttribute("data-disabled", ""); + expect(control).toHaveAttribute("data-invalid", ""); + expect(control).toHaveAttribute("data-readonly", ""); + expect(control).toHaveAttribute("data-required", ""); + expect(control).toHaveAttribute("data-size", "lg"); + expect(input).toHaveAttribute("data-slot", "input"); + expect(input).toHaveAttribute("data-size", "lg"); + expect(input).toBeDisabled(); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).toHaveAttribute("readonly"); + expect(input).toBeRequired(); + expect(screen.getByText("#").closest('[data-slot="prefix"]')).toBeInTheDocument(); + expect(screen.getByText("⌘K").closest('[data-slot="suffix"]')).toBeInTheDocument(); + }); + + it("keeps field ids and described-by wiring when grouped inside a field control", () => { + render( + + + + + + + + Find the right routing lane before queuing. + Search query is required. + + + ); + + const input = screen.getByRole("textbox", { name: "Lane search" }); + const description = screen.getByText("Find the right routing lane before queuing."); + const error = screen.getByText("Search query is required."); + + expect(input).toHaveAttribute("aria-describedby", `${description.id} ${error.id}`); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).toBeRequired(); + expect(input.closest('[data-slot="control"]')).toHaveAttribute("data-invalid", ""); + }); +}); diff --git a/packages/ui/src/components/input-group.tsx b/packages/ui/src/components/input-group.tsx new file mode 100644 index 0000000..26228f5 --- /dev/null +++ b/packages/ui/src/components/input-group.tsx @@ -0,0 +1,110 @@ +import { + createContext, + forwardRef, + useContext, + type ComponentPropsWithoutRef +} from "react"; + +import { + inputGroupAffixVariants, + inputGroupVariants +} from "./input-group.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import type { FieldStateProps } from "../lib/contracts"; +import { createDataAttributes, createSlot } from "../lib/contracts"; +import { useFieldContext } from "./field"; + +type InputGroupContextValue = { + disabled: boolean; + invalid: boolean; + readOnly: boolean; + required: boolean; + size: Exclude["size"], null | undefined>; +}; + +const InputGroupContext = createContext(null); + +export function useInputGroupContext() { + return useContext(InputGroupContext); +} + +export type InputGroupProps = ComponentPropsWithoutRef<"div"> & + FieldStateProps & + VariantProps; + +export const InputGroup = forwardRef(function InputGroup( + { + className, + disabled, + invalid, + readOnly, + required, + size = "md", + ...props + }, + ref +) { + const field = useFieldContext(); + const resolvedDisabled = disabled ?? field?.disabled ?? false; + const resolvedInvalid = invalid ?? field?.invalid ?? false; + const resolvedReadOnly = readOnly ?? field?.readOnly ?? false; + const resolvedRequired = required ?? field?.required ?? false; + const resolvedSize = size ?? "md"; + + return ( + +
+ + ); +}); + +export type InputGroupPrefixProps = ComponentPropsWithoutRef<"div">; + +export const InputGroupPrefix = forwardRef( + function InputGroupPrefix({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type InputGroupSuffixProps = ComponentPropsWithoutRef<"div">; + +export const InputGroupSuffix = forwardRef( + function InputGroupSuffix({ className, ...props }, ref) { + return ( +
+ ); + } +); diff --git a/packages/ui/src/components/input-group.variants.ts b/packages/ui/src/components/input-group.variants.ts new file mode 100644 index 0000000..1b6dab7 --- /dev/null +++ b/packages/ui/src/components/input-group.variants.ts @@ -0,0 +1,56 @@ +import { cva } from "../lib/cva"; +import { getMotionRecipeClassNames } from "../lib/motion"; + +export const inputGroupVariants = cva( + [ + "flex w-full min-w-0 items-center rounded-[var(--ui-input-radius)] border bg-[var(--ui-input-bg)]", + "text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none", + "[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]", + "transition-[border-color,box-shadow,background-color,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]", + "focus-within:-translate-y-[var(--ui-input-focus-lift)] focus-within:border-[var(--ui-input-focus-border)] focus-within:shadow-[var(--ui-input-focus-shadow)]", + "focus-within:ring-2 focus-within:ring-[var(--color-ring)] focus-within:ring-offset-2 focus-within:ring-offset-[var(--color-background)]", + "data-[disabled]:cursor-not-allowed data-[disabled]:bg-[var(--ui-input-disabled-bg)] data-[disabled]:text-[var(--color-muted-foreground)]", + "data-[readonly]:bg-[var(--ui-input-readonly-bg)] data-[readonly]:text-[var(--color-muted-foreground)]", + "data-[invalid]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]", + "data-[invalid]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]", + getMotionRecipeClassNames("ring") + ], + { + variants: { + size: { + sm: "h-10 gap-2.5 px-3", + md: "h-11 gap-3 px-4", + lg: "h-12 gap-3 px-4" + } + }, + defaultVariants: { + size: "md" + } + } +); + +export const inputGroupInputVariants = cva( + [ + "h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--ui-input-fg)] shadow-none outline-none", + "placeholder:text-[var(--color-muted-foreground)]", + "focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0", + "disabled:cursor-not-allowed disabled:text-[var(--color-muted-foreground)] disabled:opacity-100", + "read-only:text-[var(--color-muted-foreground)]" + ], + { + variants: { + size: { + sm: "text-sm", + md: "text-sm", + lg: "text-base" + } + }, + defaultVariants: { + size: "md" + } + } +); + +export const inputGroupAffixVariants = cva( + "flex shrink-0 items-center text-[var(--color-muted-foreground)] [&_svg]:size-4 [&_svg]:shrink-0" +); diff --git a/packages/ui/src/components/metric-card.test.tsx b/packages/ui/src/components/metric-card.test.tsx new file mode 100644 index 0000000..bce5fa5 --- /dev/null +++ b/packages/ui/src/components/metric-card.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Button } from "./button"; +import { + MetricCard, + MetricCardActions, + MetricCardAside, + MetricCardDelta, + MetricCardDescription, + MetricCardEyebrow, + MetricCardFooter, + MetricCardHeader, + MetricCardLabel, + MetricCardLeading, + MetricCardMedia, + MetricCardMetric, + MetricCardValue +} from "./metric-card"; + +describe("MetricCard", () => { + it("renders shared KPI slots plus media, actions, and footer regions", () => { + render( + + + + Runway lens + Operational cost reduction + + + 42% + + + + $38,250 + +78 + + Target progress across the active quarter. + +
Chart surface
+
+ + + + +
Quarter target
+
+
+ ); + + const card = screen.getByTestId("metric-card"); + + expect(card).toHaveAttribute("data-layout", "split"); + expect(card).toHaveAttribute("data-interactive", ""); + expect(card).toHaveAttribute("data-tone", "hero"); + expect(card.className).toContain("hover:translate-y-[var(--ui-card-hover-translate)]"); + expect(card.className).toContain("[&[data-interactive]:hover>[data-slot=media]]:-translate-y-0.5"); + expect(card.className).toContain("motion-reduce:hover:translate-y-0"); + expect(screen.getByText("Runway lens")).toHaveAttribute("data-slot", "eyebrow"); + expect(screen.getByText("Operational cost reduction")).toHaveAttribute("data-slot", "label"); + expect(screen.getByText("Operational cost reduction").closest('[data-slot="leading"]')).toBeInTheDocument(); + expect(screen.getByText("42%").closest('[data-slot="aside"]')).toBeInTheDocument(); + expect(screen.getByText("Chart surface").closest('[data-slot="media"]')).toHaveAttribute( + "data-padding", + "flush" + ); + expect(screen.getByText("Chart surface").closest('[data-slot="media"]')).toHaveAttribute( + "data-tone", + "accent" + ); + expect(screen.getByText("Chart surface").closest('[data-slot="media"]')?.className).toContain( + "before:pointer-events-none" + ); + expect( + screen.getByRole("button", { name: "Review forecast" }).closest('[data-slot="actions"]') + ).toHaveAttribute("data-layout", "stack"); + expect(screen.getByText("Quarter target").closest('[data-slot="footer"]')).toBeInTheDocument(); + }); + + it("shares the stat-card metric contract inside richer panels", () => { + render( + + + + Revenue pulse + Revenue influence + + + + 31% + +9.2% + + + AI-assisted routing is improving close quality instead of just adding volume. + + + ); + + expect(screen.getByText("Revenue influence").closest('[data-slot="header"]')).toHaveClass( + "items-start" + ); + expect(screen.getByText("Revenue pulse")).toHaveAttribute("data-slot", "eyebrow"); + expect(screen.getByText("Revenue influence").closest('[data-slot="root"]')).toHaveAttribute( + "data-tone", + "inverse" + ); + expect(screen.getByText("31%")).toHaveAttribute("data-slot", "value"); + expect(screen.getByText("+9.2%")).toHaveAttribute("data-tone", "primary"); + }); + + it("keeps the richer hover choreography out when interactive polish is disabled", () => { + render( + + + + Forecast confidence + + + + 31% + +9.2% + + + ); + + const card = screen.getByTestId("metric-card"); + + expect(card).not.toHaveAttribute("data-interactive"); + expect(card.className).not.toContain("hover:translate-y-[var(--ui-card-hover-translate)]"); + }); +}); diff --git a/packages/ui/src/components/metric-card.tsx b/packages/ui/src/components/metric-card.tsx new file mode 100644 index 0000000..f8271ad --- /dev/null +++ b/packages/ui/src/components/metric-card.tsx @@ -0,0 +1,158 @@ +import { forwardRef, type ComponentPropsWithoutRef } from "react"; + +import { + metricCardAsideVariants, + metricCardActionsVariants, + metricCardFooterVariants, + metricCardHeaderVariants, + metricCardLeadingVariants, + metricCardMediaVariants, + metricCardVariants +} from "./metric-card.variants"; +import { + StatCardDelta, + StatCardDescription, + StatCardEyebrow, + StatCardLabel, + StatCardMetric, + StatCardValue, + type StatCardDeltaProps, + type StatCardDescriptionProps, + type StatCardEyebrowProps, + type StatCardLabelProps, + type StatCardMetricProps, + type StatCardValueProps +} from "./stat-card"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +export type MetricCardProps = ComponentPropsWithoutRef<"div"> & + VariantProps; + +export const MetricCard = forwardRef(function MetricCard( + { className, interactive, layout, tone, ...props }, + ref +) { + return ( +
+ ); +}); + +export type MetricCardHeaderProps = ComponentPropsWithoutRef<"div">; + +export const MetricCardHeader = forwardRef( + function MetricCardHeader({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type MetricCardLeadingProps = ComponentPropsWithoutRef<"div">; + +export const MetricCardLeading = forwardRef( + function MetricCardLeading({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type MetricCardAsideProps = ComponentPropsWithoutRef<"div">; + +export const MetricCardAside = forwardRef( + function MetricCardAside({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export const MetricCardEyebrow = StatCardEyebrow; +export type MetricCardEyebrowProps = StatCardEyebrowProps; + +export const MetricCardLabel = StatCardLabel; +export type MetricCardLabelProps = StatCardLabelProps; + +export const MetricCardMetric = StatCardMetric; +export type MetricCardMetricProps = StatCardMetricProps; + +export const MetricCardValue = StatCardValue; +export type MetricCardValueProps = StatCardValueProps; + +export const MetricCardDelta = StatCardDelta; +export type MetricCardDeltaProps = StatCardDeltaProps; + +export const MetricCardDescription = StatCardDescription; +export type MetricCardDescriptionProps = StatCardDescriptionProps; + +export type MetricCardMediaProps = ComponentPropsWithoutRef<"div"> & + VariantProps; + +export const MetricCardMedia = forwardRef( + function MetricCardMedia({ className, padding, tone, ...props }, ref) { + return ( +
+ ); + } +); + +export type MetricCardActionsProps = ComponentPropsWithoutRef<"div"> & + VariantProps; + +export const MetricCardActions = forwardRef( + function MetricCardActions({ className, layout, ...props }, ref) { + return ( +
+ ); + } +); + +export type MetricCardFooterProps = ComponentPropsWithoutRef<"div">; + +export const MetricCardFooter = forwardRef( + function MetricCardFooter({ className, ...props }, ref) { + return ( +
+ ); + } +); diff --git a/packages/ui/src/components/metric-card.variants.ts b/packages/ui/src/components/metric-card.variants.ts new file mode 100644 index 0000000..99ee7f2 --- /dev/null +++ b/packages/ui/src/components/metric-card.variants.ts @@ -0,0 +1,135 @@ +import { cva } from "../lib/cva"; +import { getMotionRecipeClassNames } from "../lib/motion"; + +export const metricCardVariants = cva( + [ + "relative isolate grid gap-5 overflow-hidden rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]", + "px-5 py-5 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] sm:px-6 sm:py-6", + "before:pointer-events-none before:absolute before:inset-x-[10%] before:top-0 before:h-24 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_34%,transparent),transparent_72%)] before:opacity-70 before:blur-2xl before:content-['']", + "[&>*]:relative [&>*]:z-[1]", + "[&>[data-slot=media]]:mt-1 [&>[data-slot=media]]:isolate", + "[&>[data-slot=footer]]:mt-1 [&>[data-slot=footer]]:rounded-[calc(var(--ui-card-radius)-0.625rem)] [&>[data-slot=footer]]:border [&>[data-slot=footer]]:[border-width:var(--ui-card-border-width)] [&>[data-slot=footer]]:border-[color-mix(in_oklch,var(--color-border)_62%,transparent)] [&>[data-slot=footer]]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container)_82%,white_18%))] [&>[data-slot=footer]]:px-4 [&>[data-slot=footer]]:py-4 [&>[data-slot=footer]]:shadow-[inset_0_1px_0_rgba(255,255,255,0.48)]", + "[&>[data-slot=actions]]:rounded-[calc(var(--ui-card-radius)-0.625rem)] [&>[data-slot=actions]]:border [&>[data-slot=actions]]:[border-width:var(--ui-card-border-width)] [&>[data-slot=actions]]:border-[color-mix(in_oklch,var(--color-border)_58%,transparent)] [&>[data-slot=actions]]:bg-[color-mix(in_oklch,var(--color-surface-container-low)_76%,white_24%)] [&>[data-slot=actions]]:px-3 [&>[data-slot=actions]]:py-3 [&>[data-slot=actions]]:shadow-[inset_0_1px_0_rgba(255,255,255,0.42)] sm:[&>[data-slot=actions]]:px-4", + getMotionRecipeClassNames("transition", "ring") + ], + { + variants: { + tone: { + default: "border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)]", + subtle: + "border-[var(--ui-card-subtle-border)] bg-[var(--ui-card-subtle-bg)] shadow-[var(--ui-card-subtle-shadow)]", + accent: + "border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)] [&>[data-slot=footer]]:border-[color-mix(in_oklch,var(--color-primary)_18%,transparent)] [&>[data-slot=footer]]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_30%,white_70%),color-mix(in_oklch,var(--color-surface-container)_82%,white_18%))] [&>[data-slot=actions]]:border-[color-mix(in_oklch,var(--color-primary)_16%,transparent)] [&>[data-slot=actions]]:bg-[color-mix(in_oklch,var(--color-primary-container)_16%,white_84%)]", + inverse: [ + "border-[color-mix(in_oklch,var(--color-foreground)_10%,transparent)]", + "bg-[linear-gradient(145deg,color-mix(in_oklch,var(--color-foreground)_94%,black_6%),color-mix(in_oklch,var(--color-foreground)_74%,var(--color-primary)_26%))]", + "text-white shadow-[0_28px_72px_color-mix(in_oklch,var(--color-foreground)_22%,transparent)]", + "[&_[data-slot=eyebrow]]:text-white/60", + "[&_[data-slot=label]]:text-white", + "[&_[data-slot=value]]:text-white", + "[&_[data-slot=description]]:text-white/66", + "[&>[data-slot=footer]]:border-white/10 [&>[data-slot=footer]]:bg-white/6", + "[&>[data-slot=actions]]:border-white/10 [&>[data-slot=actions]]:bg-white/7", + "[&_[data-slot=delta][data-tone=neutral]]:border-white/12 [&_[data-slot=delta][data-tone=neutral]]:bg-white/10 [&_[data-slot=delta][data-tone=neutral]]:text-white/82", + "[&_[data-slot=delta][data-tone=primary]]:border-white/12 [&_[data-slot=delta][data-tone=primary]]:bg-white/10 [&_[data-slot=delta][data-tone=primary]]:text-white", + "[&_[data-slot=delta][data-tone=success]]:border-[color-mix(in_oklch,var(--color-success)_24%,transparent)] [&_[data-slot=delta][data-tone=success]]:bg-[color-mix(in_oklch,var(--color-success)_18%,transparent)] [&_[data-slot=delta][data-tone=success]]:text-white", + "[&_[data-slot=delta][data-tone=warning]]:border-[color-mix(in_oklch,var(--color-warning)_28%,transparent)] [&_[data-slot=delta][data-tone=warning]]:bg-[color-mix(in_oklch,var(--color-warning)_18%,transparent)] [&_[data-slot=delta][data-tone=warning]]:text-white" + ], + hero: [ + "relative overflow-hidden border-transparent", + "bg-[radial-gradient(circle_at_top,color-mix(in_oklch,var(--color-tertiary-container)_58%,transparent),transparent_40%),linear-gradient(145deg,color-mix(in_oklch,var(--color-foreground)_90%,black_10%),color-mix(in_oklch,var(--color-foreground)_78%,var(--color-primary)_22%))]", + "text-white shadow-[0_28px_72px_color-mix(in_oklch,var(--color-foreground)_22%,transparent)]", + "[&_[data-slot=eyebrow]]:text-white/60", + "[&_[data-slot=label]]:text-white", + "[&_[data-slot=value]]:text-white", + "[&_[data-slot=description]]:text-white/64", + "[&>[data-slot=footer]]:border-white/10 [&>[data-slot=footer]]:bg-white/6", + "[&>[data-slot=actions]]:border-white/10 [&>[data-slot=actions]]:bg-white/7", + "[&_[data-slot=delta][data-tone=neutral]]:border-white/12 [&_[data-slot=delta][data-tone=neutral]]:bg-white/10 [&_[data-slot=delta][data-tone=neutral]]:text-white/82", + "[&_[data-slot=delta][data-tone=primary]]:border-white/12 [&_[data-slot=delta][data-tone=primary]]:bg-white/10 [&_[data-slot=delta][data-tone=primary]]:text-white", + "[&_[data-slot=delta][data-tone=success]]:border-[color-mix(in_oklch,var(--color-success)_24%,transparent)] [&_[data-slot=delta][data-tone=success]]:bg-[color-mix(in_oklch,var(--color-success)_18%,transparent)] [&_[data-slot=delta][data-tone=success]]:text-white", + "[&_[data-slot=delta][data-tone=warning]]:border-[color-mix(in_oklch,var(--color-warning)_28%,transparent)] [&_[data-slot=delta][data-tone=warning]]:bg-[color-mix(in_oklch,var(--color-warning)_18%,transparent)] [&_[data-slot=delta][data-tone=warning]]:text-white" + ] + }, + interactive: { + false: "", + true: [ + "hover:translate-y-[var(--ui-card-hover-translate)] hover:scale-[var(--ui-card-hover-scale,1)] hover:shadow-[var(--ui-card-hover-shadow)]", + "[&>[data-slot=media]]:transition-[transform,box-shadow,border-color,background-color] [&>[data-slot=media]]:duration-[var(--dur-slow)] [&>[data-slot=media]]:ease-[var(--ease-emphasized)]", + "[&>[data-slot=actions]]:transition-[transform,box-shadow,border-color,background-color] [&>[data-slot=actions]]:duration-[var(--dur-base)] [&>[data-slot=actions]]:ease-[var(--ease-standard)]", + "[&[data-interactive]:hover>[data-slot=media]]:-translate-y-0.5 [&[data-interactive]:hover>[data-slot=media]]:shadow-[0_18px_34px_color-mix(in_oklch,var(--color-primary)_10%,transparent),inset_0_1px_0_rgba(255,255,255,0.48)]", + "[&[data-interactive]:hover>[data-slot=actions]]:-translate-y-px", + "motion-reduce:hover:translate-y-0 motion-reduce:hover:scale-100 motion-reduce:[&[data-interactive]:hover>[data-slot=media]]:translate-y-0 motion-reduce:[&[data-interactive]:hover>[data-slot=actions]]:translate-y-0" + ] + }, + layout: { + default: "", + split: + "lg:grid-cols-[minmax(0,1fr)_minmax(0,0.92fr)] lg:items-start lg:[&>[data-slot=media]]:col-start-2 lg:[&>[data-slot=media]]:row-span-3 lg:[&>[data-slot=actions]]:col-span-full lg:[&>[data-slot=footer]]:col-span-full" + } + }, + defaultVariants: { + tone: "default", + interactive: false, + layout: "default" + } + } +); + +export const metricCardHeaderVariants = cva( + "grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start" +); + +export const metricCardLeadingVariants = cva("grid min-w-0 gap-3"); + +export const metricCardAsideVariants = cva( + "flex flex-wrap items-start gap-2 justify-self-start sm:justify-self-end" +); + +export const metricCardMediaVariants = cva( + [ + "relative isolate overflow-hidden rounded-[calc(var(--ui-card-radius)-0.375rem)] border [border-width:var(--ui-card-border-width)]", + "shadow-[inset_0_1px_0_rgba(255,255,255,0.4)]", + "before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-[58%] before:bg-[linear-gradient(180deg,color-mix(in_oklch,white_74%,transparent),transparent)] before:opacity-90 before:content-['']", + "after:pointer-events-none after:absolute after:inset-y-0 after:right-[-12%] after:w-[46%] after:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_24%,transparent),transparent_72%)] after:opacity-80 after:content-['']", + "[&>*]:relative [&>*]:z-[1]" + ], + { + variants: { + padding: { + default: "p-4 sm:p-5", + flush: "p-0" + }, + tone: { + default: + "border-[var(--ui-card-subtle-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))]", + subtle: + "border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)]", + accent: + "border-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_58%,white_42%),color-mix(in_oklch,var(--color-surface)_82%,white_18%))]", + inverse: + "border-white/10 bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-foreground)_72%,white_6%),color-mix(in_oklch,var(--color-foreground)_62%,var(--color-primary)_18%))]", + hero: + "border-white/10 bg-white/6 shadow-[inset_0_1px_0_rgba(255,255,255,0.09)]" + } + }, + defaultVariants: { + padding: "default", + tone: "default" + } + } +); + +export const metricCardActionsVariants = cva("flex flex-wrap items-center gap-3", { + variants: { + layout: { + inline: "justify-start", + stack: "flex-col items-stretch sm:flex-row sm:items-center" + } + }, + defaultVariants: { + layout: "inline" + } +}); + +export const metricCardFooterVariants = cva("grid gap-3"); diff --git a/packages/ui/src/components/segmented-control.test.tsx b/packages/ui/src/components/segmented-control.test.tsx new file mode 100644 index 0000000..c8fa365 --- /dev/null +++ b/packages/ui/src/components/segmented-control.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { SegmentedControl, SegmentedControlItem } from "./segmented-control"; + +describe("SegmentedControl", () => { + it("switches the checked item when a segment is selected", async () => { + const user = userEvent.setup(); + + render( + + Sales + Support + + ); + + const sales = screen.getByRole("radio", { name: "Sales" }); + const support = screen.getByRole("radio", { name: "Support" }); + + expect(screen.getByRole("radiogroup")).toHaveAttribute("data-slot", "root"); + expect(sales).toHaveAttribute("data-slot", "control"); + expect(sales).toHaveAttribute("data-state", "checked"); + expect(screen.getByText("Sales").closest('[data-slot="label"]')).toBeInTheDocument(); + expect(sales.querySelector('[data-slot="indicator"]')).toBeTruthy(); + + await user.click(support); + + expect(support).toHaveAttribute("data-state", "checked"); + expect(sales).toHaveAttribute("data-state", "unchecked"); + expect(support.querySelector('[data-slot="indicator"]')).toBeTruthy(); + expect(sales.querySelector('[data-slot="indicator"]')).toBeNull(); + }); + + it("supports controlled value changes", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + render( + + Sales + Forecast + + ); + + await user.click(screen.getByRole("radio", { name: "Forecast" })); + + expect(onValueChange).toHaveBeenCalledWith("forecast"); + expect(screen.getByRole("radio", { name: "Sales" })).toHaveAttribute( + "data-state", + "checked" + ); + }); + + it("preserves disabled segments and orientation data attributes", async () => { + const user = userEvent.setup(); + + render( + + Sales + + Support + + + ); + + const support = screen.getByRole("radio", { name: "Support" }); + + expect(screen.getByRole("radiogroup")).toHaveAttribute("data-orientation", "vertical"); + + await user.click(support); + + expect(screen.getByRole("radio", { name: "Sales" })).toHaveAttribute("data-state", "checked"); + expect(support).toHaveAttribute("data-disabled", ""); + }); +}); diff --git a/packages/ui/src/components/segmented-control.tsx b/packages/ui/src/components/segmented-control.tsx new file mode 100644 index 0000000..7778d46 --- /dev/null +++ b/packages/ui/src/components/segmented-control.tsx @@ -0,0 +1,161 @@ +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { LayoutGroup, motion, useReducedMotion } from "motion/react"; +import { + createContext, + forwardRef, + useContext, + useEffect, + useId, + useState, + type ComponentPropsWithoutRef, + type ElementRef +} from "react"; + +import { + segmentedControlIndicatorVariants, + segmentedControlItemVariants, + segmentedControlLabelVariants, + segmentedControlVariants +} from "./segmented-control.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +type SegmentedControlMotionContextValue = { + activeValue?: string; + disableMotion: boolean; +}; + +const SegmentedControlMotionContext = createContext( + null +); + +function useStaticMotion() { + const prefersReducedMotion = useReducedMotion(); + const [isStaticMotion, setIsStaticMotion] = useState(false); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + const syncMotionMode = () => { + setIsStaticMotion(document.documentElement.dataset.motion === "static"); + }; + + syncMotionMode(); + + const observer = new MutationObserver(syncMotionMode); + observer.observe(document.documentElement, { + attributeFilter: ["data-motion"] + }); + + return () => observer.disconnect(); + }, []); + + return prefersReducedMotion || isStaticMotion; +} + +function useControllableStringState({ + controlledValue, + defaultValue, + onChange +}: { + controlledValue?: string | null; + defaultValue?: string | null; + onChange?: (value: string) => void; +}) { + const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? undefined); + const value = controlledValue ?? uncontrolledValue ?? undefined; + + const setValue = (nextValue: string) => { + if (controlledValue === undefined) { + setUncontrolledValue(nextValue); + } + + onChange?.(nextValue); + }; + + return [value, setValue] as const; +} + +export type SegmentedControlProps = + ComponentPropsWithoutRef & + VariantProps; + +export function SegmentedControl({ + children, + className, + defaultValue, + onValueChange, + orientation = "horizontal", + value, + ...props +}: SegmentedControlProps) { + const disableMotion = useStaticMotion(); + const [currentValue, setCurrentValue] = useControllableStringState({ + controlledValue: value, + defaultValue, + onChange: onValueChange + }); + const layoutGroupId = useId(); + + return ( + + + + {children} + + + + ); +} + +export type SegmentedControlItemProps = + ComponentPropsWithoutRef; + +export const SegmentedControlItem = forwardRef< + ElementRef, + SegmentedControlItemProps +>(function SegmentedControlItem({ children, className, disabled, value, ...props }, ref) { + const motionContext = useContext(SegmentedControlMotionContext); + const isActive = motionContext?.activeValue === value; + const transition = motionContext?.disableMotion + ? { duration: 0.01 } + : { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const }; + + return ( + + {isActive && motionContext ? ( + + ); +}); diff --git a/packages/ui/src/components/segmented-control.variants.ts b/packages/ui/src/components/segmented-control.variants.ts new file mode 100644 index 0000000..61b6a28 --- /dev/null +++ b/packages/ui/src/components/segmented-control.variants.ts @@ -0,0 +1,40 @@ +import { cva } from "../lib/cva"; +import { getMotionRecipeClassNames } from "../lib/motion"; + +export const segmentedControlVariants = cva( + [ + "inline-flex w-fit items-center gap-1 rounded-[calc(var(--ui-control-radius)+0.45rem)] border border-[var(--ui-control-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container)_88%,white_12%),color-mix(in_oklch,var(--color-surface-container-high)_84%,white_16%))] p-1 shadow-[var(--ui-control-shadow)] [border-width:var(--ui-input-border-width)]" + ], + { + variants: { + orientation: { + horizontal: "flex-row", + vertical: "flex-col items-stretch" + } + }, + defaultVariants: { + orientation: "horizontal" + } + } +); + +export const segmentedControlItemVariants = cva([ + "relative isolate inline-flex min-w-[5.5rem] cursor-pointer items-center justify-center gap-2 overflow-hidden whitespace-nowrap rounded-[calc(var(--ui-control-radius)+0.1rem)] px-3.5 py-2 text-sm font-medium outline-none", + "text-[var(--color-muted-foreground)] transition-[color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]", + "focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]", + "data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45", + "hover:-translate-y-px hover:text-[var(--color-foreground)]", + "data-[state=checked]:text-[var(--color-foreground)]", + getMotionRecipeClassNames("ring") +]); + +export const segmentedControlIndicatorVariants = cva([ + "pointer-events-none absolute inset-0 rounded-[inherit] border", + "border-[color-mix(in_oklch,var(--color-primary)_16%,var(--ui-control-border))]", + "bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_74%,white_26%),color-mix(in_oklch,var(--color-surface-container-high)_72%,white_28%))]", + "shadow-[inset_0_1px_0_color-mix(in_oklch,white_58%,transparent),0_10px_22px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]" +]); + +export const segmentedControlLabelVariants = cva( + "relative z-[1] inline-flex items-center justify-center gap-2" +); diff --git a/packages/ui/src/components/sparkbar.test.tsx b/packages/ui/src/components/sparkbar.test.tsx new file mode 100644 index 0000000..491228e --- /dev/null +++ b/packages/ui/src/components/sparkbar.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Sparkbar } from "./sparkbar"; + +describe("Sparkbar", () => { + it("renders the root and bar slots with highlighted range emphasis", () => { + render( + + ); + + const sparkbar = screen.getByRole("img", { name: "Revenue trend" }); + const bars = sparkbar.querySelectorAll('[data-slot="bar"]'); + + expect(sparkbar).toHaveAttribute("data-slot", "root"); + expect(sparkbar).toHaveAttribute("data-bars", "4"); + expect(sparkbar).toHaveAttribute("data-columns", "2"); + expect(sparkbar).toHaveAttribute("data-state", "value"); + expect(sparkbar).toHaveAttribute("data-tone", "contrast"); + expect(sparkbar).toHaveAttribute("data-variant", "success"); + expect(bars).toHaveLength(4); + expect(bars[0]).toHaveAttribute("data-state", "inactive"); + expect(bars[1]).toHaveAttribute("data-state", "active"); + expect(bars[1]).toHaveAttribute("data-active"); + expect(bars[2]).toHaveAttribute("data-state", "active"); + expect(bars[3]).toHaveAttribute("data-state", "active"); + }); + + it("stays decorative by default and exposes a stable empty state", () => { + const { container } = render(); + + const sparkbar = container.querySelector('[data-slot="root"]'); + const bars = container.querySelectorAll('[data-slot="bar"]'); + + expect(sparkbar).toHaveAttribute("aria-hidden", "true"); + expect(sparkbar).not.toHaveAttribute("role"); + expect(sparkbar).toHaveAttribute("data-state", "empty"); + expect(bars).toHaveLength(3); + expect(bars[0]).toHaveAttribute("data-state", "empty"); + expect(bars[1]).toHaveAttribute("data-state", "empty"); + }); + + it("switches to an image-like accessibility contract when an accessible name is provided", () => { + render(); + + const sparkbar = screen.getByRole("img", { + name: "Revenue trend over the last six weeks" + }); + + expect(sparkbar).toHaveAttribute("aria-roledescription", "sparkbar"); + expect(sparkbar).not.toHaveAttribute("aria-hidden"); + }); + + it("uses the explicit height override as the bar height ceiling", () => { + const { container } = render(); + + const sparkbar = container.querySelector('[data-slot="root"]'); + + expect(sparkbar).toHaveAttribute("data-state", "empty"); + expect(sparkbar).toHaveStyle({ "--ui-sparkbar-bar-max-height": "64px" }); + expect(container.querySelectorAll('[data-slot="bar"]')).toHaveLength(0); + }); + + it("clamps values to the provided min and max range when calculating bar height", () => { + render( + + ); + + const sparkbar = screen.getByRole("img", { name: "Capacity bars" }); + const bars = sparkbar.querySelectorAll('[data-slot="bar"]'); + + expect(bars[0]).toHaveStyle({ + "--ui-sparkbar-bar-scale": + "calc(var(--ui-sparkbar-bar-min-ratio) + 0.0000 * (1 - var(--ui-sparkbar-bar-min-ratio)))" + }); + expect(bars[1]).toHaveStyle({ + "--ui-sparkbar-bar-scale": + "calc(var(--ui-sparkbar-bar-min-ratio) + 0.5000 * (1 - var(--ui-sparkbar-bar-min-ratio)))" + }); + expect(bars[2]).toHaveStyle({ + "--ui-sparkbar-bar-scale": + "calc(var(--ui-sparkbar-bar-min-ratio) + 1.0000 * (1 - var(--ui-sparkbar-bar-min-ratio)))" + }); + }); +}); diff --git a/packages/ui/src/components/sparkbar.tsx b/packages/ui/src/components/sparkbar.tsx new file mode 100644 index 0000000..93ccffc --- /dev/null +++ b/packages/ui/src/components/sparkbar.tsx @@ -0,0 +1,223 @@ +import { + forwardRef, + type ComponentPropsWithoutRef, + type CSSProperties +} from "react"; + +import { sparkbarBarVariants, sparkbarVariants } from "./sparkbar.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +function normalizeValue(value: number | null | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + + return Math.max(0, value); +} + +function getResolvedColumns(columns: number | undefined, barCount: number) { + if (!Number.isFinite(columns)) { + return Math.max(barCount, 1); + } + + return Math.max(1, Math.round(columns as number)); +} + +function getResolvedHighlightRange( + highlightRange: SparkbarHighlightRange | undefined, + barCount: number +) { + if (!highlightRange || barCount <= 0) { + return null; + } + + const [startIndex, endIndex] = highlightRange; + const clampedStart = Math.max(0, Math.min(barCount - 1, Math.round(startIndex))); + const clampedEnd = Math.max(0, Math.min(barCount - 1, Math.round(endIndex))); + + return clampedStart <= clampedEnd + ? [clampedStart, clampedEnd] + : [clampedEnd, clampedStart]; +} + +function getResolvedMinValue(minValue: number | undefined) { + if (typeof minValue === "number" && Number.isFinite(minValue)) { + return Math.max(0, minValue); + } + + return 0; +} + +function getResolvedMaxValue( + values: readonly (number | null)[], + minValue: number, + maxValue: number | undefined +) { + if (typeof maxValue === "number" && Number.isFinite(maxValue) && maxValue >= minValue) { + return maxValue; + } + + const computedMax = values.reduce((currentMax, value) => { + if (value == null) { + return currentMax; + } + + return Math.max(currentMax, value); + }, minValue); + + return computedMax > minValue ? computedMax : minValue; +} + +function getHeightValue(height: number | string | undefined) { + if (height == null) { + return undefined; + } + + return typeof height === "number" ? `${height}px` : height; +} + +function getBarScale(value: number | null, minValue: number, maxValue: number) { + if (value == null) { + return "var(--ui-sparkbar-bar-min-ratio)"; + } + + const clampedValue = Math.min(Math.max(value, minValue), maxValue); + const ratio = + maxValue > minValue ? (clampedValue - minValue) / (maxValue - minValue) : value > 0 ? 1 : 0; + + return `calc(var(--ui-sparkbar-bar-min-ratio) + ${ratio.toFixed(4)} * (1 - var(--ui-sparkbar-bar-min-ratio)))`; +} + +function hasAccessibleName({ + ariaLabel, + ariaLabelledBy +}: { + ariaLabel: string | undefined; + ariaLabelledBy: string | undefined; +}) { + return Boolean(ariaLabel || ariaLabelledBy); +} + +export type SparkbarHighlightRange = readonly [number, number]; + +export type SparkbarProps = Omit, "children"> & + VariantProps & + VariantProps & { + activeIndexes?: readonly number[]; + columns?: number; + height?: number | string; + highlightRange?: SparkbarHighlightRange; + maxValue?: number; + minValue?: number; + values: readonly (number | null | undefined)[]; + }; + +export const Sparkbar = forwardRef(function Sparkbar( + { + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + activeIndexes = [], + className, + columns, + height, + highlightRange, + maxValue, + minValue, + role, + size, + style, + tone, + values, + variant, + ...props + }, + ref +) { + const resolvedSize = size ?? "md"; + const resolvedTone = tone ?? "default"; + const resolvedVariant = variant ?? "default"; + const normalizedValues = values.map((value) => normalizeValue(value)); + const resolvedMinValue = getResolvedMinValue(minValue); + const resolvedMaxValue = getResolvedMaxValue(normalizedValues, resolvedMinValue, maxValue); + const resolvedColumns = getResolvedColumns(columns, normalizedValues.length); + const resolvedHighlightRange = getResolvedHighlightRange(highlightRange, normalizedValues.length); + const activeIndexSet = new Set( + activeIndexes + .filter((index) => Number.isFinite(index)) + .map((index) => Math.max(0, Math.round(index))) + ); + const state = + normalizedValues.length > 0 && normalizedValues.some((value) => value != null) + ? "value" + : "empty"; + const decorative = role == null && !hasAccessibleName({ ariaLabel, ariaLabelledBy }); + + return ( +
+ {normalizedValues.map((normalizedValue, index) => { + const active = + normalizedValue != null && + (activeIndexSet.has(index) || + (resolvedHighlightRange != null && + index >= resolvedHighlightRange[0] && + index <= resolvedHighlightRange[1])); + const barState = normalizedValue == null ? "empty" : active ? "active" : "inactive"; + + return ( +
+ ); +}); diff --git a/packages/ui/src/components/sparkbar.variants.ts b/packages/ui/src/components/sparkbar.variants.ts new file mode 100644 index 0000000..a02442e --- /dev/null +++ b/packages/ui/src/components/sparkbar.variants.ts @@ -0,0 +1,60 @@ +import { cva } from "../lib/cva"; +import { getMotionRecipeClassNames } from "../lib/motion"; + +export const sparkbarVariants = cva( + [ + "grid min-h-[var(--ui-sparkbar-bar-max-height)] w-full items-end gap-[var(--ui-sparkbar-gap)]", + getMotionRecipeClassNames("transition") + ], + { + variants: { + size: { + sm: + "[--ui-sparkbar-bar-max-height:var(--ui-sparkbar-height-sm)] [--ui-sparkbar-bar-min-height:0.25rem] [--ui-sparkbar-bar-min-ratio:0.083333] [--ui-sparkbar-gap:var(--ui-sparkbar-gap-sm)]", + md: + "[--ui-sparkbar-bar-max-height:var(--ui-sparkbar-height-md)] [--ui-sparkbar-bar-min-height:0.3125rem] [--ui-sparkbar-bar-min-ratio:0.073529] [--ui-sparkbar-gap:var(--ui-sparkbar-gap-md)]", + lg: + "[--ui-sparkbar-bar-max-height:var(--ui-sparkbar-height-lg)] [--ui-sparkbar-bar-min-height:0.375rem] [--ui-sparkbar-bar-min-ratio:0.075] [--ui-sparkbar-gap:var(--ui-sparkbar-gap-lg)]" + }, + tone: { + default: + "[--ui-sparkbar-inactive-bg:var(--ui-sparkbar-inactive-default-bg)] [--ui-sparkbar-inactive-shadow:var(--ui-sparkbar-inactive-default-shadow)]", + subtle: + "[--ui-sparkbar-inactive-bg:var(--ui-sparkbar-inactive-subtle-bg)] [--ui-sparkbar-inactive-shadow:var(--ui-sparkbar-inactive-subtle-shadow)]", + contrast: + "[--ui-sparkbar-inactive-bg:var(--ui-sparkbar-inactive-contrast-bg)] [--ui-sparkbar-inactive-shadow:var(--ui-sparkbar-inactive-contrast-shadow)]" + } + }, + defaultVariants: { + size: "md", + tone: "default" + } + } +); + +export const sparkbarBarVariants = cva( + [ + "relative min-w-0 self-end h-[var(--ui-sparkbar-bar-max-height)] overflow-hidden rounded-[var(--ui-sparkbar-bar-radius)]", + "transition-[opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-emphasized)] motion-reduce:transition-none", + "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-full before:origin-bottom before:rounded-[inherit] before:[background:var(--ui-sparkbar-bar-bg,var(--ui-sparkbar-active-bg))] before:shadow-[var(--ui-sparkbar-bar-shadow,var(--ui-sparkbar-active-shadow))] before:transition-[transform,background,box-shadow,opacity] before:duration-[var(--dur-base)] before:ease-[var(--ease-emphasized)] before:will-change-transform before:content-[''] motion-reduce:before:transition-none", + "before:[transform:scaleY(var(--ui-sparkbar-bar-scale,1))]", + "data-[state=active]:-translate-y-px data-[state=inactive]:opacity-92 data-[state=empty]:opacity-30 data-[state=empty]:before:opacity-76" + ], + { + variants: { + variant: { + default: + "[--ui-sparkbar-active-bg:var(--ui-sparkbar-active-default-bg)] [--ui-sparkbar-active-shadow:var(--ui-sparkbar-active-default-shadow)]", + success: + "[--ui-sparkbar-active-bg:var(--ui-sparkbar-active-success-bg)] [--ui-sparkbar-active-shadow:var(--ui-sparkbar-active-success-shadow)]", + warning: + "[--ui-sparkbar-active-bg:var(--ui-sparkbar-active-warning-bg)] [--ui-sparkbar-active-shadow:var(--ui-sparkbar-active-warning-shadow)]", + destructive: + "[--ui-sparkbar-active-bg:var(--ui-sparkbar-active-destructive-bg)] [--ui-sparkbar-active-shadow:var(--ui-sparkbar-active-destructive-shadow)]" + } + }, + defaultVariants: { + variant: "default" + } + } +); diff --git a/packages/ui/src/components/stat-card.test.tsx b/packages/ui/src/components/stat-card.test.tsx new file mode 100644 index 0000000..3b67c8b --- /dev/null +++ b/packages/ui/src/components/stat-card.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + StatCard, + StatCardDelta, + StatCardDescription, + StatCardEyebrow, + StatCardHeader, + StatCardLabel, + StatCardMetric, + StatCardValue +} from "./stat-card"; + +describe("StatCard", () => { + it("renders the common KPI slots with default interactive metadata", () => { + render( + + + Revenue + Monthly recurring revenue + + + $101,820 + +8.4% + + Compared with the previous month. + + ); + + const card = screen.getByTestId("stat-card"); + + expect(card).toHaveAttribute("data-tone", "accent"); + expect(card).toHaveAttribute("data-interactive", ""); + expect(card.className).toContain("hover:-translate-y-1.5"); + expect(card.className).toContain("hover:scale-[1.012]"); + expect(card.className).toContain("motion-reduce:hover:translate-y-0"); + expect(screen.getByText("Revenue")).toHaveAttribute("data-slot", "eyebrow"); + expect(screen.getByText("Monthly recurring revenue")).toHaveAttribute("data-slot", "label"); + expect(screen.getByText("$101,820")).toHaveAttribute("data-slot", "value"); + expect(screen.getByText("$101,820").className).toContain("transition-[transform,color]"); + expect(screen.getByText("+8.4%")).toHaveAttribute("data-slot", "delta"); + expect(screen.getByText("+8.4%")).toHaveAttribute("data-tone", "success"); + expect(screen.getByText("+8.4%").className).toContain( + "transition-[transform,background-color,border-color,box-shadow,color]" + ); + expect(screen.getByText("Compared with the previous month.")).toHaveAttribute( + "data-slot", + "description" + ); + }); + + it("supports className overrides on header and value group slots", () => { + render( + + + Qualified pipeline + + + $82,450 + At risk + + + ); + + expect(screen.getByText("Qualified pipeline").closest('[data-slot="header"]')).toHaveClass( + "items-start" + ); + expect(screen.getByText("$82,450").closest('[data-slot="metric"]')).toHaveClass( + "justify-between" + ); + expect(screen.getByText("$82,450")).toHaveClass("text-left"); + }); + + it("allows interactive polish to be turned off explicitly", () => { + render( + + + Qualified pipeline + + + $82,450 + At risk + + + ); + + const card = screen.getByTestId("stat-card"); + + expect(card).not.toHaveAttribute("data-interactive"); + expect(card.className).not.toContain("hover:-translate-y-1.5"); + expect(screen.getByText("At risk")).toHaveAttribute("data-tone", "warning"); + }); +}); diff --git a/packages/ui/src/components/stat-card.tsx b/packages/ui/src/components/stat-card.tsx new file mode 100644 index 0000000..7bf79aa --- /dev/null +++ b/packages/ui/src/components/stat-card.tsx @@ -0,0 +1,141 @@ +import { forwardRef, type ComponentPropsWithoutRef } from "react"; + +import { + statCardDeltaVariants, + statCardDescriptionVariants, + statCardEyebrowVariants, + statCardHeaderVariants, + statCardLabelVariants, + statCardMetricVariants, + statCardValueVariants, + statCardVariants +} from "./stat-card.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +export type StatCardProps = ComponentPropsWithoutRef<"div"> & + VariantProps; + +export const StatCard = forwardRef(function StatCard( + { className, interactive = true, tone, ...props }, + ref +) { + return ( +
+ ); +}); + +export type StatCardHeaderProps = ComponentPropsWithoutRef<"div">; + +export const StatCardHeader = forwardRef( + function StatCardHeader({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type StatCardEyebrowProps = ComponentPropsWithoutRef<"p">; + +export const StatCardEyebrow = forwardRef( + function StatCardEyebrow({ className, ...props }, ref) { + return ( +

+ ); + } +); + +export type StatCardLabelProps = ComponentPropsWithoutRef<"h3">; + +export const StatCardLabel = forwardRef( + function StatCardLabel({ className, ...props }, ref) { + return ( +

+ ); + } +); + +export type StatCardMetricProps = ComponentPropsWithoutRef<"div">; + +export const StatCardMetric = forwardRef( + function StatCardMetric({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type StatCardValueProps = ComponentPropsWithoutRef<"p">; + +export const StatCardValue = forwardRef( + function StatCardValue({ className, ...props }, ref) { + return ( +

+ ); + } +); + +export type StatCardDeltaProps = ComponentPropsWithoutRef<"div"> & + VariantProps; + +export const StatCardDelta = forwardRef( + function StatCardDelta({ className, tone, ...props }, ref) { + return ( +

+ ); + } +); + +export type StatCardDescriptionProps = ComponentPropsWithoutRef<"p">; + +export const StatCardDescription = forwardRef< + HTMLParagraphElement, + StatCardDescriptionProps +>(function StatCardDescription({ className, ...props }, ref) { + return ( +

+ ); +}); diff --git a/packages/ui/src/components/stat-card.variants.ts b/packages/ui/src/components/stat-card.variants.ts new file mode 100644 index 0000000..1feeaa1 --- /dev/null +++ b/packages/ui/src/components/stat-card.variants.ts @@ -0,0 +1,81 @@ +import { cva } from "../lib/cva"; +import { getMotionRecipeClassNames } from "../lib/motion"; + +export const statCardVariants = cva( + [ + "group relative isolate overflow-hidden grid gap-5 rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]", + "px-5 py-5 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] sm:px-6 sm:py-6", + "before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-24 before:bg-[linear-gradient(180deg,color-mix(in_oklch,white_58%,transparent),transparent)] before:opacity-95 before:content-['']", + "after:pointer-events-none after:absolute after:right-[-2.5rem] after:top-[-2.25rem] after:size-28 after:rounded-full after:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_54%,transparent),transparent_68%)] after:opacity-70 after:content-['']", + "[&>*]:relative [&>*]:z-[1]", + getMotionRecipeClassNames("transition", "ring") + ], + { + variants: { + tone: { + default: + "border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] after:opacity-62", + subtle: + "border-[var(--ui-card-subtle-border)] bg-[var(--ui-card-subtle-bg)] shadow-[var(--ui-card-subtle-shadow)] after:opacity-48", + accent: + "border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)] after:opacity-88" + }, + interactive: { + false: "", + true: + "hover:-translate-y-1.5 hover:scale-[1.012] hover:shadow-[var(--ui-card-hover-shadow)] hover:[&_[data-slot=value]]:-translate-y-px hover:[&_[data-slot=value]]:text-[color-mix(in_oklch,var(--color-foreground)_92%,var(--color-primary)_8%)] hover:[&_[data-slot=delta]]:-translate-y-px hover:[&_[data-slot=delta]]:shadow-[0_14px_24px_color-mix(in_oklch,var(--color-primary)_12%,transparent)] motion-reduce:hover:translate-y-0 motion-reduce:hover:scale-100 motion-reduce:hover:[&_[data-slot=value]]:translate-y-0 motion-reduce:hover:[&_[data-slot=delta]]:translate-y-0" + } + }, + defaultVariants: { + tone: "default", + interactive: true + } + } +); + +export const statCardHeaderVariants = cva("grid gap-2"); + +export const statCardEyebrowVariants = cva( + "text-[0.72rem] font-semibold uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]" +); + +export const statCardLabelVariants = cva( + "max-w-[20ch] text-base font-semibold leading-6 tracking-[var(--tracking-tight)] text-[var(--color-foreground)]" +); + +export const statCardMetricVariants = cva("flex flex-wrap items-end gap-3.5 sm:gap-4"); + +export const statCardValueVariants = cva( + "text-[clamp(2.2rem,4vw,3.2rem)] font-semibold leading-none tracking-[var(--tracking-tight)] text-[var(--color-foreground)] transition-[transform,color] duration-[var(--dur-base)] ease-[var(--ease-standard)]" +); + +export const statCardDeltaVariants = cva( + [ + "relative inline-flex min-h-7.5 items-center gap-1.5 rounded-[var(--radius-full)] border px-2.75 py-1 text-[0.72rem] font-semibold tracking-[0.01em]", + "before:size-1.5 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 before:content-['']", + "shadow-[var(--ui-control-shadow)] transition-[transform,background-color,border-color,box-shadow,color] duration-[var(--dur-base)] ease-[var(--ease-standard)]" + ], + { + variants: { + tone: { + neutral: + "border-[var(--ui-control-border)] bg-[color-mix(in_oklch,var(--ui-control-bg)_86%,white_14%)] text-[var(--color-muted-foreground)]", + primary: + "border-[color-mix(in_oklch,var(--color-primary)_28%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_72%,white_28%),color-mix(in_oklch,var(--color-primary)_8%,var(--color-card)))] text-[color-mix(in_oklch,var(--color-primary)_84%,var(--color-foreground))]", + success: + "border-[color-mix(in_oklch,var(--color-success)_30%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-success)_16%,white_84%),color-mix(in_oklch,var(--color-success)_12%,var(--color-card)))] text-[color-mix(in_oklch,var(--color-success)_76%,var(--color-foreground))]", + warning: + "border-[color-mix(in_oklch,var(--color-warning)_32%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-warning)_16%,white_84%),color-mix(in_oklch,var(--color-warning)_13%,var(--color-card)))] text-[color-mix(in_oklch,var(--color-warning)_78%,var(--color-foreground))]", + destructive: + "border-[color-mix(in_oklch,var(--color-destructive)_30%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-destructive)_14%,white_86%),color-mix(in_oklch,var(--color-destructive)_10%,var(--color-card)))] text-[var(--color-destructive)]" + } + }, + defaultVariants: { + tone: "neutral" + } + } +); + +export const statCardDescriptionVariants = cva( + "max-w-[32ch] text-sm leading-6 text-[var(--color-muted-foreground)]" +); diff --git a/packages/ui/src/components/value-field.test.tsx b/packages/ui/src/components/value-field.test.tsx new file mode 100644 index 0000000..ac96696 --- /dev/null +++ b/packages/ui/src/components/value-field.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Button } from "./button"; +import { Field, FieldControl, FieldDescription, FieldError } from "./field"; +import { Label } from "./label"; +import { + ValueField, + ValueFieldPrefix, + ValueFieldSuffix, + ValueFieldValue +} from "./value-field"; + +describe("ValueField", () => { + it("renders value and affix slots with grouped state attributes", () => { + render( + + + ORBT-7X92-KLL9-001P + + + + + ); + + const root = screen.getByText("ORBT-7X92-KLL9-001P").closest('[data-slot="root"]'); + const value = screen.getByText("ORBT-7X92-KLL9-001P").closest('[data-slot="value"]'); + + expect(root).toHaveAttribute("data-disabled", ""); + expect(root).toHaveAttribute("data-invalid", ""); + expect(root).toHaveAttribute("data-readonly", ""); + expect(root).toHaveAttribute("data-required", ""); + expect(root).toHaveAttribute("data-size", "lg"); + expect(screen.getByText("#").closest('[data-slot="prefix"]')).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "copy" }).closest('[data-slot="suffix"]')).toBeInTheDocument(); + expect(value).toHaveAttribute("data-readonly", ""); + expect(value).toHaveAttribute("data-size", "lg"); + expect(value).toHaveAttribute("aria-disabled", "true"); + expect(value).toHaveAttribute("aria-invalid", "true"); + }); + + it("inherits field ids and described-by wiring for the displayed value", () => { + render( + + + + + ORBT-7X92-KLL9-001P + + Use this code if scanning is unavailable. + Backup code must remain visible. + + + ); + + const value = screen.getByText("ORBT-7X92-KLL9-001P").closest('[data-slot="value"]'); + const label = screen.getByText("Manual backup code").closest("label"); + const description = screen.getByText("Use this code if scanning is unavailable."); + const error = screen.getByText("Backup code must remain visible."); + + expect(value).toHaveAttribute("id"); + expect(label).toHaveAttribute("for", value?.getAttribute("id")); + expect(value).toHaveAttribute("aria-describedby", `${description.id} ${error.id}`); + expect(value).toHaveAttribute("aria-invalid", "true"); + }); +}); diff --git a/packages/ui/src/components/value-field.tsx b/packages/ui/src/components/value-field.tsx new file mode 100644 index 0000000..eae7d6c --- /dev/null +++ b/packages/ui/src/components/value-field.tsx @@ -0,0 +1,155 @@ +import { + createContext, + forwardRef, + useContext, + type ComponentPropsWithoutRef +} from "react"; + +import { + valueFieldAffixVariants, + valueFieldValueVariants, + valueFieldVariants +} from "./value-field.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import type { FieldStateProps } from "../lib/contracts"; +import { createDataAttributes, createSlot } from "../lib/contracts"; +import { useFieldContext } from "./field"; + +type ValueFieldContextValue = { + disabled: boolean; + invalid: boolean; + readOnly: boolean; + required: boolean; + size: Exclude["size"], null | undefined>; +}; + +const ValueFieldContext = createContext(null); + +function useValueFieldContext() { + return useContext(ValueFieldContext); +} + +function mergeIds(...ids: Array) { + const value = ids.filter(Boolean).join(" ").trim(); + return value.length > 0 ? value : undefined; +} + +export type ValueFieldProps = ComponentPropsWithoutRef<"div"> & + FieldStateProps & + VariantProps; + +export const ValueField = forwardRef(function ValueField( + { + className, + disabled, + invalid, + readOnly, + required, + size = "md", + ...props + }, + ref +) { + const field = useFieldContext(); + const resolvedDisabled = disabled ?? field?.disabled ?? false; + const resolvedInvalid = invalid ?? field?.invalid ?? false; + const resolvedReadOnly = readOnly ?? field?.readOnly ?? true; + const resolvedRequired = required ?? field?.required ?? false; + const resolvedSize = size ?? "md"; + + return ( + +

+ + ); +}); + +export type ValueFieldValueProps = ComponentPropsWithoutRef<"output"> & + VariantProps; + +export const ValueFieldValue = forwardRef( + function ValueFieldValue({ className, id, size, ...props }, ref) { + const field = useFieldContext(); + const valueField = useValueFieldContext(); + const resolvedInvalid = field?.invalid ?? valueField?.invalid ?? false; + const resolvedDisabled = field?.disabled ?? valueField?.disabled ?? false; + const resolvedReadOnly = field?.readOnly ?? valueField?.readOnly ?? true; + const resolvedRequired = field?.required ?? valueField?.required ?? false; + const resolvedSize = size ?? valueField?.size ?? "md"; + + return ( + + ); + } +); + +export type ValueFieldPrefixProps = ComponentPropsWithoutRef<"div">; + +export const ValueFieldPrefix = forwardRef( + function ValueFieldPrefix({ className, ...props }, ref) { + return ( +
+ ); + } +); + +export type ValueFieldSuffixProps = ComponentPropsWithoutRef<"div">; + +export const ValueFieldSuffix = forwardRef( + function ValueFieldSuffix({ className, ...props }, ref) { + return ( +
+ ); + } +); diff --git a/packages/ui/src/components/value-field.variants.ts b/packages/ui/src/components/value-field.variants.ts new file mode 100644 index 0000000..67e0aeb --- /dev/null +++ b/packages/ui/src/components/value-field.variants.ts @@ -0,0 +1,48 @@ +import { cva } from "../lib/cva"; + +export const valueFieldVariants = cva( + [ + "flex w-full min-w-0 items-center overflow-hidden rounded-[var(--ui-input-radius)] border", + "bg-[var(--ui-input-readonly-bg)] text-[var(--color-foreground)] shadow-[var(--shadow-xs)]", + "[border-width:var(--ui-input-border-width)] border-[var(--color-border)]", + "transition-[border-color,box-shadow,background-color] duration-[var(--dur-base)] ease-[var(--ease-standard)]", + "data-[disabled]:cursor-not-allowed data-[disabled]:text-[var(--color-muted-foreground)] data-[disabled]:opacity-72", + "data-[invalid]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]", + "data-[invalid]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]" + ], + { + variants: { + size: { + sm: "min-h-10 gap-2.5 px-3", + md: "min-h-11 gap-3 px-4", + lg: "min-h-12 gap-3 px-4" + } + }, + defaultVariants: { + size: "md" + } + } +); + +export const valueFieldValueVariants = cva( + [ + "min-w-0 flex-1 truncate whitespace-nowrap bg-transparent text-[var(--color-foreground)]", + "data-[disabled]:text-[var(--color-muted-foreground)]" + ], + { + variants: { + size: { + sm: "text-sm", + md: "text-sm", + lg: "text-base" + } + }, + defaultVariants: { + size: "md" + } + } +); + +export const valueFieldAffixVariants = cva( + "flex shrink-0 items-center text-[var(--color-muted-foreground)] [&_svg]:size-4 [&_svg]:shrink-0" +); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 7ccb899..c78d66a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -96,6 +96,244 @@ export { cardTitleVariants, cardVariants } from "./components/card.variants"; +export { + MetricCard, + MetricCardActions, + MetricCardAside, + MetricCardDelta, + MetricCardDescription, + MetricCardEyebrow, + MetricCardFooter, + MetricCardHeader, + MetricCardLabel, + MetricCardLeading, + MetricCardMedia, + MetricCardMetric, + MetricCardValue, + type MetricCardActionsProps, + type MetricCardAsideProps, + type MetricCardDeltaProps, + type MetricCardDescriptionProps, + type MetricCardEyebrowProps, + type MetricCardFooterProps, + type MetricCardHeaderProps, + type MetricCardLabelProps, + type MetricCardLeadingProps, + type MetricCardMediaProps, + type MetricCardMetricProps, + type MetricCardProps, + type MetricCardValueProps +} from "./components/metric-card"; +export { + metricCardAsideVariants, + metricCardActionsVariants, + metricCardFooterVariants, + metricCardHeaderVariants, + metricCardLeadingVariants, + metricCardMediaVariants, + metricCardVariants +} from "./components/metric-card.variants"; +export { + Chart, + ChartChange, + ChartDescription, + ChartEyebrow, + ChartHeader, + ChartHeaderAside, + ChartHeaderLeading, + ChartMetrics, + ChartTitle, + ChartValue, + type ChartChangeProps, + type ChartDescriptionProps, + type ChartEyebrowProps, + type ChartHeaderAsideProps, + type ChartHeaderLeadingProps, + type ChartHeaderProps, + type ChartLegendValueMode, + type ChartMetricsProps, + type ChartProps, + type ChartSeries, + type ChartSeriesStyle, + type ChartSeriesTone, + type ChartTitleProps, + type ChartTooltipContext, + type ChartTooltipValue, + type ChartValueProps +} from "./components/chart"; +export { + chartCanvasVariants, + chartChangeVariants, + chartDescriptionVariants, + chartEmptyStateVariants, + chartEyebrowVariants, + chartHeaderAsideVariants, + chartHeaderLeadingVariants, + chartHeaderVariants, + chartLegendItemVariants, + chartLegendVariants, + chartMetricGroupVariants, + chartTitleVariants, + chartTooltipVariants, + chartValueVariants, + chartVariants +} from "./components/chart.variants"; +export { + AppShell, + AppShellBody, + AppShellFooter, + AppShellHeader, + AppShellMain, + AppShellSidebar, + type AppShellBodyProps, + type AppShellFooterProps, + type AppShellHeaderProps, + type AppShellMainProps, + type AppShellProps, + type AppShellSidebarProps +} from "./patterns/app-shell"; +export { + appShellBodyVariants, + appShellFooterVariants, + appShellHeaderVariants, + appShellMainVariants, + appShellSidebarVariants, + appShellVariants +} from "./patterns/app-shell.variants"; +export { + PageFooter, + PageFooterActions, + PageFooterDescription, + PageFooterLeading, + PageFooterMeta, + PageFooterTitle, + type PageFooterActionsProps, + type PageFooterDescriptionProps, + type PageFooterLeadingProps, + type PageFooterMetaProps, + type PageFooterProps, + type PageFooterTitleProps +} from "./patterns/page-footer"; +export { + pageFooterActionsVariants, + pageFooterDescriptionVariants, + pageFooterLeadingVariants, + pageFooterMetaVariants, + pageFooterTitleVariants, + pageFooterVariants +} from "./patterns/page-footer.variants"; +export { + PageHeader, + PageHeaderActions, + PageHeaderDescription, + PageHeaderEyebrow, + PageHeaderLeading, + PageHeaderMeta, + PageHeaderTitle, + type PageHeaderActionsProps, + type PageHeaderDescriptionProps, + type PageHeaderEyebrowProps, + type PageHeaderLeadingProps, + type PageHeaderMetaProps, + type PageHeaderProps, + type PageHeaderTitleProps +} from "./patterns/page-header"; +export { + pageHeaderActionsVariants, + pageHeaderDescriptionVariants, + pageHeaderEyebrowVariants, + pageHeaderLeadingVariants, + pageHeaderMetaVariants, + pageHeaderTitleVariants, + pageHeaderVariants +} from "./patterns/page-header.variants"; +export { + ChallengeProgress, + type ChallengeProgressItem, + type ChallengeProgressProps +} from "./patterns/challenge-progress"; +export { + challengeProgressBadgeIconVariants, + challengeProgressFooterVariants, + challengeProgressHeaderVariants, + challengeProgressIconVariants, + challengeProgressItemHeaderVariants, + challengeProgressItemVariants, + challengeProgressListVariants, + challengeProgressMaxVariants, + challengeProgressMeterVariants, + challengeProgressResultValueVariants, + challengeProgressResultVariants, + challengeProgressStatusVariants, + challengeProgressTargetValueVariants, + challengeProgressTargetVariants, + challengeProgressTitleVariants, + challengeProgressVariants +} from "./patterns/challenge-progress.variants"; +export { + SidebarNav, + SidebarNavContent, + SidebarNavFooter, + SidebarNavHeader, + SidebarNavItem, + SidebarNavItemBadge, + SidebarNavItemIcon, + SidebarNavItemLabel, + SidebarNavItems, + SidebarNavSection, + SidebarNavSectionLabel, + type SidebarNavContentProps, + type SidebarNavFooterProps, + type SidebarNavHeaderProps, + type SidebarNavItemBadgeProps, + type SidebarNavItemIconProps, + type SidebarNavItemLabelProps, + type SidebarNavItemProps, + type SidebarNavItemsProps, + type SidebarNavProps, + type SidebarNavSectionLabelProps, + type SidebarNavSectionProps +} from "./patterns/sidebar-nav"; +export { + sidebarNavContentVariants, + sidebarNavFooterVariants, + sidebarNavHeaderVariants, + sidebarNavItemBadgeVariants, + sidebarNavItemIconVariants, + sidebarNavItemLabelVariants, + sidebarNavItemsVariants, + sidebarNavItemVariants, + sidebarNavSectionLabelVariants, + sidebarNavSectionVariants, + sidebarNavVariants +} from "./patterns/sidebar-nav.variants"; +export { + WorkspaceToolbar, + WorkspaceToolbarActions, + WorkspaceToolbarContent, + WorkspaceToolbarFilters, + WorkspaceToolbarLeading, + WorkspaceToolbarSearch, + WorkspaceToolbarStatus, + type WorkspaceToolbarActionsProps, + type WorkspaceToolbarContentProps, + type WorkspaceToolbarFiltersProps, + type WorkspaceToolbarLeadingProps, + type WorkspaceToolbarProps, + type WorkspaceToolbarSearchProps, + type WorkspaceToolbarStatusProps +} from "./patterns/workspace-toolbar"; +export { + workspaceToolbarActionsVariants, + workspaceToolbarContentVariants, + workspaceToolbarFiltersVariants, + workspaceToolbarLeadingVariants, + workspaceToolbarSearchVariants, + workspaceToolbarStatusVariants, + workspaceToolbarVariants +} from "./patterns/workspace-toolbar.variants"; +export { Sparkbar, type SparkbarHighlightRange, type SparkbarProps } from "./components/sparkbar"; +export { sparkbarBarVariants, sparkbarVariants } from "./components/sparkbar.variants"; export { Checkbox, type CheckboxProps } from "./components/checkbox"; export { checkboxVariants } from "./components/checkbox.variants"; export { @@ -343,9 +581,65 @@ export { type FormMethods, type FormProps } from "./components/form"; +export { + Gauge, + type GaugeProps, + type GaugeValueFormatterContext +} from "./components/gauge"; +export { + gaugeCanvasVariants, + gaugeDescriptionVariants, + gaugeIndicatorVariants, + gaugeLabelVariants, + gaugeSvgVariants, + gaugeTickVariants, + gaugeTicksVariants, + gaugeTrackVariants, + gaugeValueVariants, + gaugeVariants +} from "./components/gauge.variants"; +export { + Col, + Row, + type ColProps, + type GridOffset, + type GridResponsiveValue, + type GridSpan, + type RowGap, + type RowProps +} from "./components/grid"; +export { colVariants, rowGapClasses, rowVariants, rowXGapClasses, rowYGapClasses } from "./components/grid.variants"; +export { + InputGroup, + InputGroupPrefix, + InputGroupSuffix, + type InputGroupPrefixProps, + type InputGroupProps, + type InputGroupSuffixProps +} from "./components/input-group"; export { Input, type InputProps } from "./components/input"; +export { + inputGroupAffixVariants, + inputGroupInputVariants, + inputGroupVariants +} from "./components/input-group.variants"; export { inputVariants } from "./components/input.variants"; export { Label, type LabelProps } from "./components/label"; +export { + ValueField, + ValueFieldPrefix, + ValueFieldSuffix, + ValueFieldValue, + type ValueFieldPrefixProps, + type ValueFieldProps, + type ValueFieldSuffixProps, + type ValueFieldValueProps +} from "./components/value-field"; +export { + valueFieldAffixVariants, + valueFieldValueVariants, + valueFieldVariants +} from "./components/value-field.variants"; export { Popover, PopoverAnchor, @@ -363,6 +657,8 @@ export { } from "./components/progress"; export { progressIndicatorVariants, + progressSegmentVariants, + progressSegmentsVariants, progressVariants } from "./components/progress.variants"; export { @@ -394,6 +690,16 @@ export { selectTriggerVariants, selectViewportVariants } from "./components/select.variants"; +export { + SegmentedControl, + SegmentedControlItem, + type SegmentedControlItemProps, + type SegmentedControlProps +} from "./components/segmented-control"; +export { + segmentedControlItemVariants, + segmentedControlVariants +} from "./components/segmented-control.variants"; export { Separator, type SeparatorProps } from "./components/separator"; export { separatorVariants } from "./components/separator.variants"; export { @@ -417,6 +723,34 @@ export { } from "./components/sheet.variants"; export { Skeleton, type SkeletonProps } from "./components/skeleton"; export { Spinner, type SpinnerProps } from "./components/spinner"; +export { + StatCard, + StatCardDelta, + StatCardDescription, + StatCardEyebrow, + StatCardHeader, + StatCardLabel, + StatCardMetric, + StatCardValue, + type StatCardDeltaProps, + type StatCardDescriptionProps, + type StatCardEyebrowProps, + type StatCardHeaderProps, + type StatCardLabelProps, + type StatCardMetricProps, + type StatCardProps, + type StatCardValueProps +} from "./components/stat-card"; +export { + statCardDeltaVariants, + statCardDescriptionVariants, + statCardEyebrowVariants, + statCardHeaderVariants, + statCardLabelVariants, + statCardMetricVariants, + statCardValueVariants, + statCardVariants +} from "./components/stat-card.variants"; export { Switch, type SwitchProps } from "./components/switch"; export { switchThumbVariants, diff --git a/packages/ui/src/lib/contracts.ts b/packages/ui/src/lib/contracts.ts index 4e57c41..a34c6b6 100644 --- a/packages/ui/src/lib/contracts.ts +++ b/packages/ui/src/lib/contracts.ts @@ -72,6 +72,10 @@ export const commonSlotNames = [ slot: "input", guidance: "Typed value entry element such as input or textarea." }, + { + slot: "value", + guidance: "Displayed or computed value content within a component." + }, { slot: "trigger", guidance: "Element that opens, closes, or toggles related content." @@ -80,9 +84,21 @@ export const commonSlotNames = [ slot: "content", guidance: "Popover, drawer, menu, dialog, or expandable content region." }, + { + slot: "item", + guidance: "Repeated child element inside a collection or layout container." + }, { slot: "icon", guidance: "Decorative or stateful icon container." + }, + { + slot: "prefix", + guidance: "Leading inline adornment attached to an input-like control." + }, + { + slot: "suffix", + guidance: "Trailing inline adornment attached to an input-like control." } ] as const; diff --git a/packages/ui/src/patterns/app-shell.test.tsx b/packages/ui/src/patterns/app-shell.test.tsx new file mode 100644 index 0000000..6467761 --- /dev/null +++ b/packages/ui/src/patterns/app-shell.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + AppShell, + AppShellBody, + AppShellFooter, + AppShellHeader, + AppShellMain, + AppShellSidebar +} from "./app-shell"; + +describe("AppShell", () => { + it("renders the shell regions and layout contract", () => { + render( + + Sidebar + + Header + Main + Footer + + + ); + + expect(screen.getByTestId("app-shell")).toHaveAttribute("data-layout", "sidebar"); + expect(screen.getByTestId("app-shell")).toHaveAttribute("data-sidebar-width", "xl"); + expect(screen.getByTestId("app-shell")).toHaveAttribute("data-surface", "panel"); + expect(screen.getByTestId("app-shell")).toHaveClass("motion-transition"); + expect(screen.getByText("Sidebar")).toHaveAttribute("data-slot", "sidebar"); + expect(screen.getByText("Header")).toHaveAttribute("data-slot", "header"); + expect(screen.getByText("Main")).toHaveAttribute("data-slot", "main"); + expect(screen.getByText("Footer")).toHaveAttribute("data-slot", "footer"); + }); +}); diff --git a/packages/ui/src/patterns/app-shell.tsx b/packages/ui/src/patterns/app-shell.tsx new file mode 100644 index 0000000..0e45c26 --- /dev/null +++ b/packages/ui/src/patterns/app-shell.tsx @@ -0,0 +1,107 @@ +import { forwardRef, type ComponentPropsWithoutRef } from "react"; + +import { + appShellBodyVariants, + appShellFooterVariants, + appShellHeaderVariants, + appShellMainVariants, + appShellSidebarVariants, + appShellVariants +} from "./app-shell.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +export type AppShellProps = ComponentPropsWithoutRef<"div"> & + VariantProps; + +export const AppShell = forwardRef(function AppShell( + { className, layout, sidebarWidth, surface, ...props }, + ref +) { + return ( +
+ ); +}); + +export type AppShellSidebarProps = ComponentPropsWithoutRef<"aside">; + +export const AppShellSidebar = forwardRef( + function AppShellSidebar({ className, ...props }, ref) { + return ( +