feat(ui): add analytics primitives and layout patterns
This commit is contained in:
@@ -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`
|
||||||
@@ -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)]"
|
||||||
|
);
|
||||||
@@ -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%");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)]"
|
||||||
|
);
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", "");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)))"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
@@ -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
@@ -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`
|
||||||
|
|||||||
Reference in New Issue
Block a user