feat(ui): add analytics primitives and layout patterns
This commit is contained in:
@@ -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,
|
||||
cardVariants
|
||||
} from "./components/card.variants";
|
||||
export {
|
||||
MetricCard,
|
||||
MetricCardActions,
|
||||
MetricCardAside,
|
||||
MetricCardDelta,
|
||||
MetricCardDescription,
|
||||
MetricCardEyebrow,
|
||||
MetricCardFooter,
|
||||
MetricCardHeader,
|
||||
MetricCardLabel,
|
||||
MetricCardLeading,
|
||||
MetricCardMedia,
|
||||
MetricCardMetric,
|
||||
MetricCardValue,
|
||||
type MetricCardActionsProps,
|
||||
type MetricCardAsideProps,
|
||||
type MetricCardDeltaProps,
|
||||
type MetricCardDescriptionProps,
|
||||
type MetricCardEyebrowProps,
|
||||
type MetricCardFooterProps,
|
||||
type MetricCardHeaderProps,
|
||||
type MetricCardLabelProps,
|
||||
type MetricCardLeadingProps,
|
||||
type MetricCardMediaProps,
|
||||
type MetricCardMetricProps,
|
||||
type MetricCardProps,
|
||||
type MetricCardValueProps
|
||||
} from "./components/metric-card";
|
||||
export {
|
||||
metricCardAsideVariants,
|
||||
metricCardActionsVariants,
|
||||
metricCardFooterVariants,
|
||||
metricCardHeaderVariants,
|
||||
metricCardLeadingVariants,
|
||||
metricCardMediaVariants,
|
||||
metricCardVariants
|
||||
} from "./components/metric-card.variants";
|
||||
export {
|
||||
Chart,
|
||||
ChartChange,
|
||||
ChartDescription,
|
||||
ChartEyebrow,
|
||||
ChartHeader,
|
||||
ChartHeaderAside,
|
||||
ChartHeaderLeading,
|
||||
ChartMetrics,
|
||||
ChartTitle,
|
||||
ChartValue,
|
||||
type ChartChangeProps,
|
||||
type ChartDescriptionProps,
|
||||
type ChartEyebrowProps,
|
||||
type ChartHeaderAsideProps,
|
||||
type ChartHeaderLeadingProps,
|
||||
type ChartHeaderProps,
|
||||
type ChartLegendValueMode,
|
||||
type ChartMetricsProps,
|
||||
type ChartProps,
|
||||
type ChartSeries,
|
||||
type ChartSeriesStyle,
|
||||
type ChartSeriesTone,
|
||||
type ChartTitleProps,
|
||||
type ChartTooltipContext,
|
||||
type ChartTooltipValue,
|
||||
type ChartValueProps
|
||||
} from "./components/chart";
|
||||
export {
|
||||
chartCanvasVariants,
|
||||
chartChangeVariants,
|
||||
chartDescriptionVariants,
|
||||
chartEmptyStateVariants,
|
||||
chartEyebrowVariants,
|
||||
chartHeaderAsideVariants,
|
||||
chartHeaderLeadingVariants,
|
||||
chartHeaderVariants,
|
||||
chartLegendItemVariants,
|
||||
chartLegendVariants,
|
||||
chartMetricGroupVariants,
|
||||
chartTitleVariants,
|
||||
chartTooltipVariants,
|
||||
chartValueVariants,
|
||||
chartVariants
|
||||
} from "./components/chart.variants";
|
||||
export {
|
||||
AppShell,
|
||||
AppShellBody,
|
||||
AppShellFooter,
|
||||
AppShellHeader,
|
||||
AppShellMain,
|
||||
AppShellSidebar,
|
||||
type AppShellBodyProps,
|
||||
type AppShellFooterProps,
|
||||
type AppShellHeaderProps,
|
||||
type AppShellMainProps,
|
||||
type AppShellProps,
|
||||
type AppShellSidebarProps
|
||||
} from "./patterns/app-shell";
|
||||
export {
|
||||
appShellBodyVariants,
|
||||
appShellFooterVariants,
|
||||
appShellHeaderVariants,
|
||||
appShellMainVariants,
|
||||
appShellSidebarVariants,
|
||||
appShellVariants
|
||||
} from "./patterns/app-shell.variants";
|
||||
export {
|
||||
PageFooter,
|
||||
PageFooterActions,
|
||||
PageFooterDescription,
|
||||
PageFooterLeading,
|
||||
PageFooterMeta,
|
||||
PageFooterTitle,
|
||||
type PageFooterActionsProps,
|
||||
type PageFooterDescriptionProps,
|
||||
type PageFooterLeadingProps,
|
||||
type PageFooterMetaProps,
|
||||
type PageFooterProps,
|
||||
type PageFooterTitleProps
|
||||
} from "./patterns/page-footer";
|
||||
export {
|
||||
pageFooterActionsVariants,
|
||||
pageFooterDescriptionVariants,
|
||||
pageFooterLeadingVariants,
|
||||
pageFooterMetaVariants,
|
||||
pageFooterTitleVariants,
|
||||
pageFooterVariants
|
||||
} from "./patterns/page-footer.variants";
|
||||
export {
|
||||
PageHeader,
|
||||
PageHeaderActions,
|
||||
PageHeaderDescription,
|
||||
PageHeaderEyebrow,
|
||||
PageHeaderLeading,
|
||||
PageHeaderMeta,
|
||||
PageHeaderTitle,
|
||||
type PageHeaderActionsProps,
|
||||
type PageHeaderDescriptionProps,
|
||||
type PageHeaderEyebrowProps,
|
||||
type PageHeaderLeadingProps,
|
||||
type PageHeaderMetaProps,
|
||||
type PageHeaderProps,
|
||||
type PageHeaderTitleProps
|
||||
} from "./patterns/page-header";
|
||||
export {
|
||||
pageHeaderActionsVariants,
|
||||
pageHeaderDescriptionVariants,
|
||||
pageHeaderEyebrowVariants,
|
||||
pageHeaderLeadingVariants,
|
||||
pageHeaderMetaVariants,
|
||||
pageHeaderTitleVariants,
|
||||
pageHeaderVariants
|
||||
} from "./patterns/page-header.variants";
|
||||
export {
|
||||
ChallengeProgress,
|
||||
type ChallengeProgressItem,
|
||||
type ChallengeProgressProps
|
||||
} from "./patterns/challenge-progress";
|
||||
export {
|
||||
challengeProgressBadgeIconVariants,
|
||||
challengeProgressFooterVariants,
|
||||
challengeProgressHeaderVariants,
|
||||
challengeProgressIconVariants,
|
||||
challengeProgressItemHeaderVariants,
|
||||
challengeProgressItemVariants,
|
||||
challengeProgressListVariants,
|
||||
challengeProgressMaxVariants,
|
||||
challengeProgressMeterVariants,
|
||||
challengeProgressResultValueVariants,
|
||||
challengeProgressResultVariants,
|
||||
challengeProgressStatusVariants,
|
||||
challengeProgressTargetValueVariants,
|
||||
challengeProgressTargetVariants,
|
||||
challengeProgressTitleVariants,
|
||||
challengeProgressVariants
|
||||
} from "./patterns/challenge-progress.variants";
|
||||
export {
|
||||
SidebarNav,
|
||||
SidebarNavContent,
|
||||
SidebarNavFooter,
|
||||
SidebarNavHeader,
|
||||
SidebarNavItem,
|
||||
SidebarNavItemBadge,
|
||||
SidebarNavItemIcon,
|
||||
SidebarNavItemLabel,
|
||||
SidebarNavItems,
|
||||
SidebarNavSection,
|
||||
SidebarNavSectionLabel,
|
||||
type SidebarNavContentProps,
|
||||
type SidebarNavFooterProps,
|
||||
type SidebarNavHeaderProps,
|
||||
type SidebarNavItemBadgeProps,
|
||||
type SidebarNavItemIconProps,
|
||||
type SidebarNavItemLabelProps,
|
||||
type SidebarNavItemProps,
|
||||
type SidebarNavItemsProps,
|
||||
type SidebarNavProps,
|
||||
type SidebarNavSectionLabelProps,
|
||||
type SidebarNavSectionProps
|
||||
} from "./patterns/sidebar-nav";
|
||||
export {
|
||||
sidebarNavContentVariants,
|
||||
sidebarNavFooterVariants,
|
||||
sidebarNavHeaderVariants,
|
||||
sidebarNavItemBadgeVariants,
|
||||
sidebarNavItemIconVariants,
|
||||
sidebarNavItemLabelVariants,
|
||||
sidebarNavItemsVariants,
|
||||
sidebarNavItemVariants,
|
||||
sidebarNavSectionLabelVariants,
|
||||
sidebarNavSectionVariants,
|
||||
sidebarNavVariants
|
||||
} from "./patterns/sidebar-nav.variants";
|
||||
export {
|
||||
WorkspaceToolbar,
|
||||
WorkspaceToolbarActions,
|
||||
WorkspaceToolbarContent,
|
||||
WorkspaceToolbarFilters,
|
||||
WorkspaceToolbarLeading,
|
||||
WorkspaceToolbarSearch,
|
||||
WorkspaceToolbarStatus,
|
||||
type WorkspaceToolbarActionsProps,
|
||||
type WorkspaceToolbarContentProps,
|
||||
type WorkspaceToolbarFiltersProps,
|
||||
type WorkspaceToolbarLeadingProps,
|
||||
type WorkspaceToolbarProps,
|
||||
type WorkspaceToolbarSearchProps,
|
||||
type WorkspaceToolbarStatusProps
|
||||
} from "./patterns/workspace-toolbar";
|
||||
export {
|
||||
workspaceToolbarActionsVariants,
|
||||
workspaceToolbarContentVariants,
|
||||
workspaceToolbarFiltersVariants,
|
||||
workspaceToolbarLeadingVariants,
|
||||
workspaceToolbarSearchVariants,
|
||||
workspaceToolbarStatusVariants,
|
||||
workspaceToolbarVariants
|
||||
} from "./patterns/workspace-toolbar.variants";
|
||||
export { Sparkbar, type SparkbarHighlightRange, type SparkbarProps } from "./components/sparkbar";
|
||||
export { sparkbarBarVariants, sparkbarVariants } from "./components/sparkbar.variants";
|
||||
export { Checkbox, type CheckboxProps } from "./components/checkbox";
|
||||
export { checkboxVariants } from "./components/checkbox.variants";
|
||||
export {
|
||||
@@ -343,9 +581,65 @@ export {
|
||||
type FormMethods,
|
||||
type FormProps
|
||||
} from "./components/form";
|
||||
export {
|
||||
Gauge,
|
||||
type GaugeProps,
|
||||
type GaugeValueFormatterContext
|
||||
} from "./components/gauge";
|
||||
export {
|
||||
gaugeCanvasVariants,
|
||||
gaugeDescriptionVariants,
|
||||
gaugeIndicatorVariants,
|
||||
gaugeLabelVariants,
|
||||
gaugeSvgVariants,
|
||||
gaugeTickVariants,
|
||||
gaugeTicksVariants,
|
||||
gaugeTrackVariants,
|
||||
gaugeValueVariants,
|
||||
gaugeVariants
|
||||
} from "./components/gauge.variants";
|
||||
export {
|
||||
Col,
|
||||
Row,
|
||||
type ColProps,
|
||||
type GridOffset,
|
||||
type GridResponsiveValue,
|
||||
type GridSpan,
|
||||
type RowGap,
|
||||
type RowProps
|
||||
} from "./components/grid";
|
||||
export { colVariants, rowGapClasses, rowVariants, rowXGapClasses, rowYGapClasses } from "./components/grid.variants";
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupPrefix,
|
||||
InputGroupSuffix,
|
||||
type InputGroupPrefixProps,
|
||||
type InputGroupProps,
|
||||
type InputGroupSuffixProps
|
||||
} from "./components/input-group";
|
||||
export { Input, type InputProps } from "./components/input";
|
||||
export {
|
||||
inputGroupAffixVariants,
|
||||
inputGroupInputVariants,
|
||||
inputGroupVariants
|
||||
} from "./components/input-group.variants";
|
||||
export { inputVariants } from "./components/input.variants";
|
||||
export { Label, type LabelProps } from "./components/label";
|
||||
export {
|
||||
ValueField,
|
||||
ValueFieldPrefix,
|
||||
ValueFieldSuffix,
|
||||
ValueFieldValue,
|
||||
type ValueFieldPrefixProps,
|
||||
type ValueFieldProps,
|
||||
type ValueFieldSuffixProps,
|
||||
type ValueFieldValueProps
|
||||
} from "./components/value-field";
|
||||
export {
|
||||
valueFieldAffixVariants,
|
||||
valueFieldValueVariants,
|
||||
valueFieldVariants
|
||||
} from "./components/value-field.variants";
|
||||
export {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -363,6 +657,8 @@ export {
|
||||
} from "./components/progress";
|
||||
export {
|
||||
progressIndicatorVariants,
|
||||
progressSegmentVariants,
|
||||
progressSegmentsVariants,
|
||||
progressVariants
|
||||
} from "./components/progress.variants";
|
||||
export {
|
||||
@@ -394,6 +690,16 @@ export {
|
||||
selectTriggerVariants,
|
||||
selectViewportVariants
|
||||
} from "./components/select.variants";
|
||||
export {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
type SegmentedControlItemProps,
|
||||
type SegmentedControlProps
|
||||
} from "./components/segmented-control";
|
||||
export {
|
||||
segmentedControlItemVariants,
|
||||
segmentedControlVariants
|
||||
} from "./components/segmented-control.variants";
|
||||
export { Separator, type SeparatorProps } from "./components/separator";
|
||||
export { separatorVariants } from "./components/separator.variants";
|
||||
export {
|
||||
@@ -417,6 +723,34 @@ export {
|
||||
} from "./components/sheet.variants";
|
||||
export { Skeleton, type SkeletonProps } from "./components/skeleton";
|
||||
export { Spinner, type SpinnerProps } from "./components/spinner";
|
||||
export {
|
||||
StatCard,
|
||||
StatCardDelta,
|
||||
StatCardDescription,
|
||||
StatCardEyebrow,
|
||||
StatCardHeader,
|
||||
StatCardLabel,
|
||||
StatCardMetric,
|
||||
StatCardValue,
|
||||
type StatCardDeltaProps,
|
||||
type StatCardDescriptionProps,
|
||||
type StatCardEyebrowProps,
|
||||
type StatCardHeaderProps,
|
||||
type StatCardLabelProps,
|
||||
type StatCardMetricProps,
|
||||
type StatCardProps,
|
||||
type StatCardValueProps
|
||||
} from "./components/stat-card";
|
||||
export {
|
||||
statCardDeltaVariants,
|
||||
statCardDescriptionVariants,
|
||||
statCardEyebrowVariants,
|
||||
statCardHeaderVariants,
|
||||
statCardLabelVariants,
|
||||
statCardMetricVariants,
|
||||
statCardValueVariants,
|
||||
statCardVariants
|
||||
} from "./components/stat-card.variants";
|
||||
export { Switch, type SwitchProps } from "./components/switch";
|
||||
export {
|
||||
switchThumbVariants,
|
||||
|
||||
@@ -72,6 +72,10 @@ export const commonSlotNames = [
|
||||
slot: "input",
|
||||
guidance: "Typed value entry element such as input or textarea."
|
||||
},
|
||||
{
|
||||
slot: "value",
|
||||
guidance: "Displayed or computed value content within a component."
|
||||
},
|
||||
{
|
||||
slot: "trigger",
|
||||
guidance: "Element that opens, closes, or toggles related content."
|
||||
@@ -80,9 +84,21 @@ export const commonSlotNames = [
|
||||
slot: "content",
|
||||
guidance: "Popover, drawer, menu, dialog, or expandable content region."
|
||||
},
|
||||
{
|
||||
slot: "item",
|
||||
guidance: "Repeated child element inside a collection or layout container."
|
||||
},
|
||||
{
|
||||
slot: "icon",
|
||||
guidance: "Decorative or stateful icon container."
|
||||
},
|
||||
{
|
||||
slot: "prefix",
|
||||
guidance: "Leading inline adornment attached to an input-like control."
|
||||
},
|
||||
{
|
||||
slot: "suffix",
|
||||
guidance: "Trailing inline adornment attached to an input-like control."
|
||||
}
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
Reference in New Issue
Block a user