feat(ui): add analytics primitives and layout patterns

This commit is contained in:
2026-03-25 19:49:49 +08:00
parent cc1509d2f6
commit a5d75f42e9
63 changed files with 7751 additions and 2 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ default styling with its own tokens, motion recipes, and component contract.
## Current status ## Current status
- The foundation, token layer, authoring contract, Storybook docs, and unit coverage are in place. - 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 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 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. - 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.
@@ -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`.
@@ -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`.
@@ -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
@@ -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`.
@@ -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`
@@ -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`.
@@ -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
@@ -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.
@@ -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`.
@@ -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
@@ -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
@@ -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
@@ -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`
+227
View File
@@ -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<RevenueDatum>[] = [
{
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(
<Chart
data={rows}
defaultActiveIndex={1}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
header={
<ChartHeader>
<ChartHeaderLeading>
<ChartTitle>Sales Impact</ChartTitle>
<ChartDescription>
Revenue compared to gross margin over the current month set.
</ChartDescription>
</ChartHeaderLeading>
<ChartHeaderAside>
<ChartMetrics>
<ChartValue>{formatCurrency(96350)}</ChartValue>
<ChartChange>Live forecast</ChartChange>
</ChartMetrics>
</ChartHeaderAside>
</ChartHeader>
}
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(
<Chart
data={rows}
defaultActiveIndex={1}
description="Revenue compared to gross margin over the current month set."
getActiveLabel={(datum) => `${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(
<Chart
data={rows}
defaultActiveIndex={0}
getActiveLabel={(datum) => `${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(
<Chart
activeIndex={0}
data={rows}
getActiveLabel={(datum) => `${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(
<Chart
data={rows}
defaultActiveIndex={0}
getActiveLabel={(datum) => `${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(
<Chart
data={[]}
empty="No series are visible in the current scope."
getXAxisLabel={() => ""}
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"
);
});
});
File diff suppressed because it is too large Load Diff
@@ -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)]"
);
+131
View File
@@ -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(
<Gauge
description="Lead scoring remains inside the healthy forecast band."
label="Forecast confidence"
value={72}
/>
);
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(
<Gauge
label="Capacity utilization"
max={80}
shape="semi"
tickCount={7}
tone="accent"
value={40}
variant="warning"
/>
);
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(<Gauge aria-label="Pipeline health" tickCount={0} value={null} />);
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(<Gauge aria-label="Budget saturation" max={120} value={160} />);
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(<Gauge label="Forecast confidence" value={72} />);
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%");
});
});
+604
View File
@@ -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<GaugeShape, GaugeGeometry> = {
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<ComponentPropsWithoutRef<"div">, "children"> &
VariantProps<typeof gaugeVariants> & {
description?: ReactNode;
label?: ReactNode;
max?: number;
min?: number;
tickCount?: number;
value?: number | null;
valueFormatter?: (context: GaugeValueFormatterContext) => ReactNode;
};
export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(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<number | null>(() =>
motionIsDisabled()
? computedAriaValueNow ?? null
: computedAriaValueNow == null
? null
: resolvedMin
);
const [isAnimating, setIsAnimating] = useState(false);
const hasAnimatedRef = useRef(false);
const previousPercentageRef = useRef(percentage ?? 0);
const previousValueRef = useRef<number | null>(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 (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating,
ticks: resolvedTickCount,
tone: resolvedTone,
variant: resolvedVariant
})}
aria-describedby={ariaDescribedBy}
aria-label={computedAriaLabel}
aria-valuemax={resolvedMax}
aria-valuemin={resolvedMin}
aria-valuenow={computedAriaValueNow}
aria-valuetext={computedAriaValueText}
className={cn(
gaugeVariants({
shape: resolvedShape,
size: resolvedSize,
tone: resolvedTone,
variant: resolvedVariant
}),
className
)}
ref={ref}
role="meter"
>
<div
{...createSlot("canvas")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating,
ticks: resolvedTickCount,
tone: resolvedTone,
variant: resolvedVariant
})}
className={cn(gaugeCanvasVariants({ shape: resolvedShape }))}
>
<svg
{...createSlot("svg")}
aria-hidden="true"
className={cn(gaugeSvgVariants())}
viewBox={geometry.viewBox}
>
<defs>
<linearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" stopColor="var(--ui-gauge-indicator-start)" />
<stop offset="100%" stopColor="var(--ui-gauge-indicator-end)" />
</linearGradient>
</defs>
{ticks.length > 0 ? (
<g
{...createSlot("ticks")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
ticks: resolvedTickCount
})}
className={cn(gaugeTicksVariants())}
>
{ticks.map((tick) => (
<path
key={tick.index}
{...createSlot("tick")}
{...createDataAttributes({
active: tick.index < renderedActiveTickCount,
shape: resolvedShape,
size: resolvedSize,
state,
variant: resolvedVariant
})}
className={cn(gaugeTickVariants())}
d={tick.path}
strokeLinecap="round"
strokeWidth="2.25"
/>
))}
</g>
) : null}
<path
{...createSlot("track")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating
})}
className={cn(gaugeTrackVariants())}
d={trackPath}
strokeLinecap="round"
strokeWidth="10"
/>
<path
{...createSlot("indicator")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating,
variant: resolvedVariant
})}
className={cn(gaugeIndicatorVariants())}
d={trackPath}
pathLength={100}
stroke={`url(#${gradientId})`}
strokeDasharray={`${renderedPercentage} 100`}
strokeLinecap="round"
strokeWidth="10"
/>
</svg>
<div
{...createSlot("value")}
{...createDataAttributes({
state,
animating: isAnimating
})}
className={cn(gaugeValueVariants())}
>
{renderedFormattedValue}
</div>
</div>
{label ? (
<p
{...createSlot("label")}
className={cn(gaugeLabelVariants())}
>
{label}
</p>
) : null}
{description ? (
<p
{...createSlot("description")}
className={cn(gaugeDescriptionVariants())}
>
{description}
</p>
) : null}
</div>
);
});
@@ -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)]"
);
+52
View File
@@ -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(
<Row data-testid="row">
<Col data-testid="col">Content</Col>
</Row>
);
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(
<Row align="center" data-testid="row" gap="lg" xGap="xl" yGap="sm">
<Col>Metric</Col>
</Row>
);
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(
<Col
data-testid="col"
lg={{ offset: 2, span: 4 }}
offset={1}
span={6}
xxl="auto"
/>
);
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");
});
});
+191
View File
@@ -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<Record<GridStyleVarName, string>>;
const breakpointVars: Record<BreakpointKey, GridStyleVarName> = {
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<typeof rowVariants> & {
gap?: RowGap;
xGap?: RowGap;
yGap?: RowGap;
};
export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
{
align = "stretch",
className,
gap = "md",
xGap,
yGap,
...props
},
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
align,
gap
})}
className={cn(
rowVariants({ align }),
rowGapClasses[gap],
xGap ? rowXGapClasses[xGap] : undefined,
yGap ? rowYGapClasses[yGap] : undefined,
className
)}
ref={ref}
/>
);
});
export type ColProps = ComponentPropsWithoutRef<"div"> & {
offset?: GridOffset;
span?: GridSpan;
sm?: GridResponsiveValue;
md?: GridResponsiveValue;
lg?: GridResponsiveValue;
xl?: GridResponsiveValue;
xxl?: GridResponsiveValue;
};
export const Col = forwardRef<HTMLDivElement, ColProps>(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 (
<div
{...props}
{...createSlot("item")}
{...createDataAttributes({
offset: offset && offset > 0 ? offset : undefined,
span: span ?? "full"
})}
className={cn(colVariants(), className)}
ref={ref}
style={resolvedStyle}
/>
);
});
@@ -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))))))]"
]);
@@ -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(
<InputGroup disabled invalid readOnly required size="lg">
<InputGroupPrefix>
<span aria-hidden="true">#</span>
</InputGroupPrefix>
<Input aria-label="Search launches" />
<InputGroupSuffix>
<span>K</span>
</InputGroupSuffix>
</InputGroup>
);
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(
<Field invalid required>
<Label requiredIndicator>Lane search</Label>
<FieldControl>
<InputGroup>
<InputGroupPrefix aria-hidden="true">
<span>#</span>
</InputGroupPrefix>
<Input />
</InputGroup>
<FieldDescription>Find the right routing lane before queuing.</FieldDescription>
<FieldError>Search query is required.</FieldError>
</FieldControl>
</Field>
);
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", "");
});
});
+110
View File
@@ -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<VariantProps<typeof inputGroupVariants>["size"], null | undefined>;
};
const InputGroupContext = createContext<InputGroupContextValue | null>(null);
export function useInputGroupContext() {
return useContext(InputGroupContext);
}
export type InputGroupProps = ComponentPropsWithoutRef<"div"> &
FieldStateProps &
VariantProps<typeof inputGroupVariants>;
export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(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 (
<InputGroupContext.Provider
value={{
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readOnly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
}}
>
<div
{...props}
{...createSlot("control")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
})}
className={cn(inputGroupVariants({ size: resolvedSize }), className)}
ref={ref}
/>
</InputGroupContext.Provider>
);
});
export type InputGroupPrefixProps = ComponentPropsWithoutRef<"div">;
export const InputGroupPrefix = forwardRef<HTMLDivElement, InputGroupPrefixProps>(
function InputGroupPrefix({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("prefix")}
className={cn(inputGroupAffixVariants(), className)}
ref={ref}
/>
);
}
);
export type InputGroupSuffixProps = ComponentPropsWithoutRef<"div">;
export const InputGroupSuffix = forwardRef<HTMLDivElement, InputGroupSuffixProps>(
function InputGroupSuffix({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("suffix")}
className={cn(inputGroupAffixVariants(), className)}
ref={ref}
/>
);
}
);
@@ -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"
);
@@ -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(
<MetricCard data-testid="metric-card" interactive layout="split" tone="hero">
<MetricCardHeader>
<MetricCardLeading>
<MetricCardEyebrow>Runway lens</MetricCardEyebrow>
<MetricCardLabel>Operational cost reduction</MetricCardLabel>
</MetricCardLeading>
<MetricCardAside>
<MetricCardDelta tone="primary">42%</MetricCardDelta>
</MetricCardAside>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>$38,250</MetricCardValue>
<MetricCardDelta tone="success">+78</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription>Target progress across the active quarter.</MetricCardDescription>
<MetricCardMedia padding="flush" tone="accent">
<div>Chart surface</div>
</MetricCardMedia>
<MetricCardActions layout="stack">
<Button size="sm">Review forecast</Button>
</MetricCardActions>
<MetricCardFooter>
<div>Quarter target</div>
</MetricCardFooter>
</MetricCard>
);
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(
<MetricCard tone="inverse">
<MetricCardHeader className="items-start text-left">
<MetricCardLeading>
<MetricCardEyebrow>Revenue pulse</MetricCardEyebrow>
<MetricCardLabel>Revenue influence</MetricCardLabel>
</MetricCardLeading>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>31%</MetricCardValue>
<MetricCardDelta tone="primary">+9.2%</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription>
AI-assisted routing is improving close quality instead of just adding volume.
</MetricCardDescription>
</MetricCard>
);
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(
<MetricCard data-testid="metric-card" interactive={false}>
<MetricCardHeader>
<MetricCardLeading>
<MetricCardLabel>Forecast confidence</MetricCardLabel>
</MetricCardLeading>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>31%</MetricCardValue>
<MetricCardDelta tone="primary">+9.2%</MetricCardDelta>
</MetricCardMetric>
</MetricCard>
);
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)]");
});
});
+158
View File
@@ -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<typeof metricCardVariants>;
export const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(function MetricCard(
{ className, interactive, layout, tone, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({ interactive, layout, tone })}
className={cn(metricCardVariants({ interactive, layout, tone }), className)}
ref={ref}
/>
);
});
export type MetricCardHeaderProps = ComponentPropsWithoutRef<"div">;
export const MetricCardHeader = forwardRef<HTMLDivElement, MetricCardHeaderProps>(
function MetricCardHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(metricCardHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type MetricCardLeadingProps = ComponentPropsWithoutRef<"div">;
export const MetricCardLeading = forwardRef<HTMLDivElement, MetricCardLeadingProps>(
function MetricCardLeading({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("leading")}
className={cn(metricCardLeadingVariants(), className)}
ref={ref}
/>
);
}
);
export type MetricCardAsideProps = ComponentPropsWithoutRef<"div">;
export const MetricCardAside = forwardRef<HTMLDivElement, MetricCardAsideProps>(
function MetricCardAside({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("aside")}
className={cn(metricCardAsideVariants(), className)}
ref={ref}
/>
);
}
);
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<typeof metricCardMediaVariants>;
export const MetricCardMedia = forwardRef<HTMLDivElement, MetricCardMediaProps>(
function MetricCardMedia({ className, padding, tone, ...props }, ref) {
return (
<div
{...props}
{...createSlot("media")}
{...createDataAttributes({ padding, tone })}
className={cn(metricCardMediaVariants({ padding, tone }), className)}
ref={ref}
/>
);
}
);
export type MetricCardActionsProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof metricCardActionsVariants>;
export const MetricCardActions = forwardRef<HTMLDivElement, MetricCardActionsProps>(
function MetricCardActions({ className, layout, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
{...createDataAttributes({ layout })}
className={cn(metricCardActionsVariants({ layout }), className)}
ref={ref}
/>
);
}
);
export type MetricCardFooterProps = ComponentPropsWithoutRef<"div">;
export const MetricCardFooter = forwardRef<HTMLDivElement, MetricCardFooterProps>(
function MetricCardFooter({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(metricCardFooterVariants(), className)}
ref={ref}
/>
);
}
);
@@ -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");
@@ -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(
<SegmentedControl aria-label="Revenue lens" defaultValue="sales">
<SegmentedControlItem value="sales">Sales</SegmentedControlItem>
<SegmentedControlItem value="support">Support</SegmentedControlItem>
</SegmentedControl>
);
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(
<SegmentedControl aria-label="Forecast mode" onValueChange={onValueChange} value="sales">
<SegmentedControlItem value="sales">Sales</SegmentedControlItem>
<SegmentedControlItem value="forecast">Forecast</SegmentedControlItem>
</SegmentedControl>
);
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(
<SegmentedControl aria-label="Lane switcher" defaultValue="sales" orientation="vertical">
<SegmentedControlItem value="sales">Sales</SegmentedControlItem>
<SegmentedControlItem disabled value="support">
Support
</SegmentedControlItem>
</SegmentedControl>
);
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", "");
});
});
@@ -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<SegmentedControlMotionContextValue | null>(
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<typeof RadioGroupPrimitive.Root> &
VariantProps<typeof segmentedControlVariants>;
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 (
<SegmentedControlMotionContext.Provider
value={{ activeValue: currentValue ?? undefined, disableMotion }}
>
<LayoutGroup id={layoutGroupId}>
<RadioGroupPrimitive.Root
{...props}
{...createSlot("root")}
{...createDataAttributes({ orientation })}
className={cn(segmentedControlVariants({ orientation }), className)}
onValueChange={setCurrentValue}
orientation={orientation}
value={currentValue ?? undefined}
>
{children}
</RadioGroupPrimitive.Root>
</LayoutGroup>
</SegmentedControlMotionContext.Provider>
);
}
export type SegmentedControlItemProps =
ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>;
export const SegmentedControlItem = forwardRef<
ElementRef<typeof RadioGroupPrimitive.Item>,
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 (
<RadioGroupPrimitive.Item
{...props}
{...createSlot("control")}
{...createDataAttributes({ disabled })}
className={cn(segmentedControlItemVariants(), className)}
disabled={disabled}
ref={ref}
value={value}
>
{isActive && motionContext ? (
<motion.span
{...createSlot("indicator")}
aria-hidden="true"
className={segmentedControlIndicatorVariants()}
layoutId="active-pill"
transition={transition}
/>
) : null}
<span {...createSlot("label")} className={segmentedControlLabelVariants()}>
{children}
</span>
</RadioGroupPrimitive.Item>
);
});
@@ -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"
);
@@ -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(
<Sparkbar
aria-label="Revenue trend"
columns={2}
highlightRange={[1, 3]}
tone="contrast"
values={[12, 48, 24, 60]}
variant="success"
/>
);
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(<Sparkbar values={[null, undefined, null]} />);
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(<Sparkbar aria-label="Revenue trend over the last six weeks" values={[4, 8, 6, 10]} />);
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(<Sparkbar height={64} values={[]} />);
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(
<Sparkbar
aria-label="Capacity bars"
maxValue={80}
minValue={20}
values={[20, 50, 120]}
variant="warning"
/>
);
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)))"
});
});
});
+223
View File
@@ -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<number>((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<ComponentPropsWithoutRef<"div">, "children"> &
VariantProps<typeof sparkbarVariants> &
VariantProps<typeof sparkbarBarVariants> & {
activeIndexes?: readonly number[];
columns?: number;
height?: number | string;
highlightRange?: SparkbarHighlightRange;
maxValue?: number;
minValue?: number;
values: readonly (number | null | undefined)[];
};
export const Sparkbar = forwardRef<HTMLDivElement, SparkbarProps>(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 (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
bars: normalizedValues.length,
columns: resolvedColumns,
size: resolvedSize,
state,
tone: resolvedTone,
variant: resolvedVariant
})}
aria-hidden={decorative ? true : undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-roledescription={decorative || role != null ? undefined : "sparkbar"}
className={cn(sparkbarVariants({ size: resolvedSize, tone: resolvedTone }), className)}
ref={ref}
role={role ?? (decorative ? undefined : "img")}
style={
{
"--ui-sparkbar-bar-max-height": getHeightValue(height),
gridTemplateColumns: `repeat(${resolvedColumns}, minmax(0, 1fr))`,
...style
} as CSSProperties
}
>
{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 (
<span
key={`${index}-${normalizedValue ?? "empty"}`}
{...createSlot("bar")}
{...createDataAttributes({
active,
index,
state: barState,
value: normalizedValue ?? undefined
})}
aria-hidden="true"
className={cn(sparkbarBarVariants({ variant: resolvedVariant }))}
style={
{
"--ui-sparkbar-bar-bg": active ? undefined : "var(--ui-sparkbar-inactive-bg)",
"--ui-sparkbar-bar-shadow": active
? undefined
: "var(--ui-sparkbar-inactive-shadow)",
"--ui-sparkbar-bar-scale": getBarScale(
normalizedValue,
resolvedMinValue,
resolvedMaxValue
)
} as CSSProperties
}
/>
);
})}
</div>
);
});
@@ -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"
}
}
);
@@ -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(
<StatCard data-testid="stat-card" tone="accent">
<StatCardHeader>
<StatCardEyebrow>Revenue</StatCardEyebrow>
<StatCardLabel>Monthly recurring revenue</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$101,820</StatCardValue>
<StatCardDelta tone="success">+8.4%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>Compared with the previous month.</StatCardDescription>
</StatCard>
);
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(
<StatCard tone="subtle">
<StatCardHeader className="items-start text-left">
<StatCardLabel>Qualified pipeline</StatCardLabel>
</StatCardHeader>
<StatCardMetric className="justify-between">
<StatCardValue className="text-left">$82,450</StatCardValue>
<StatCardDelta tone="warning">At risk</StatCardDelta>
</StatCardMetric>
</StatCard>
);
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(
<StatCard data-testid="stat-card" interactive={false}>
<StatCardHeader>
<StatCardLabel>Qualified pipeline</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$82,450</StatCardValue>
<StatCardDelta tone="warning">At risk</StatCardDelta>
</StatCardMetric>
</StatCard>
);
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");
});
});
+141
View File
@@ -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<typeof statCardVariants>;
export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(function StatCard(
{ className, interactive = true, tone, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({ interactive, tone })}
className={cn(statCardVariants({ interactive, tone }), className)}
ref={ref}
/>
);
});
export type StatCardHeaderProps = ComponentPropsWithoutRef<"div">;
export const StatCardHeader = forwardRef<HTMLDivElement, StatCardHeaderProps>(
function StatCardHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(statCardHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardEyebrowProps = ComponentPropsWithoutRef<"p">;
export const StatCardEyebrow = forwardRef<HTMLParagraphElement, StatCardEyebrowProps>(
function StatCardEyebrow({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("eyebrow")}
className={cn(statCardEyebrowVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardLabelProps = ComponentPropsWithoutRef<"h3">;
export const StatCardLabel = forwardRef<HTMLHeadingElement, StatCardLabelProps>(
function StatCardLabel({ className, ...props }, ref) {
return (
<h3
{...props}
{...createSlot("label")}
className={cn(statCardLabelVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardMetricProps = ComponentPropsWithoutRef<"div">;
export const StatCardMetric = forwardRef<HTMLDivElement, StatCardMetricProps>(
function StatCardMetric({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("metric")}
className={cn(statCardMetricVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardValueProps = ComponentPropsWithoutRef<"p">;
export const StatCardValue = forwardRef<HTMLParagraphElement, StatCardValueProps>(
function StatCardValue({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("value")}
className={cn(statCardValueVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardDeltaProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof statCardDeltaVariants>;
export const StatCardDelta = forwardRef<HTMLDivElement, StatCardDeltaProps>(
function StatCardDelta({ className, tone, ...props }, ref) {
return (
<div
{...props}
{...createSlot("delta")}
{...createDataAttributes({ tone })}
className={cn(statCardDeltaVariants({ tone }), className)}
ref={ref}
/>
);
}
);
export type StatCardDescriptionProps = ComponentPropsWithoutRef<"p">;
export const StatCardDescription = forwardRef<
HTMLParagraphElement,
StatCardDescriptionProps
>(function StatCardDescription({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("description")}
className={cn(statCardDescriptionVariants(), className)}
ref={ref}
/>
);
});
@@ -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)]"
);
@@ -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(
<ValueField disabled invalid required size="lg">
<ValueFieldPrefix aria-hidden="true">#</ValueFieldPrefix>
<ValueFieldValue>ORBT-7X92-KLL9-001P</ValueFieldValue>
<ValueFieldSuffix>
<Button size="icon" type="button" variant="ghost">
copy
</Button>
</ValueFieldSuffix>
</ValueField>
);
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(
<Field invalid required>
<Label requiredIndicator>Manual backup code</Label>
<FieldControl>
<ValueField>
<ValueFieldValue>ORBT-7X92-KLL9-001P</ValueFieldValue>
</ValueField>
<FieldDescription>Use this code if scanning is unavailable.</FieldDescription>
<FieldError>Backup code must remain visible.</FieldError>
</FieldControl>
</Field>
);
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");
});
});
+155
View File
@@ -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<VariantProps<typeof valueFieldVariants>["size"], null | undefined>;
};
const ValueFieldContext = createContext<ValueFieldContextValue | null>(null);
function useValueFieldContext() {
return useContext(ValueFieldContext);
}
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
export type ValueFieldProps = ComponentPropsWithoutRef<"div"> &
FieldStateProps &
VariantProps<typeof valueFieldVariants>;
export const ValueField = forwardRef<HTMLDivElement, ValueFieldProps>(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 (
<ValueFieldContext.Provider
value={{
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readOnly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
}}
>
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
})}
className={cn(valueFieldVariants({ size: resolvedSize }), className)}
ref={ref}
/>
</ValueFieldContext.Provider>
);
});
export type ValueFieldValueProps = ComponentPropsWithoutRef<"output"> &
VariantProps<typeof valueFieldValueVariants>;
export const ValueFieldValue = forwardRef<HTMLOutputElement, ValueFieldValueProps>(
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 (
<output
{...props}
{...createSlot("value")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
})}
aria-describedby={mergeIds(
props["aria-describedby"],
field?.descriptionId,
resolvedInvalid ? field?.errorId : undefined
)}
aria-disabled={resolvedDisabled || undefined}
aria-invalid={resolvedInvalid || undefined}
className={cn(valueFieldValueVariants({ size: resolvedSize }), className)}
id={id ?? field?.inputId}
ref={ref}
/>
);
}
);
export type ValueFieldPrefixProps = ComponentPropsWithoutRef<"div">;
export const ValueFieldPrefix = forwardRef<HTMLDivElement, ValueFieldPrefixProps>(
function ValueFieldPrefix({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("prefix")}
className={cn(valueFieldAffixVariants(), className)}
ref={ref}
/>
);
}
);
export type ValueFieldSuffixProps = ComponentPropsWithoutRef<"div">;
export const ValueFieldSuffix = forwardRef<HTMLDivElement, ValueFieldSuffixProps>(
function ValueFieldSuffix({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("suffix")}
className={cn(valueFieldAffixVariants(), className)}
ref={ref}
/>
);
}
);
@@ -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"
);
+334
View File
@@ -96,6 +96,244 @@ export {
cardTitleVariants, cardTitleVariants,
cardVariants cardVariants
} from "./components/card.variants"; } 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 { Checkbox, type CheckboxProps } from "./components/checkbox";
export { checkboxVariants } from "./components/checkbox.variants"; export { checkboxVariants } from "./components/checkbox.variants";
export { export {
@@ -343,9 +581,65 @@ export {
type FormMethods, type FormMethods,
type FormProps type FormProps
} from "./components/form"; } 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 { Input, type InputProps } from "./components/input";
export {
inputGroupAffixVariants,
inputGroupInputVariants,
inputGroupVariants
} from "./components/input-group.variants";
export { inputVariants } from "./components/input.variants"; export { inputVariants } from "./components/input.variants";
export { Label, type LabelProps } from "./components/label"; 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 { export {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
@@ -363,6 +657,8 @@ export {
} from "./components/progress"; } from "./components/progress";
export { export {
progressIndicatorVariants, progressIndicatorVariants,
progressSegmentVariants,
progressSegmentsVariants,
progressVariants progressVariants
} from "./components/progress.variants"; } from "./components/progress.variants";
export { export {
@@ -394,6 +690,16 @@ export {
selectTriggerVariants, selectTriggerVariants,
selectViewportVariants selectViewportVariants
} from "./components/select.variants"; } 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 { Separator, type SeparatorProps } from "./components/separator";
export { separatorVariants } from "./components/separator.variants"; export { separatorVariants } from "./components/separator.variants";
export { export {
@@ -417,6 +723,34 @@ export {
} from "./components/sheet.variants"; } from "./components/sheet.variants";
export { Skeleton, type SkeletonProps } from "./components/skeleton"; export { Skeleton, type SkeletonProps } from "./components/skeleton";
export { Spinner, type SpinnerProps } from "./components/spinner"; 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 { Switch, type SwitchProps } from "./components/switch";
export { export {
switchThumbVariants, switchThumbVariants,
+16
View File
@@ -72,6 +72,10 @@ export const commonSlotNames = [
slot: "input", slot: "input",
guidance: "Typed value entry element such as input or textarea." guidance: "Typed value entry element such as input or textarea."
}, },
{
slot: "value",
guidance: "Displayed or computed value content within a component."
},
{ {
slot: "trigger", slot: "trigger",
guidance: "Element that opens, closes, or toggles related content." guidance: "Element that opens, closes, or toggles related content."
@@ -80,9 +84,21 @@ export const commonSlotNames = [
slot: "content", slot: "content",
guidance: "Popover, drawer, menu, dialog, or expandable content region." guidance: "Popover, drawer, menu, dialog, or expandable content region."
}, },
{
slot: "item",
guidance: "Repeated child element inside a collection or layout container."
},
{ {
slot: "icon", slot: "icon",
guidance: "Decorative or stateful icon container." 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; ] as const;
@@ -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(
<AppShell data-testid="app-shell" layout="sidebar" sidebarWidth="xl" surface="panel">
<AppShellSidebar>Sidebar</AppShellSidebar>
<AppShellBody>
<AppShellHeader>Header</AppShellHeader>
<AppShellMain>Main</AppShellMain>
<AppShellFooter>Footer</AppShellFooter>
</AppShellBody>
</AppShell>
);
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");
});
});
+107
View File
@@ -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<typeof appShellVariants>;
export const AppShell = forwardRef<HTMLDivElement, AppShellProps>(function AppShell(
{ className, layout, sidebarWidth, surface, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({ layout, "sidebar-width": sidebarWidth, surface })}
className={cn(appShellVariants({ layout, sidebarWidth, surface }), className)}
ref={ref}
/>
);
});
export type AppShellSidebarProps = ComponentPropsWithoutRef<"aside">;
export const AppShellSidebar = forwardRef<HTMLElement, AppShellSidebarProps>(
function AppShellSidebar({ className, ...props }, ref) {
return (
<aside
{...props}
{...createSlot("sidebar")}
className={cn(appShellSidebarVariants(), className)}
ref={ref}
/>
);
}
);
export type AppShellBodyProps = ComponentPropsWithoutRef<"div">;
export const AppShellBody = forwardRef<HTMLDivElement, AppShellBodyProps>(
function AppShellBody({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("body")}
className={cn(appShellBodyVariants(), className)}
ref={ref}
/>
);
}
);
export type AppShellHeaderProps = ComponentPropsWithoutRef<"div">;
export const AppShellHeader = forwardRef<HTMLDivElement, AppShellHeaderProps>(
function AppShellHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(appShellHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type AppShellMainProps = ComponentPropsWithoutRef<"main">;
export const AppShellMain = forwardRef<HTMLElement, AppShellMainProps>(function AppShellMain(
{ className, ...props },
ref
) {
return (
<main
{...props}
{...createSlot("main")}
className={cn(appShellMainVariants(), className)}
ref={ref}
/>
);
});
export type AppShellFooterProps = ComponentPropsWithoutRef<"div">;
export const AppShellFooter = forwardRef<HTMLDivElement, AppShellFooterProps>(
function AppShellFooter({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(appShellFooterVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,49 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const appShellVariants = cva(
[
"relative isolate grid gap-4 text-[var(--color-foreground)]",
"[&>[data-slot]]:relative [&>[data-slot]]:z-[1]",
"[&>[data-slot=sidebar]]:transition-[transform,box-shadow] [&>[data-slot=sidebar]]:duration-[var(--dur-base)] [&>[data-slot=sidebar]]:ease-[var(--ease-standard)]",
"[&>[data-slot=body]]:transition-[transform,box-shadow] [&>[data-slot=body]]:duration-[var(--dur-base)] [&>[data-slot=body]]:ease-[var(--ease-standard)]",
"focus-within:[&>[data-slot=sidebar]]:-translate-y-px focus-within:[&>[data-slot=body]]:-translate-y-px",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
surface: {
default: "",
panel:
"overflow-hidden rounded-[2.35rem] border border-[color-mix(in_oklch,var(--color-outline-variant)_90%,white_10%)] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-surface)_84%,white_16%),color-mix(in_oklch,var(--color-surface-container-low)_90%,white_10%))] p-4 shadow-[0_28px_90px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] before:pointer-events-none before:absolute before:left-[7%] before:top-0 before:h-32 before:w-40 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_34%,transparent),transparent_72%)] before:opacity-72 before:blur-3xl before:content-[''] after:pointer-events-none after:absolute after:right-[4%] after:top-10 after:h-24 after:w-24 after:rounded-full after:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-tertiary-container)_24%,transparent),transparent_72%)] after:opacity-58 after:blur-3xl after:content-[''] focus-within:shadow-[0_34px_104px_color-mix(in_oklch,var(--color-primary)_12%,transparent)] lg:p-5"
},
layout: {
default: "",
sidebar: "xl:grid-cols-[18.5rem_minmax(0,1fr)]"
},
sidebarWidth: {
md: "xl:[grid-template-columns:16rem_minmax(0,1fr)]",
lg: "xl:[grid-template-columns:18.5rem_minmax(0,1fr)]",
xl: "xl:[grid-template-columns:20rem_minmax(0,1fr)]"
}
},
defaultVariants: {
surface: "default",
layout: "default"
}
}
);
export const appShellSidebarVariants = cva(
"min-w-0 self-start transition-[transform,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
);
export const appShellBodyVariants = cva(
"grid min-w-0 gap-4 transition-[transform,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
);
export const appShellHeaderVariants = cva("grid gap-4");
export const appShellMainVariants = cva("grid gap-4");
export const appShellFooterVariants = cva("grid gap-4");
@@ -0,0 +1,78 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ChallengeProgress } from "./challenge-progress";
describe("ChallengeProgress", () => {
it("renders the title, repeated rows, and progress semantics", () => {
render(
<ChallengeProgress
items={[
{
max: 8_000,
maxLabel: "$8,000",
resultValue: "$8,000",
statusLabel: "Passed",
statusTone: "success",
targetLabel: "Profit target",
targetValue: "$8,000",
value: 8_000,
variant: "success"
},
{
max: 10_000,
maxLabel: "$10,000",
progressLabel: "40%",
resultValue: "$4,000",
statusLabel: "Phase 2",
statusTone: "primary",
targetLabel: "Profit target",
targetValue: "$10,000",
value: 4_000
}
]}
title="Challenge progress"
/>
);
const root = screen.getByText("Challenge progress").closest('[data-slot="root"]');
const items = screen.getAllByText(/Profit target/).map((label) => label.closest('[data-slot="item"]'));
const completedProgress = screen.getByRole("progressbar", {
name: "Profit target $8,000 progress"
});
expect(root).toHaveAttribute("data-count", "2");
expect(screen.getByText("Challenge progress")).toHaveAttribute("data-slot", "title");
expect(items).toHaveLength(2);
expect(items[0]).toHaveAttribute("data-state", "complete");
expect(items[1]).toHaveAttribute("data-state", "loading");
expect(completedProgress).toHaveAttribute("data-pattern", "segmented");
expect(screen.getByText("Passed")).toHaveTextContent("Passed");
expect(screen.getByText("40%")).toHaveTextContent("40%");
});
it("falls back to indeterminate row state and pending label", () => {
render(
<ChallengeProgress
items={[
{
progressAriaLabel: "Review pending progress",
resultValue: "$0",
statusLabel: "Queued",
targetLabel: "Review target",
targetValue: "$5,000",
value: null
}
]}
title="Challenge progress"
/>
);
const progressbar = screen.getByRole("progressbar", { name: "Review pending progress" });
const item = progressbar.closest('[data-slot="item"]');
expect(item).toHaveAttribute("data-state", "indeterminate");
expect(progressbar).toHaveAttribute("data-state", "indeterminate");
expect(screen.getByText("Pending")).toHaveTextContent("Pending");
});
});
@@ -0,0 +1,277 @@
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from "react";
import {
challengeProgressBadgeIconVariants,
challengeProgressFooterVariants,
challengeProgressHeaderVariants,
challengeProgressIconVariants,
challengeProgressItemHeaderVariants,
challengeProgressItemVariants,
challengeProgressListVariants,
challengeProgressMaxVariants,
challengeProgressMeterVariants,
challengeProgressResultValueVariants,
challengeProgressResultVariants,
challengeProgressStatusVariants,
challengeProgressTargetValueVariants,
challengeProgressTargetVariants,
challengeProgressTitleVariants,
challengeProgressVariants
} from "./challenge-progress.variants";
import { Badge, type BadgeProps } from "../components/badge";
import { Progress, type ProgressProps } from "../components/progress";
import { Spinner } from "../components/spinner";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
function clampValue(value: number, max: number) {
return Math.min(Math.max(value, 0), max);
}
function getResolvedMax(max: number | undefined) {
return max && max > 0 ? max : 100;
}
function getItemState(value: number | null | undefined, max: number) {
if (value == null) {
return "indeterminate" as const;
}
return clampValue(value, max) >= max ? ("complete" as const) : ("loading" as const);
}
function getProgressLabel(value: number | null | undefined, max: number, progressLabel?: ReactNode) {
if (progressLabel !== undefined && progressLabel !== null) {
return progressLabel;
}
if (value == null) {
return "Pending";
}
return `${Math.round((clampValue(value, max) / max) * 100)}%`;
}
function getProgressVariant(
variant: ProgressProps["variant"] | undefined,
tone: BadgeProps["tone"] | undefined
): NonNullable<ProgressProps["variant"]> {
if (variant) {
return variant;
}
switch (tone) {
case "success":
return "success";
case "warning":
return "warning";
case "destructive":
return "destructive";
default:
return "default";
}
}
function getDefaultProgressAriaLabel(targetLabel: ReactNode, targetValue: ReactNode) {
if (typeof targetLabel === "string" && typeof targetValue === "string") {
return `${targetLabel} ${targetValue} progress`;
}
if (typeof targetLabel === "string") {
return `${targetLabel} progress`;
}
return "Challenge progress";
}
function StatusCheckIcon() {
return (
<svg aria-hidden="true" className="size-2.5" fill="none" viewBox="0 0 12 12">
<path
d="M2.5 6.25 4.75 8.5 9.5 3.75"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
/>
</svg>
);
}
function ProgressBadgeAdornment({ state }: { state: "complete" | "loading" | "indeterminate" }) {
if (state === "complete") {
return (
<span
{...createSlot("badge-icon")}
aria-hidden="true"
className={cn(
challengeProgressBadgeIconVariants(),
"bg-[color-mix(in_oklch,var(--color-success)_16%,white_84%)] text-[color-mix(in_oklch,var(--color-success)_72%,var(--color-foreground))]"
)}
>
<StatusCheckIcon />
</span>
);
}
if (state === "indeterminate") {
return (
<span
{...createSlot("badge-icon")}
aria-hidden="true"
className={cn(
challengeProgressBadgeIconVariants(),
"border border-[color-mix(in_oklch,var(--color-outline-variant)_78%,white_22%)] bg-[color-mix(in_oklch,var(--color-surface-container-highest)_76%,white_24%)]"
)}
/>
);
}
return <Spinner className="opacity-78" size="sm" tone="current" />;
}
export type ChallengeProgressItem = {
id?: string;
max?: number;
maxLabel?: ReactNode;
progressAriaLabel?: string;
progressLabel?: ReactNode;
progressTone?: BadgeProps["tone"];
progressVariant?: BadgeProps["variant"];
resultLabel?: ReactNode;
resultValue: ReactNode;
segmentCount?: number;
statusLabel: ReactNode;
statusTone?: BadgeProps["tone"];
statusVariant?: BadgeProps["variant"];
targetLabel: ReactNode;
targetValue: ReactNode;
tone?: ProgressProps["tone"];
value: number | null;
variant?: ProgressProps["variant"];
};
export type ChallengeProgressProps = ComponentPropsWithoutRef<"section"> & {
icon?: ReactNode;
items: readonly ChallengeProgressItem[];
title: ReactNode;
};
export const ChallengeProgress = forwardRef<HTMLElement, ChallengeProgressProps>(
function ChallengeProgress({ className, icon, items, title, ...props }, ref) {
return (
<section
{...props}
{...createSlot("root")}
{...createDataAttributes({ count: items.length })}
className={cn(challengeProgressVariants(), className)}
ref={ref}
>
<div {...createSlot("header")} className={challengeProgressHeaderVariants()}>
{icon ? (
<div {...createSlot("icon")} className={challengeProgressIconVariants()}>
{icon}
</div>
) : null}
<h3 {...createSlot("title")} className={challengeProgressTitleVariants()}>
{title}
</h3>
</div>
<div {...createSlot("list")} className={challengeProgressListVariants()}>
{items.map((item, index) => {
const resolvedMax = getResolvedMax(item.max);
const state = getItemState(item.value, resolvedMax);
const progressVariant = getProgressVariant(item.variant, item.statusTone);
const meterLabel = getProgressLabel(item.value, resolvedMax, item.progressLabel);
return (
<article
key={item.id ?? index}
{...createSlot("item")}
{...createDataAttributes({
state,
variant: progressVariant
})}
className={challengeProgressItemVariants({ state })}
>
<div
{...createSlot("item-header")}
className={challengeProgressItemHeaderVariants()}
>
<p {...createSlot("target")} className={challengeProgressTargetVariants()}>
{item.targetLabel}:{" "}
<span
{...createSlot("target-value")}
className={challengeProgressTargetValueVariants()}
>
{item.targetValue}
</span>
</p>
<div {...createSlot("status")} className={challengeProgressStatusVariants()}>
<Badge
className="rounded-[999px] px-3 py-1.5"
size="md"
tone={
item.statusTone ??
(state === "complete"
? "success"
: state === "indeterminate"
? "neutral"
: "primary")
}
variant={item.statusVariant ?? "subtle"}
>
{item.statusLabel}
</Badge>
<Badge
className="rounded-[999px] px-3 py-1.5 text-[color-mix(in_oklch,var(--color-muted-foreground)_90%,var(--color-foreground)_10%)]"
size="md"
tone={item.progressTone ?? "neutral"}
variant={item.progressVariant ?? "outline"}
>
<ProgressBadgeAdornment state={state} />
<span>{meterLabel}</span>
</Badge>
</div>
</div>
<div {...createSlot("meter")} className={challengeProgressMeterVariants()}>
<Progress
aria-label={
item.progressAriaLabel ??
getDefaultProgressAriaLabel(item.targetLabel, item.targetValue)
}
max={resolvedMax}
pattern="segmented"
segmentCount={item.segmentCount ?? 32}
size="lg"
tone={item.tone ?? "subtle"}
value={item.value}
variant={progressVariant}
/>
</div>
<div {...createSlot("footer")} className={challengeProgressFooterVariants()}>
<p {...createSlot("result")} className={challengeProgressResultVariants()}>
{item.resultLabel ?? "Result"}:{" "}
<span
{...createSlot("result-value")}
className={challengeProgressResultValueVariants()}
>
{item.resultValue}
</span>
</p>
<span {...createSlot("max")} className={challengeProgressMaxVariants()}>
{item.maxLabel ?? item.targetValue}
</span>
</div>
</article>
);
})}
</div>
</section>
);
}
);
@@ -0,0 +1,104 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const challengeProgressVariants = cva(
[
"grid gap-4 rounded-[2rem] border px-4 py-4 text-[var(--color-foreground)] sm:px-5 sm:py-5",
"border-[color-mix(in_oklch,var(--color-outline-variant)_82%,white_18%)]",
"bg-[radial-gradient(circle_at_top_left,color-mix(in_oklch,var(--color-primary-container)_52%,transparent),transparent_38%),linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_74%,white_26%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))]",
"shadow-[0_26px_72px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]",
getMotionRecipeClassNames("transition", "ring")
]
);
export const challengeProgressHeaderVariants = cva(
"flex items-center gap-3 px-1"
);
export const challengeProgressIconVariants = cva(
[
"flex size-11 shrink-0 items-center justify-center rounded-full border",
"border-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-border))]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_74%,white_26%),color-mix(in_oklch,var(--color-card)_82%,var(--color-primary-container)_18%))]",
"text-[var(--color-primary)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_64%,transparent),0_12px_24px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]"
]
);
export const challengeProgressTitleVariants = cva(
"[font-family:var(--font-display)] font-semibold tracking-[var(--tracking-tight)] text-[1.125rem] text-[var(--color-foreground)] sm:text-[1.25rem]"
);
export const challengeProgressListVariants = cva("grid gap-3");
export const challengeProgressItemVariants = cva(
[
"grid gap-4 rounded-[1.65rem] border px-4 py-4 shadow-[0_18px_36px_color-mix(in_oklch,var(--color-primary)_8%,transparent)]",
getMotionRecipeClassNames("transition")
],
{
variants: {
state: {
complete: [
"border-[color-mix(in_oklch,var(--color-success)_20%,var(--color-border))]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-tertiary-container)_56%,white_44%),color-mix(in_oklch,var(--color-card)_86%,var(--color-tertiary-container)_14%))]"
],
loading: [
"border-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-border))]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_42%,white_58%),color-mix(in_oklch,var(--color-card)_88%,var(--color-primary-container)_12%))]"
],
indeterminate: [
"border-[color-mix(in_oklch,var(--color-outline-variant)_84%,white_16%)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container)_78%,white_22%),color-mix(in_oklch,var(--color-card)_90%,var(--color-surface-container)_10%))]"
]
}
},
defaultVariants: {
state: "loading"
}
}
);
export const challengeProgressItemHeaderVariants = cva(
"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
);
export const challengeProgressTargetVariants = cva(
"text-sm leading-6 text-[var(--color-muted-foreground)]"
);
export const challengeProgressTargetValueVariants = cva(
"font-semibold text-[var(--color-foreground)]"
);
export const challengeProgressStatusVariants = cva(
"flex flex-wrap items-center gap-2 sm:justify-end"
);
export const challengeProgressBadgeIconVariants = cva(
"inline-flex size-4 shrink-0 items-center justify-center rounded-full"
);
export const challengeProgressMeterVariants = cva(
[
"rounded-[1.35rem] border p-1.5",
"border-[color-mix(in_oklch,var(--color-outline-variant)_84%,white_16%)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_80%,white_20%),color-mix(in_oklch,var(--color-surface)_84%,var(--color-surface-container-low)_16%))]",
"shadow-[inset_0_1px_0_color-mix(in_oklch,white_62%,transparent),0_12px_24px_color-mix(in_oklch,var(--color-primary)_8%,transparent)]"
]
);
export const challengeProgressFooterVariants = cva(
"flex items-center justify-between gap-4 text-sm leading-6"
);
export const challengeProgressResultVariants = cva(
"text-[var(--color-muted-foreground)]"
);
export const challengeProgressResultValueVariants = cva(
"font-semibold text-[var(--color-foreground)]"
);
export const challengeProgressMaxVariants = cva(
"font-medium text-[color-mix(in_oklch,var(--color-muted-foreground)_88%,var(--color-foreground)_12%)]"
);
@@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
PageFooter,
PageFooterActions,
PageFooterDescription,
PageFooterLeading,
PageFooterMeta,
PageFooterTitle
} from "./page-footer";
describe("PageFooter", () => {
it("renders the footer slots and tone contract", () => {
render(
<PageFooter data-testid="page-footer" tone="accent">
<PageFooterLeading>
<PageFooterMeta>Synced</PageFooterMeta>
<PageFooterTitle>Ready for review</PageFooterTitle>
<PageFooterDescription>All supporting signals are up to date.</PageFooterDescription>
</PageFooterLeading>
<PageFooterActions>
<button type="button">Open audit log</button>
</PageFooterActions>
</PageFooter>
);
expect(screen.getByTestId("page-footer")).toHaveAttribute("data-tone", "accent");
expect(screen.getByTestId("page-footer")).toHaveClass("motion-transition");
expect(screen.getByText("Synced")).toHaveAttribute("data-slot", "meta");
expect(screen.getByText("Ready for review")).toHaveAttribute("data-slot", "title");
expect(screen.getByText("All supporting signals are up to date.")).toHaveAttribute(
"data-slot",
"description"
);
expect(screen.getByRole("button", { name: "Open audit log" }).closest('[data-slot="actions"]')).toHaveClass(
"motion-safe:[&>*:hover]:-translate-y-px"
);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import {
pageFooterActionsVariants,
pageFooterDescriptionVariants,
pageFooterLeadingVariants,
pageFooterMetaVariants,
pageFooterTitleVariants,
pageFooterVariants
} from "./page-footer.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type PageFooterProps = ComponentPropsWithoutRef<"footer"> &
VariantProps<typeof pageFooterVariants>;
export const PageFooter = forwardRef<HTMLElement, PageFooterProps>(function PageFooter(
{ className, tone, ...props },
ref
) {
return (
<footer
{...props}
{...createSlot("root")}
{...createDataAttributes({ tone })}
className={cn(pageFooterVariants({ tone }), className)}
ref={ref}
/>
);
});
export type PageFooterLeadingProps = ComponentPropsWithoutRef<"div">;
export const PageFooterLeading = forwardRef<HTMLDivElement, PageFooterLeadingProps>(
function PageFooterLeading({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("leading")}
className={cn(pageFooterLeadingVariants(), className)}
ref={ref}
/>
);
}
);
export type PageFooterMetaProps = ComponentPropsWithoutRef<"div">;
export const PageFooterMeta = forwardRef<HTMLDivElement, PageFooterMetaProps>(
function PageFooterMeta({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("meta")}
className={cn(pageFooterMetaVariants(), className)}
ref={ref}
/>
);
}
);
export type PageFooterActionsProps = ComponentPropsWithoutRef<"div">;
export const PageFooterActions = forwardRef<HTMLDivElement, PageFooterActionsProps>(
function PageFooterActions({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
className={cn(pageFooterActionsVariants(), className)}
ref={ref}
/>
);
}
);
export type PageFooterTitleProps = ComponentPropsWithoutRef<"p">;
export const PageFooterTitle = forwardRef<HTMLParagraphElement, PageFooterTitleProps>(
function PageFooterTitle({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("title")}
className={cn(pageFooterTitleVariants(), className)}
ref={ref}
/>
);
}
);
export type PageFooterDescriptionProps = ComponentPropsWithoutRef<"p">;
export const PageFooterDescription = forwardRef<
HTMLParagraphElement,
PageFooterDescriptionProps
>(function PageFooterDescription({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("description")}
className={cn(pageFooterDescriptionVariants(), className)}
ref={ref}
/>
);
});
@@ -0,0 +1,46 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const pageFooterVariants = cva(
[
"relative isolate grid gap-4 overflow-hidden rounded-[calc(var(--ui-card-radius)-0.25rem)] border [border-width:var(--ui-card-border-width)]",
"px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.42)] sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center sm:px-5",
"before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-20 before:w-28 before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_24%,transparent),transparent_72%)] before:opacity-68 before:blur-3xl before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"motion-safe:hover:-translate-y-px focus-within:-translate-y-[0.5px] motion-reduce:hover:translate-y-0",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default:
"border-[color-mix(in_oklch,var(--color-border)_62%,transparent)] 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%))]",
subtle:
"border-[color-mix(in_oklch,var(--color-border)_52%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)]",
accent:
"border-[color-mix(in_oklch,var(--color-primary)_18%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_28%,white_72%),color-mix(in_oklch,var(--color-surface-container)_82%,white_18%))]"
}
},
defaultVariants: {
tone: "default"
}
}
);
export const pageFooterLeadingVariants = cva("grid gap-2");
export const pageFooterMetaVariants = cva(
"flex flex-wrap items-center gap-2.5 [&>*]:transition-[transform,box-shadow,background-color,border-color,opacity] [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)] motion-safe:[&>*:hover]:-translate-y-px"
);
export const pageFooterActionsVariants = cva(
"flex flex-wrap items-center gap-3 [&>*]:transition-[transform,box-shadow,background-color,border-color,opacity] [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)] motion-safe:[&>*:hover]:-translate-y-px"
);
export const pageFooterTitleVariants = cva(
"text-sm font-medium text-[var(--color-foreground)] sm:text-[0.95rem]"
);
export const pageFooterDescriptionVariants = cva(
"text-sm leading-6 text-[var(--color-muted-foreground)]"
);
@@ -0,0 +1,56 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderEyebrow,
PageHeaderLeading,
PageHeaderMeta,
PageHeaderTitle
} from "./page-header";
describe("PageHeader", () => {
it("renders the public header slots and root layout contract", () => {
render(
<PageHeader align="end" density="compact" variant="compact">
<PageHeaderLeading>
<PageHeaderMeta>
<PageHeaderEyebrow>Friday</PageHeaderEyebrow>
</PageHeaderMeta>
<PageHeaderTitle>Welcome back</PageHeaderTitle>
<PageHeaderDescription>Revenue operations is holding a calm pulse today.</PageHeaderDescription>
</PageHeaderLeading>
<PageHeaderActions>
<button type="button">Morning brief</button>
</PageHeaderActions>
</PageHeader>
);
expect(screen.getByText("Friday")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("Welcome back")).toHaveAttribute("data-slot", "title");
expect(screen.getByText("Welcome back").closest('[data-slot="root"]')).toHaveAttribute(
"data-variant",
"compact"
);
expect(screen.getByText("Welcome back").closest('[data-slot="root"]')).toHaveClass(
"motion-transition"
);
expect(screen.getByText("Welcome back").closest('[data-slot="root"]')).toHaveAttribute(
"data-density",
"compact"
);
expect(screen.getByText("Welcome back").closest('[data-slot="root"]')).toHaveAttribute(
"data-align",
"end"
);
expect(screen.getByText("Revenue operations is holding a calm pulse today.")).toHaveAttribute(
"data-slot",
"description"
);
expect(screen.getByRole("button", { name: "Morning brief" }).closest('[data-slot="actions"]')).toHaveClass(
"motion-safe:[&>*:hover]:-translate-y-px"
);
});
});
+203
View File
@@ -0,0 +1,203 @@
import {
createContext,
forwardRef,
useContext,
type ComponentPropsWithoutRef,
type CSSProperties
} from "react";
import {
pageHeaderActionsVariants,
pageHeaderDescriptionVariants,
pageHeaderEyebrowVariants,
pageHeaderLeadingVariants,
pageHeaderMetaVariants,
pageHeaderTitleVariants,
pageHeaderVariants
} from "./page-header.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
type PageHeaderContextValue = {
align: Exclude<VariantProps<typeof pageHeaderVariants>["align"], null | undefined>;
density: Exclude<VariantProps<typeof pageHeaderVariants>["density"], null | undefined>;
variant: Exclude<VariantProps<typeof pageHeaderVariants>["variant"], null | undefined>;
};
const PageHeaderContext = createContext<PageHeaderContextValue | null>(null);
function usePageHeaderContext() {
return useContext(PageHeaderContext);
}
export type PageHeaderProps = ComponentPropsWithoutRef<"section"> &
VariantProps<typeof pageHeaderVariants>;
export const PageHeader = forwardRef<HTMLElement, PageHeaderProps>(function PageHeader(
{
align = "start",
className,
density = "comfortable",
variant = "default",
...props
},
ref
) {
const resolvedAlign = align ?? "start";
const resolvedDensity = density ?? "comfortable";
const resolvedVariant = variant ?? "default";
return (
<PageHeaderContext.Provider
value={{
align: resolvedAlign,
density: resolvedDensity,
variant: resolvedVariant
}}
>
<section
{...props}
{...createSlot("root")}
{...createDataAttributes({
align: resolvedAlign,
density: resolvedDensity,
variant: resolvedVariant
})}
className={cn(
pageHeaderVariants({
align: resolvedAlign,
density: resolvedDensity,
variant: resolvedVariant
}),
className
)}
ref={ref}
/>
</PageHeaderContext.Provider>
);
});
export type PageHeaderLeadingProps = ComponentPropsWithoutRef<"div">;
export const PageHeaderLeading = forwardRef<HTMLDivElement, PageHeaderLeadingProps>(
function PageHeaderLeading({ className, ...props }, ref) {
const context = usePageHeaderContext();
return (
<div
{...props}
{...createSlot("leading")}
className={cn(
pageHeaderLeadingVariants({
density: context?.density,
variant: context?.variant
}),
className
)}
ref={ref}
/>
);
}
);
export type PageHeaderMetaProps = ComponentPropsWithoutRef<"div">;
export const PageHeaderMeta = forwardRef<HTMLDivElement, PageHeaderMetaProps>(
function PageHeaderMeta({ className, ...props }, ref) {
const context = usePageHeaderContext();
return (
<div
{...props}
{...createSlot("meta")}
className={cn(pageHeaderMetaVariants({ density: context?.density }), className)}
ref={ref}
/>
);
}
);
export type PageHeaderActionsProps = ComponentPropsWithoutRef<"div">;
export const PageHeaderActions = forwardRef<HTMLDivElement, PageHeaderActionsProps>(
function PageHeaderActions({ className, ...props }, ref) {
const context = usePageHeaderContext();
return (
<div
{...props}
{...createSlot("actions")}
className={cn(
pageHeaderActionsVariants({
align: context?.align,
density: context?.density
}),
className
)}
ref={ref}
/>
);
}
);
export type PageHeaderEyebrowProps = ComponentPropsWithoutRef<"p">;
export const PageHeaderEyebrow = forwardRef<HTMLParagraphElement, PageHeaderEyebrowProps>(
function PageHeaderEyebrow({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("eyebrow")}
className={cn(pageHeaderEyebrowVariants(), className)}
ref={ref}
/>
);
}
);
export type PageHeaderTitleProps = ComponentPropsWithoutRef<"h1">;
export const PageHeaderTitle = forwardRef<HTMLHeadingElement, PageHeaderTitleProps>(
function PageHeaderTitle({ className, style, ...props }, ref) {
const context = usePageHeaderContext();
const resolvedStyle: CSSProperties = {
fontFamily: "var(--font-display)",
...(style ?? {})
};
return (
<h1
{...props}
{...createSlot("title")}
className={cn(
pageHeaderTitleVariants({
density: context?.density,
variant: context?.variant
}),
className
)}
ref={ref}
style={resolvedStyle}
/>
);
}
);
export type PageHeaderDescriptionProps = ComponentPropsWithoutRef<"p">;
export const PageHeaderDescription = forwardRef<
HTMLParagraphElement,
PageHeaderDescriptionProps
>(function PageHeaderDescription({ className, ...props }, ref) {
const context = usePageHeaderContext();
return (
<p
{...props}
{...createSlot("description")}
className={cn(pageHeaderDescriptionVariants({ variant: context?.variant }), className)}
ref={ref}
/>
);
});
@@ -0,0 +1,141 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const pageHeaderVariants = cva(
[
"relative isolate grid",
"[&>*]:relative [&>*]:z-[1]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
variant: {
hero: [
"xl:grid-cols-[minmax(0,1fr)_auto]",
"before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-28 before:w-36 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_44%,transparent),transparent_72%)] before:opacity-80 before:blur-3xl before:content-['']"
],
default: [
"lg:grid-cols-[minmax(0,1fr)_auto]",
"before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-20 before:w-28 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_30%,transparent),transparent_72%)] before:opacity-72 before:blur-3xl before:content-['']"
],
compact:
"lg:grid-cols-[minmax(0,1fr)_auto] before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-16 before:w-20 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_18%,transparent),transparent_72%)] before:opacity-55 before:blur-3xl before:content-['']"
},
density: {
comfortable: "gap-4",
compact: "gap-3"
},
align: {
start: "lg:items-start",
end: "lg:items-end"
}
},
defaultVariants: {
variant: "default",
density: "comfortable",
align: "start"
}
}
);
export const pageHeaderLeadingVariants = cva("grid", {
variants: {
variant: {
hero: "max-w-4xl",
default: "max-w-3xl",
compact: "max-w-2xl"
},
density: {
comfortable: "gap-3",
compact: "gap-2"
}
},
defaultVariants: {
variant: "default",
density: "comfortable"
}
});
export const pageHeaderMetaVariants = cva(
[
"flex flex-wrap items-center",
"[&>*]:transition-[transform,box-shadow,background-color,border-color,opacity] [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)]",
"motion-safe:[&>*:hover]:-translate-y-px"
],
{
variants: {
density: {
comfortable: "gap-3",
compact: "gap-2"
}
},
defaultVariants: {
density: "comfortable"
}
}
);
export const pageHeaderActionsVariants = cva(
[
"flex flex-wrap items-center",
"[&>*]:transition-[transform,box-shadow,background-color,border-color,opacity] [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)]",
"motion-safe:[&>*:hover]:-translate-y-px"
],
{
variants: {
density: {
comfortable: "gap-3",
compact: "gap-2"
},
align: {
start: "justify-start lg:justify-self-start",
end: "justify-start lg:justify-self-end"
}
},
defaultVariants: {
density: "comfortable",
align: "start"
}
}
);
export const pageHeaderEyebrowVariants = cva(
"text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
);
export const pageHeaderTitleVariants = cva(
"font-semibold text-[var(--color-foreground)]",
{
variants: {
variant: {
hero: "text-[clamp(2.75rem,5vw,4.7rem)] leading-[0.96] tracking-[-0.06em]",
default: "text-[clamp(2rem,3.6vw,3.15rem)] leading-[1] tracking-[-0.045em]",
compact: "text-[clamp(1.45rem,2.2vw,2.1rem)] leading-[1.06] tracking-[-0.03em]"
},
density: {
comfortable: "",
compact: "leading-[1.04]"
}
},
defaultVariants: {
variant: "default",
density: "comfortable"
}
}
);
export const pageHeaderDescriptionVariants = cva(
"text-[var(--color-muted-foreground)]",
{
variants: {
variant: {
hero: "max-w-3xl text-[1.02rem] leading-7",
default: "max-w-2xl text-[0.98rem] leading-7",
compact: "max-w-xl text-sm leading-6"
}
},
defaultVariants: {
variant: "default"
}
}
);
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
SidebarNav,
SidebarNavContent,
SidebarNavFooter,
SidebarNavHeader,
SidebarNavItem,
SidebarNavItemBadge,
SidebarNavItemIcon,
SidebarNavItemLabel,
SidebarNavItems,
SidebarNavSection,
SidebarNavSectionLabel
} from "./sidebar-nav";
describe("SidebarNav", () => {
it("renders the public rail slots and active item contract", () => {
render(
<SidebarNav data-testid="sidebar-nav" tone="accent">
<SidebarNavHeader>Header</SidebarNavHeader>
<SidebarNavContent>
<SidebarNavSection>
<SidebarNavSectionLabel>Main</SidebarNavSectionLabel>
<SidebarNavItems>
<SidebarNavItem active>
<SidebarNavItemIcon>O</SidebarNavItemIcon>
<SidebarNavItemLabel>Overview</SidebarNavItemLabel>
<SidebarNavItemBadge>2</SidebarNavItemBadge>
</SidebarNavItem>
</SidebarNavItems>
</SidebarNavSection>
</SidebarNavContent>
<SidebarNavFooter>Footer</SidebarNavFooter>
</SidebarNav>
);
expect(screen.getByTestId("sidebar-nav")).toHaveAttribute("data-tone", "accent");
expect(screen.getByText("Header")).toHaveAttribute("data-slot", "header");
expect(screen.getByText("Main")).toHaveAttribute("data-slot", "section-label");
expect(screen.getByText("Overview").closest('[data-slot="item"]')).toHaveAttribute(
"data-active",
""
);
expect(screen.getByText("2")).toHaveAttribute("data-slot", "badge");
expect(screen.getByText("Footer")).toHaveAttribute("data-slot", "footer");
});
});
+202
View File
@@ -0,0 +1,202 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from "react";
import {
sidebarNavContentVariants,
sidebarNavFooterVariants,
sidebarNavHeaderVariants,
sidebarNavItemBadgeVariants,
sidebarNavItemIconVariants,
sidebarNavItemLabelVariants,
sidebarNavItemsVariants,
sidebarNavItemVariants,
sidebarNavSectionLabelVariants,
sidebarNavSectionVariants,
sidebarNavVariants
} from "./sidebar-nav.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot, type AsChildProp } from "../lib/contracts";
export type SidebarNavProps = ComponentPropsWithoutRef<"aside"> &
VariantProps<typeof sidebarNavVariants>;
export const SidebarNav = forwardRef<HTMLElement, SidebarNavProps>(function SidebarNav(
{ className, tone, ...props },
ref
) {
return (
<aside
{...props}
{...createSlot("root")}
{...createDataAttributes({ tone })}
className={cn(sidebarNavVariants({ tone }), className)}
ref={ref}
/>
);
});
export type SidebarNavHeaderProps = ComponentPropsWithoutRef<"div">;
export const SidebarNavHeader = forwardRef<HTMLDivElement, SidebarNavHeaderProps>(
function SidebarNavHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(sidebarNavHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavContentProps = ComponentPropsWithoutRef<"div">;
export const SidebarNavContent = forwardRef<HTMLDivElement, SidebarNavContentProps>(
function SidebarNavContent({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("content")}
className={cn(sidebarNavContentVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavFooterProps = ComponentPropsWithoutRef<"div">;
export const SidebarNavFooter = forwardRef<HTMLDivElement, SidebarNavFooterProps>(
function SidebarNavFooter({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(sidebarNavFooterVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavSectionProps = ComponentPropsWithoutRef<"section">;
export const SidebarNavSection = forwardRef<HTMLElement, SidebarNavSectionProps>(
function SidebarNavSection({ className, ...props }, ref) {
return (
<section
{...props}
{...createSlot("section")}
className={cn(sidebarNavSectionVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavSectionLabelProps = ComponentPropsWithoutRef<"p">;
export const SidebarNavSectionLabel = forwardRef<
HTMLParagraphElement,
SidebarNavSectionLabelProps
>(function SidebarNavSectionLabel({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("section-label")}
className={cn(sidebarNavSectionLabelVariants(), className)}
ref={ref}
/>
);
});
export type SidebarNavItemsProps = ComponentPropsWithoutRef<"div">;
export const SidebarNavItems = forwardRef<HTMLDivElement, SidebarNavItemsProps>(
function SidebarNavItems({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("items")}
className={cn(sidebarNavItemsVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavItemProps = Omit<ComponentPropsWithoutRef<"button">, "children"> &
AsChildProp &
VariantProps<typeof sidebarNavItemVariants> & {
children?: ReactNode;
};
export const SidebarNavItem = forwardRef<HTMLButtonElement, SidebarNavItemProps>(
function SidebarNavItem(
{ active, asChild = false, children, className, disabled, type, ...props },
ref
) {
const Component = asChild ? Slot : "button";
return (
<Component
{...props}
{...createSlot("item")}
{...createDataAttributes({ active, disabled })}
className={cn(sidebarNavItemVariants({ active }), className)}
disabled={asChild ? undefined : disabled}
ref={ref}
type={asChild ? undefined : type ?? "button"}
>
{asChild ? <Slottable>{children}</Slottable> : children}
</Component>
);
}
);
export type SidebarNavItemIconProps = ComponentPropsWithoutRef<"span">;
export const SidebarNavItemIcon = forwardRef<HTMLSpanElement, SidebarNavItemIconProps>(
function SidebarNavItemIcon({ className, ...props }, ref) {
return (
<span
{...props}
{...createSlot("icon")}
className={cn(sidebarNavItemIconVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavItemLabelProps = ComponentPropsWithoutRef<"span">;
export const SidebarNavItemLabel = forwardRef<HTMLSpanElement, SidebarNavItemLabelProps>(
function SidebarNavItemLabel({ className, ...props }, ref) {
return (
<span
{...props}
{...createSlot("label")}
className={cn(sidebarNavItemLabelVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavItemBadgeProps = ComponentPropsWithoutRef<"span">;
export const SidebarNavItemBadge = forwardRef<HTMLSpanElement, SidebarNavItemBadgeProps>(
function SidebarNavItemBadge({ className, ...props }, ref) {
return (
<span
{...props}
{...createSlot("badge")}
className={cn(sidebarNavItemBadgeVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,79 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const sidebarNavVariants = cva(
[
"relative isolate flex h-full flex-col gap-7 overflow-hidden rounded-[calc(var(--ui-card-radius)+0.55rem)] 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:left-6 before:top-0 before:h-24 before:w-24 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_48%,transparent),transparent_72%)] before:blur-3xl before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default:
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] shadow-[var(--ui-card-default-shadow)]",
subtle:
"border-[var(--ui-card-subtle-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_84%,white_16%),color-mix(in_oklch,var(--color-surface)_88%,white_12%))] 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)]"
}
},
defaultVariants: {
tone: "subtle"
}
}
);
export const sidebarNavHeaderVariants = cva("grid gap-5");
export const sidebarNavContentVariants = cva("grid gap-6");
export const sidebarNavFooterVariants = cva("mt-auto");
export const sidebarNavSectionVariants = cva("grid gap-2.5");
export const sidebarNavSectionLabelVariants = cva(
"px-2 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
);
export const sidebarNavItemsVariants = cva("grid gap-1.5");
export const sidebarNavItemVariants = cva(
[
"relative isolate flex min-h-12 w-full items-center gap-3 rounded-[1.1rem] px-3.5 py-3 text-left text-[0.95rem] outline-none",
"transition-[background-color,color,box-shadow,transform,border-color] duration-[var(--dur-base)] 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]:opacity-45",
"[&>[data-slot=icon]]:flex [&>[data-slot=icon]]:size-7 [&>[data-slot=icon]]:shrink-0 [&>[data-slot=icon]]:items-center [&>[data-slot=icon]]:justify-center [&>[data-slot=icon]]:rounded-[0.85rem]",
"[&>[data-slot=icon]]:transition-[background-color,color,transform] [&>[data-slot=icon]]:duration-[var(--dur-base)] [&>[data-slot=icon]]:ease-[var(--ease-standard)]",
"[&>[data-slot=label]]:min-w-0 [&>[data-slot=label]]:flex-1 [&>[data-slot=label]]:text-left",
"[&>[data-slot=badge]]:shrink-0"
],
{
variants: {
active: {
false: [
"text-[var(--color-muted-foreground)]",
"hover:-translate-y-px hover:bg-[color-mix(in_oklch,var(--color-surface)_88%,white_12%)] hover:text-[var(--color-foreground)]",
"[&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-surface)_88%,white_12%)] [&>[data-slot=icon]]:text-[var(--color-muted-foreground)]"
],
true: [
"bg-[var(--color-foreground)] text-[var(--color-background)] shadow-[0_20px_36px_color-mix(in_oklch,var(--color-foreground)_18%,transparent)]",
"hover:bg-[color-mix(in_oklch,var(--color-foreground)_94%,white_6%)]",
"[&>[data-slot=icon]]:bg-white/12 [&>[data-slot=icon]]:text-white"
]
}
},
defaultVariants: {
active: false
}
}
);
export const sidebarNavItemIconVariants = cva("");
export const sidebarNavItemLabelVariants = cva("truncate");
export const sidebarNavItemBadgeVariants = cva("inline-flex items-center");
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
WorkspaceToolbar,
WorkspaceToolbarActions,
WorkspaceToolbarContent,
WorkspaceToolbarFilters,
WorkspaceToolbarLeading,
WorkspaceToolbarSearch,
WorkspaceToolbarStatus
} from "./workspace-toolbar";
describe("WorkspaceToolbar", () => {
it("renders the public toolbar slots and surface contract", () => {
render(
<WorkspaceToolbar data-testid="workspace-toolbar" surface="panel">
<WorkspaceToolbarLeading>Release desk</WorkspaceToolbarLeading>
<WorkspaceToolbarContent>
<WorkspaceToolbarSearch>Search</WorkspaceToolbarSearch>
<WorkspaceToolbarFilters>Filters</WorkspaceToolbarFilters>
<WorkspaceToolbarStatus>Status</WorkspaceToolbarStatus>
<WorkspaceToolbarActions>
<button type="button">Export board</button>
</WorkspaceToolbarActions>
</WorkspaceToolbarContent>
</WorkspaceToolbar>
);
expect(screen.getByTestId("workspace-toolbar")).toHaveAttribute("data-surface", "panel");
expect(screen.getByText("Release desk")).toHaveAttribute("data-slot", "leading");
expect(screen.getByText("Search")).toHaveAttribute("data-slot", "search");
expect(screen.getByText("Filters")).toHaveAttribute("data-slot", "filters");
expect(screen.getByText("Status")).toHaveAttribute("data-slot", "status");
expect(
screen.getByRole("button", { name: "Export board" }).closest('[data-slot="actions"]')
).toBeInTheDocument();
});
});
@@ -0,0 +1,123 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import {
workspaceToolbarActionsVariants,
workspaceToolbarContentVariants,
workspaceToolbarFiltersVariants,
workspaceToolbarLeadingVariants,
workspaceToolbarSearchVariants,
workspaceToolbarStatusVariants,
workspaceToolbarVariants
} from "./workspace-toolbar.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type WorkspaceToolbarProps = ComponentPropsWithoutRef<"section"> &
VariantProps<typeof workspaceToolbarVariants>;
export const WorkspaceToolbar = forwardRef<HTMLElement, WorkspaceToolbarProps>(
function WorkspaceToolbar({ className, surface, ...props }, ref) {
return (
<section
{...props}
{...createSlot("root")}
{...createDataAttributes({ surface })}
className={cn(workspaceToolbarVariants({ surface }), className)}
ref={ref}
/>
);
}
);
export type WorkspaceToolbarLeadingProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarLeading = forwardRef<
HTMLDivElement,
WorkspaceToolbarLeadingProps
>(function WorkspaceToolbarLeading({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("leading")}
className={cn(workspaceToolbarLeadingVariants(), className)}
ref={ref}
/>
);
});
export type WorkspaceToolbarContentProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarContent = forwardRef<
HTMLDivElement,
WorkspaceToolbarContentProps
>(function WorkspaceToolbarContent({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("content")}
className={cn(workspaceToolbarContentVariants(), className)}
ref={ref}
/>
);
});
export type WorkspaceToolbarSearchProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarSearch = forwardRef<HTMLDivElement, WorkspaceToolbarSearchProps>(
function WorkspaceToolbarSearch({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("search")}
className={cn(workspaceToolbarSearchVariants(), className)}
ref={ref}
/>
);
}
);
export type WorkspaceToolbarFiltersProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarFilters = forwardRef<HTMLDivElement, WorkspaceToolbarFiltersProps>(
function WorkspaceToolbarFilters({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("filters")}
className={cn(workspaceToolbarFiltersVariants(), className)}
ref={ref}
/>
);
}
);
export type WorkspaceToolbarStatusProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarStatus = forwardRef<HTMLDivElement, WorkspaceToolbarStatusProps>(
function WorkspaceToolbarStatus({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("status")}
className={cn(workspaceToolbarStatusVariants(), className)}
ref={ref}
/>
);
}
);
export type WorkspaceToolbarActionsProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarActions = forwardRef<HTMLDivElement, WorkspaceToolbarActionsProps>(
function WorkspaceToolbarActions({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
className={cn(workspaceToolbarActionsVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,36 @@
import { cva } from "../lib/cva";
export const workspaceToolbarVariants = cva("grid gap-4 text-[var(--color-foreground)]", {
variants: {
surface: {
default: "",
panel:
"rounded-[calc(var(--ui-card-radius)-0.15rem)] border [border-width:var(--ui-card-border-width)] border-[color-mix(in_oklch,var(--color-outline-variant)_86%,white_14%)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] px-4 py-4 shadow-[var(--shadow-sm)] sm:px-5"
}
},
defaultVariants: {
surface: "default"
}
});
export const workspaceToolbarLeadingVariants = cva("grid max-w-3xl gap-1.5");
export const workspaceToolbarContentVariants = cva(
"flex min-w-0 flex-wrap items-center gap-3"
);
export const workspaceToolbarSearchVariants = cva(
"min-w-0 basis-full xl:max-w-xl xl:flex-[1_1_20rem]"
);
export const workspaceToolbarFiltersVariants = cva(
"flex flex-wrap items-center gap-3"
);
export const workspaceToolbarStatusVariants = cva(
"flex flex-wrap items-center gap-3"
);
export const workspaceToolbarActionsVariants = cva(
"flex flex-wrap items-center gap-3 xl:ml-auto"
);
+281
View File
@@ -237,6 +237,36 @@
"sourceVersion": "0.0.0", "sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui" "targetDirectory": "src/cadence-ui"
}, },
{
"description": "Source-owned Chart component.",
"displayName": "Chart",
"entrypoints": [
"packages/ui/src/components/chart.tsx"
],
"files": [
"packages/ui/src/components/chart.tsx",
"packages/ui/src/components/chart.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "chart",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"motion": "^12.38.0",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{ {
"description": "Source-owned Checkbox component.", "description": "Source-owned Checkbox component.",
"displayName": "Checkbox", "displayName": "Checkbox",
@@ -291,6 +321,7 @@
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"motion": "^12.38.0",
"react": "^18.3.1 || ^19.0.0", "react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
@@ -312,6 +343,10 @@
"packages/ui/src/components/command.variants.ts", "packages/ui/src/components/command.variants.ts",
"packages/ui/src/components/dialog.tsx", "packages/ui/src/components/dialog.tsx",
"packages/ui/src/components/dialog.variants.ts", "packages/ui/src/components/dialog.variants.ts",
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/input-group.tsx",
"packages/ui/src/components/input-group.variants.ts",
"packages/ui/src/components/label.tsx",
"packages/ui/src/lib/cn.ts", "packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts", "packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts", "packages/ui/src/lib/cva.ts",
@@ -325,6 +360,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"motion": "^12.38.0",
"react": "^18.3.1 || ^19.0.0", "react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
@@ -385,6 +421,8 @@
"packages/ui/src/components/empty-state.tsx", "packages/ui/src/components/empty-state.tsx",
"packages/ui/src/components/empty-state.variants.ts", "packages/ui/src/components/empty-state.variants.ts",
"packages/ui/src/components/field.tsx", "packages/ui/src/components/field.tsx",
"packages/ui/src/components/input-group.tsx",
"packages/ui/src/components/input-group.variants.ts",
"packages/ui/src/components/input.tsx", "packages/ui/src/components/input.tsx",
"packages/ui/src/components/input.variants.ts", "packages/ui/src/components/input.variants.ts",
"packages/ui/src/components/label.tsx", "packages/ui/src/components/label.tsx",
@@ -434,6 +472,8 @@
"packages/ui/src/components/date-picker.tsx", "packages/ui/src/components/date-picker.tsx",
"packages/ui/src/components/date-picker.variants.ts", "packages/ui/src/components/date-picker.variants.ts",
"packages/ui/src/components/field.tsx", "packages/ui/src/components/field.tsx",
"packages/ui/src/components/input-group.tsx",
"packages/ui/src/components/input-group.variants.ts",
"packages/ui/src/components/input.tsx", "packages/ui/src/components/input.tsx",
"packages/ui/src/components/input.variants.ts", "packages/ui/src/components/input.variants.ts",
"packages/ui/src/components/label.tsx", "packages/ui/src/components/label.tsx",
@@ -545,6 +585,7 @@
"packageDependencies": { "packageDependencies": {
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"motion": "^12.38.0",
"react": "^18.3.1 || ^19.0.0", "react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
@@ -610,6 +651,93 @@
"sourceVersion": "0.0.0", "sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui" "targetDirectory": "src/cadence-ui"
}, },
{
"description": "Source-owned Gauge component.",
"displayName": "Gauge",
"entrypoints": [
"packages/ui/src/components/gauge.tsx"
],
"files": [
"packages/ui/src/components/gauge.tsx",
"packages/ui/src/components/gauge.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "gauge",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Grid component.",
"displayName": "Grid",
"entrypoints": [
"packages/ui/src/components/grid.tsx"
],
"files": [
"packages/ui/src/components/grid.tsx",
"packages/ui/src/components/grid.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "grid",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Input Group component.",
"displayName": "Input Group",
"entrypoints": [
"packages/ui/src/components/input-group.tsx"
],
"files": [
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/input-group.tsx",
"packages/ui/src/components/input-group.variants.ts",
"packages/ui/src/components/label.tsx",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "input-group",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{ {
"description": "Source-owned Input component.", "description": "Source-owned Input component.",
"displayName": "Input", "displayName": "Input",
@@ -618,6 +746,8 @@
], ],
"files": [ "files": [
"packages/ui/src/components/field.tsx", "packages/ui/src/components/field.tsx",
"packages/ui/src/components/input-group.tsx",
"packages/ui/src/components/input-group.variants.ts",
"packages/ui/src/components/input.tsx", "packages/ui/src/components/input.tsx",
"packages/ui/src/components/input.variants.ts", "packages/ui/src/components/input.variants.ts",
"packages/ui/src/components/label.tsx", "packages/ui/src/components/label.tsx",
@@ -667,6 +797,37 @@
"sourceVersion": "0.0.0", "sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui" "targetDirectory": "src/cadence-ui"
}, },
{
"description": "Source-owned Metric Card component.",
"displayName": "Metric Card",
"entrypoints": [
"packages/ui/src/components/metric-card.tsx"
],
"files": [
"packages/ui/src/components/metric-card.tsx",
"packages/ui/src/components/metric-card.variants.ts",
"packages/ui/src/components/stat-card.tsx",
"packages/ui/src/components/stat-card.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "metric-card",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{ {
"description": "Source-owned Popover component.", "description": "Source-owned Popover component.",
"displayName": "Popover", "displayName": "Popover",
@@ -756,6 +917,37 @@
"sourceVersion": "0.0.0", "sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui" "targetDirectory": "src/cadence-ui"
}, },
{
"description": "Source-owned Segmented Control component.",
"displayName": "Segmented Control",
"entrypoints": [
"packages/ui/src/components/segmented-control.tsx"
],
"files": [
"packages/ui/src/components/segmented-control.tsx",
"packages/ui/src/components/segmented-control.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "segmented-control",
"packageDependencies": {
"@radix-ui/react-radio-group": "^1.3.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"motion": "^12.38.0",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{ {
"description": "Source-owned Select component.", "description": "Source-owned Select component.",
"displayName": "Select", "displayName": "Select",
@@ -875,6 +1067,35 @@
"sourceVersion": "0.0.0", "sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui" "targetDirectory": "src/cadence-ui"
}, },
{
"description": "Source-owned Sparkbar component.",
"displayName": "Sparkbar",
"entrypoints": [
"packages/ui/src/components/sparkbar.tsx"
],
"files": [
"packages/ui/src/components/sparkbar.tsx",
"packages/ui/src/components/sparkbar.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "sparkbar",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{ {
"description": "Source-owned Spinner component.", "description": "Source-owned Spinner component.",
"displayName": "Spinner", "displayName": "Spinner",
@@ -902,6 +1123,35 @@
"sourceVersion": "0.0.0", "sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui" "targetDirectory": "src/cadence-ui"
}, },
{
"description": "Source-owned Stat Card component.",
"displayName": "Stat Card",
"entrypoints": [
"packages/ui/src/components/stat-card.tsx"
],
"files": [
"packages/ui/src/components/stat-card.tsx",
"packages/ui/src/components/stat-card.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "stat-card",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{ {
"description": "Source-owned Switch component.", "description": "Source-owned Switch component.",
"displayName": "Switch", "displayName": "Switch",
@@ -952,6 +1202,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"motion": "^12.38.0",
"react": "^18.3.1 || ^19.0.0", "react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
@@ -1052,6 +1303,36 @@
"sourcePackage": "@ai-ui/ui", "sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0", "sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui" "targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Value Field component.",
"displayName": "Value Field",
"entrypoints": [
"packages/ui/src/components/value-field.tsx"
],
"files": [
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/label.tsx",
"packages/ui/src/components/value-field.tsx",
"packages/ui/src/components/value-field.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "value-field",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
} }
], ],
"library": { "library": {
+3 -1
View File
@@ -15,7 +15,7 @@ This document began as the build-out plan for the system. The current repo has a
completed most of that baseline work: completed most of that baseline work:
- phases 0 through 4 are effectively in place: workspace, tokens, authoring contract, core components, Storybook docs, and baseline tests - phases 0 through 4 are effectively in place: workspace, tokens, authoring contract, core components, Storybook docs, and baseline tests
- phase 5 has shipped its first advanced-pattern slice with `DataTable`, alongside `Command`, `Combobox`, `Sheet`, and `EmptyState` - phase 5 has shipped advanced patterns such as `Chart`, `DataTable`, `Command`, `Combobox`, `Sheet`, and `EmptyState`
- phase 6 now covers package-first release automation, fixed-version package publishing, and the optional source-copy registry flow - phase 6 now covers package-first release automation, fixed-version package publishing, and the optional source-copy registry flow
The next work is mostly hardening and distribution: The next work is mostly hardening and distribution:
@@ -342,6 +342,7 @@ Build higher-level patterns only after the base layer is stable.
Candidate patterns: Candidate patterns:
- `Form` - `Form`
- `Chart`
- `Data Table` - `Data Table`
- `Command` - `Command`
- `Combobox` - `Combobox`
@@ -353,6 +354,7 @@ Candidate patterns:
Current shipped patterns: Current shipped patterns:
- `Form` - `Form`
- `Chart`
- `Data Table` - `Data Table`
- `Command` - `Command`
- `Combobox` - `Combobox`