feat(ui): add analytics primitives and layout patterns

This commit is contained in:
2026-03-25 19:49:49 +08:00
parent cc1509d2f6
commit a5d75f42e9
63 changed files with 7751 additions and 2 deletions
+227
View File
@@ -0,0 +1,227 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
Chart,
ChartChange,
ChartDescription,
ChartHeader,
ChartHeaderAside,
ChartHeaderLeading,
ChartMetrics,
ChartTitle,
ChartValue,
type ChartSeries
} from "./chart";
type RevenueDatum = {
gross: number;
month: string;
revenue: number;
};
const rows: RevenueDatum[] = [
{ gross: 61200, month: "Jan", revenue: 84200 },
{ gross: 73400, month: "Feb", revenue: 96350 },
{ gross: 82500, month: "Mar", revenue: 118420 },
{ gross: 78220, month: "Apr", revenue: 109860 }
];
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency"
}).format(value);
}
const series: ChartSeries<RevenueDatum>[] = [
{
getValue: (datum) => datum.revenue,
id: "revenue",
label: "Revenue",
style: "line-area",
tone: "primary",
valueFormatter: formatCurrency
},
{
getValue: (datum) => datum.gross,
id: "gross",
label: "Gross",
strokeDasharray: "6 8",
tone: "neutral",
valueFormatter: formatCurrency
}
];
describe("Chart", () => {
it("renders the composed header, plot, tooltip, and legend surfaces", () => {
const { container } = render(
<Chart
data={rows}
defaultActiveIndex={1}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
header={
<ChartHeader>
<ChartHeaderLeading>
<ChartTitle>Sales Impact</ChartTitle>
<ChartDescription>
Revenue compared to gross margin over the current month set.
</ChartDescription>
</ChartHeaderLeading>
<ChartHeaderAside>
<ChartMetrics>
<ChartValue>{formatCurrency(96350)}</ChartValue>
<ChartChange>Live forecast</ChartChange>
</ChartMetrics>
</ChartHeaderAside>
</ChartHeader>
}
series={series}
showLegend
title="Sales Impact"
/>
);
expect(screen.getByText("Sales Impact").closest('[data-slot="label"]')).toBeInTheDocument();
expect(
screen.getByText("Revenue compared to gross margin over the current month set.")
).toHaveAttribute("data-slot", "description");
const tooltip = document.querySelector('[data-slot="tooltip"]');
const legend = document.querySelector('[data-slot="legend"]');
expect(tooltip).not.toBeNull();
expect(legend).not.toBeNull();
expect(screen.getByText("Sales Impact").closest('[data-slot="leading"]')).toBeInTheDocument();
expect(screen.getByText("Live forecast").closest('[data-slot="aside"]')).toBeInTheDocument();
expect(within(tooltip as HTMLElement).getByText("Feb 2025")).toBeInTheDocument();
expect(within(legend as HTMLElement).getByText("Revenue").closest('[data-slot="item"]')).toBeInTheDocument();
expect(within(legend as HTMLElement).getByText("Gross")).toBeInTheDocument();
expect(container.querySelector('line[stroke-width="1.5"]')).toBeInTheDocument();
expect(container.querySelectorAll("svg circle")).toHaveLength(4);
});
it("keeps the legacy convenience header props working", () => {
const longValueChange =
"Legend values follow the active point when the chart is interactive.";
render(
<Chart
data={rows}
defaultActiveIndex={1}
description="Revenue compared to gross margin over the current month set."
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
series={series}
title="Legacy chart"
value={formatCurrency(96350)}
valueChange={longValueChange}
/>
);
expect(screen.getByText("Legacy chart")).toHaveAttribute("data-slot", "label");
expect(screen.getByText(longValueChange)).toHaveAttribute("data-slot", "delta");
expect(screen.getByText(longValueChange)).toHaveClass("max-w-full");
expect(
screen
.getAllByText(formatCurrency(96350))
.find((element) => element.getAttribute("data-slot") === "value")
).toBeInTheDocument();
});
it("updates the active point summary when a chart point is hovered", async () => {
const user = userEvent.setup();
render(
<Chart
data={rows}
defaultActiveIndex={0}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
series={series}
title="Hoverable chart"
/>
);
const tooltip = () => document.querySelector('[data-slot="tooltip"]') as HTMLElement;
expect(within(tooltip()).getByText("Jan 2025")).toBeInTheDocument();
await user.hover(screen.getByRole("button", { name: /mar 2025/i }));
expect(within(tooltip()).getByText("Mar 2025")).toBeInTheDocument();
expect(within(tooltip()).getByText(formatCurrency(118420))).toBeInTheDocument();
});
it("supports controlled active state and only emits callbacks on interaction", async () => {
const user = userEvent.setup();
const onActiveIndexChange = vi.fn();
render(
<Chart
activeIndex={0}
data={rows}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
onActiveIndexChange={onActiveIndexChange}
series={series}
title="Controlled chart"
/>
);
await user.hover(screen.getByRole("button", { name: /apr 2025/i }));
expect(onActiveIndexChange).toHaveBeenCalledWith(3);
const tooltip = document.querySelector('[data-slot="tooltip"]') as HTMLElement;
expect(within(tooltip).getByText("Jan 2025")).toBeInTheDocument();
expect(within(tooltip).queryByText("Apr 2025")).not.toBeInTheDocument();
});
it("keeps the active comparison usable when document motion is static", async () => {
const user = userEvent.setup();
document.documentElement.dataset.motion = "static";
render(
<Chart
data={rows}
defaultActiveIndex={0}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
series={series}
title="Static motion chart"
/>
);
const tooltip = () => document.querySelector('[data-slot="tooltip"]') as HTMLElement;
expect(within(tooltip()).getByText("Jan 2025")).toBeInTheDocument();
await user.hover(screen.getByRole("button", { name: /apr 2025/i }));
expect(within(tooltip()).getByText("Apr 2025")).toBeInTheDocument();
delete document.documentElement.dataset.motion;
});
it("renders the empty contract without collapsing the panel", () => {
render(
<Chart
data={[]}
empty="No series are visible in the current scope."
getXAxisLabel={() => ""}
series={series}
title="Empty chart"
/>
);
expect(screen.getByText("Empty chart")).toBeInTheDocument();
expect(screen.getByText("No series are visible in the current scope.")).toHaveAttribute(
"data-slot",
"empty"
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const chartVariants = cva(
[
"grid gap-5 rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]",
"px-5 py-5 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] sm:px-6 sm:py-6",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default:
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)]",
accent:
"border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)]"
}
},
defaultVariants: {
tone: "default"
}
}
);
export const chartHeaderVariants = cva(
"grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(13rem,18rem)] md:items-start"
);
export const chartHeaderLeadingVariants = cva("grid min-w-0 gap-2");
export const chartHeaderAsideVariants = cva(
"grid min-w-0 gap-3 justify-self-start md:justify-self-end md:justify-items-end"
);
export const chartEyebrowVariants = cva(
"text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
);
export const chartTitleVariants = cva(
"text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)] sm:text-[1.375rem]"
);
export const chartDescriptionVariants = cva(
"max-w-[42rem] text-sm leading-6 text-[var(--color-muted-foreground)]"
);
export const chartMetricGroupVariants = cva("grid min-w-0 gap-1 md:justify-items-end");
export const chartValueVariants = cva(
"max-w-full text-2xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)] md:text-right md:text-[2rem]"
);
export const chartChangeVariants = cva(
"flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-[var(--color-muted-foreground)] md:max-w-[18rem] md:justify-end md:text-right"
);
export const chartCanvasVariants = cva(
[
"relative overflow-hidden rounded-[calc(var(--ui-card-radius)-0.375rem)] border [border-width:var(--ui-card-border-width)]",
"border-[color-mix(in_oklch,var(--ui-card-default-border)_84%,transparent)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%)_0%,color-mix(in_oklch,var(--color-surface-bright)_72%,var(--color-surface-container-low)_28%)_100%)]"
],
{
variants: {
interactive: {
false: "",
true:
"supports-[backdrop-filter:blur(0px)]:backdrop-blur-[2px]"
}
},
defaultVariants: {
interactive: true
}
}
);
export const chartTooltipVariants = cva(
[
"min-w-[10.5rem] max-w-[14rem] rounded-[calc(var(--ui-card-radius)-0.5rem)] origin-bottom will-change-transform",
"border border-[color-mix(in_oklch,var(--ui-card-default-border)_88%,transparent)]",
"bg-[color-mix(in_oklch,var(--color-surface-bright)_88%,white_12%)] px-3 py-2.5",
"shadow-[0_18px_42px_color-mix(in_oklch,var(--color-primary)_14%,transparent)]",
"backdrop-blur-sm"
]
);
export const chartLegendVariants = cva(
"flex flex-wrap items-center gap-2.5"
);
export const chartLegendItemVariants = cva(
[
"inline-flex min-h-9 items-center gap-2.5 rounded-[var(--radius-full)] border px-3 py-2",
"border-[color-mix(in_oklch,var(--color-border)_72%,transparent)]",
"bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)] text-sm"
]
);
export const chartEmptyStateVariants = cva(
"grid min-h-72 place-items-center px-6 py-10 text-center text-sm leading-6 text-[var(--color-muted-foreground)]"
);
+131
View File
@@ -0,0 +1,131 @@
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Gauge } from "./gauge";
const STARTUP_ANIMATION_DURATION = 520;
describe("Gauge", () => {
beforeEach(() => {
document.documentElement.dataset.motion = "static";
});
afterEach(() => {
delete document.documentElement.dataset.motion;
vi.restoreAllMocks();
vi.useRealTimers();
});
it("renders a dial meter with the expected slots and aria contract", () => {
render(
<Gauge
description="Lead scoring remains inside the healthy forecast band."
label="Forecast confidence"
value={72}
/>
);
const gauge = screen.getByRole("meter", { name: "Forecast confidence" });
const canvas = gauge.querySelector('[data-slot="canvas"]');
const indicator = gauge.querySelector('[data-slot="indicator"]');
const value = gauge.querySelector('[data-slot="value"]');
const ticks = gauge.querySelectorAll('[data-slot="tick"]');
expect(gauge).toHaveAttribute("data-slot", "root");
expect(gauge).toHaveAttribute("data-shape", "dial");
expect(gauge).toHaveAttribute("data-state", "value");
expect(gauge).toHaveAttribute("aria-valuemin", "0");
expect(gauge).toHaveAttribute("aria-valuemax", "100");
expect(gauge).toHaveAttribute("aria-valuenow", "72");
expect(canvas).toHaveAttribute("data-slot", "canvas");
expect(indicator).toHaveAttribute("data-variant", "default");
expect(indicator).toHaveAttribute("stroke-dasharray", "72 100");
expect(value).toHaveTextContent("72%");
expect(ticks).toHaveLength(0);
});
it("supports semi-circle geometry, semantic variants, and tick activation", () => {
render(
<Gauge
label="Capacity utilization"
max={80}
shape="semi"
tickCount={7}
tone="accent"
value={40}
variant="warning"
/>
);
const gauge = screen.getByRole("meter", { name: "Capacity utilization" });
const svg = gauge.querySelector('[data-slot="svg"]');
const indicator = gauge.querySelector('[data-slot="indicator"]');
const activeTicks = gauge.querySelectorAll('[data-slot="tick"][data-active]');
expect(gauge).toHaveAttribute("data-shape", "semi");
expect(gauge).toHaveAttribute("data-tone", "accent");
expect(gauge).toHaveAttribute("data-variant", "warning");
expect(gauge).toHaveAttribute("aria-valuemax", "80");
expect(gauge).toHaveAttribute("aria-valuenow", "40");
expect(svg).toHaveAttribute("viewBox", "0 0 120 76");
expect(indicator).toHaveAttribute("stroke-dasharray", "50 100");
expect(activeTicks).toHaveLength(4);
});
it("renders an empty state when no numeric value is available", () => {
render(<Gauge aria-label="Pipeline health" tickCount={0} value={null} />);
const gauge = screen.getByRole("meter", { name: "Pipeline health" });
const indicator = gauge.querySelector('[data-slot="indicator"]');
const ticks = gauge.querySelectorAll('[data-slot="tick"]');
expect(gauge).toHaveAttribute("data-state", "empty");
expect(gauge).not.toHaveAttribute("aria-valuenow");
expect(gauge.querySelector('[data-slot="value"]')).toHaveTextContent("—");
expect(indicator).toHaveAttribute("data-state", "empty");
expect(indicator).toHaveAttribute("stroke-dasharray", "0 100");
expect(ticks).toHaveLength(0);
});
it("clamps values beyond the provided range and marks the max state", () => {
render(<Gauge aria-label="Budget saturation" max={120} value={160} />);
const gauge = screen.getByRole("meter", { name: "Budget saturation" });
const indicator = gauge.querySelector('[data-slot="indicator"]');
expect(gauge).toHaveAttribute("data-state", "max");
expect(gauge).toHaveAttribute("aria-valuenow", "120");
expect(indicator).toHaveAttribute("stroke-dasharray", "100 100");
expect(gauge.querySelector('[data-slot="value"]')).toHaveTextContent("120");
});
it("stages the sweep and value count-up when motion is enabled", () => {
delete document.documentElement.dataset.motion;
vi.useFakeTimers();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
return window.setTimeout(() => callback(performance.now()), 16);
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((handle) => {
window.clearTimeout(handle);
});
render(<Gauge label="Forecast confidence" value={72} />);
const gauge = screen.getByRole("meter", { name: "Forecast confidence" });
const indicator = gauge.querySelector('[data-slot="indicator"]');
const value = gauge.querySelector('[data-slot="value"]');
expect(gauge).toHaveAttribute("data-animating", "");
expect(indicator).toHaveAttribute("stroke-dasharray", "0 100");
expect(value).toHaveTextContent("0%");
act(() => {
vi.advanceTimersByTime(STARTUP_ANIMATION_DURATION + 80);
});
expect(gauge).not.toHaveAttribute("data-animating");
expect(indicator).toHaveAttribute("stroke-dasharray", "72 100");
expect(value).toHaveTextContent("72%");
});
});
+604
View File
@@ -0,0 +1,604 @@
import {
forwardRef,
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode
} from "react";
import {
gaugeCanvasVariants,
gaugeDescriptionVariants,
gaugeIndicatorVariants,
gaugeLabelVariants,
gaugeSvgVariants,
gaugeTickVariants,
gaugeTicksVariants,
gaugeTrackVariants,
gaugeValueVariants,
gaugeVariants
} from "./gauge.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
const DEFAULT_MAX = 100;
const DEFAULT_MIN = 0;
const DEFAULT_TICK_COUNTS = {
dial: 0,
semi: 0
} as const;
const MIN_TICK_COUNT = 0;
const MAX_TICK_COUNT = 24;
const numberFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 1
});
const INITIAL_SWEEP_DURATION = 520;
const UPDATE_SWEEP_DURATION = 320;
type GaugeShape = "dial" | "semi";
type GaugeGeometry = {
centerX: number;
centerY: number;
endAngle: number;
radius: number;
startAngle: number;
tickInnerRadius: number;
tickOuterRadius: number;
viewBox: string;
};
const GAUGE_GEOMETRY: Record<GaugeShape, GaugeGeometry> = {
dial: {
centerX: 60,
centerY: 60,
endAngle: 495,
radius: 42,
startAngle: 225,
tickInnerRadius: 47,
tickOuterRadius: 54,
viewBox: "0 0 120 120"
},
semi: {
centerX: 60,
centerY: 60,
endAngle: 450,
radius: 42,
startAngle: 270,
tickInnerRadius: 47,
tickOuterRadius: 54,
viewBox: "0 0 120 76"
}
};
export type GaugeValueFormatterContext = {
max: number;
min: number;
percentage: number | null;
value: number | null;
};
function normalizeNumber(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
return value;
}
function getResolvedMinMax(min: number | undefined, max: number | undefined) {
const resolvedMin = Number.isFinite(min) ? (min as number) : DEFAULT_MIN;
const resolvedMaxCandidate = Number.isFinite(max) ? (max as number) : DEFAULT_MAX;
const resolvedMax =
resolvedMaxCandidate > resolvedMin ? resolvedMaxCandidate : resolvedMin + 1;
return {
max: resolvedMax,
min: resolvedMin
};
}
function clampValue(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getState(value: number | null | undefined, min: number, max: number) {
const normalizedValue = normalizeNumber(value);
if (normalizedValue == null) {
return "empty";
}
return clampValue(normalizedValue, min, max) >= max ? "max" : "value";
}
function getPercentage(value: number | null | undefined, min: number, max: number) {
const normalizedValue = normalizeNumber(value);
if (normalizedValue == null) {
return null;
}
return ((clampValue(normalizedValue, min, max) - min) / (max - min)) * 100;
}
function getResolvedTickCount(shape: GaugeShape, tickCount: number | undefined) {
if (!Number.isFinite(tickCount)) {
return DEFAULT_TICK_COUNTS[shape];
}
return Math.min(Math.max(Math.round(tickCount as number), MIN_TICK_COUNT), MAX_TICK_COUNT);
}
function polarToCartesian(
centerX: number,
centerY: number,
radius: number,
angleInDegrees: number
) {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians)
};
}
function describeArc(
centerX: number,
centerY: number,
radius: number,
startAngle: number,
endAngle: number
) {
const start = polarToCartesian(centerX, centerY, radius, endAngle);
const end = polarToCartesian(centerX, centerY, radius, startAngle);
const sweep = Math.abs(endAngle - startAngle);
const largeArcFlag = sweep <= 180 ? "0" : "1";
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
}
function describeTick(
centerX: number,
centerY: number,
innerRadius: number,
outerRadius: number,
angle: number
) {
const start = polarToCartesian(centerX, centerY, outerRadius, angle);
const end = polarToCartesian(centerX, centerY, innerRadius, angle);
return `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
}
function getActiveTickCount(percentage: number | null, tickCount: number) {
if (percentage == null || percentage <= 0 || tickCount <= 0) {
return 0;
}
if (percentage >= 100) {
return tickCount;
}
return Math.max(1, Math.round((tickCount - 1) * (percentage / 100)) + 1);
}
function defaultValueFormatter({
max,
min,
percentage,
value
}: GaugeValueFormatterContext) {
if (value == null || percentage == null) {
return "—";
}
const clampedValue = clampValue(value, min, max);
if (min === 0 && max === 100) {
return `${Math.round(clampedValue)}%`;
}
return numberFormatter.format(clampedValue);
}
function easeOutCubic(value: number) {
return 1 - (1 - value) ** 3;
}
function motionIsDisabled() {
if (typeof document !== "undefined" && document.documentElement.dataset.motion === "static") {
return true;
}
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
return false;
}
type LegacyMediaQueryList = MediaQueryList & {
addListener?: (listener: (event: MediaQueryListEvent) => void) => void;
removeListener?: (listener: (event: MediaQueryListEvent) => void) => void;
};
export type GaugeProps = Omit<ComponentPropsWithoutRef<"div">, "children"> &
VariantProps<typeof gaugeVariants> & {
description?: ReactNode;
label?: ReactNode;
max?: number;
min?: number;
tickCount?: number;
value?: number | null;
valueFormatter?: (context: GaugeValueFormatterContext) => ReactNode;
};
export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
{
"aria-describedby": ariaDescribedBy,
"aria-label": ariaLabelProp,
className,
description,
label,
max,
min,
shape = "dial",
size,
tickCount,
tone,
value,
valueFormatter,
variant,
...props
},
ref
) {
const resolvedShape: GaugeShape = shape ?? "dial";
const resolvedSize = size ?? "md";
const resolvedTone = tone ?? "default";
const resolvedVariant = variant ?? "default";
const { max: resolvedMax, min: resolvedMin } = getResolvedMinMax(min, max);
const state = getState(value, resolvedMin, resolvedMax);
const percentage = getPercentage(value, resolvedMin, resolvedMax);
const resolvedTickCount = getResolvedTickCount(resolvedShape, tickCount);
const geometry = GAUGE_GEOMETRY[resolvedShape];
const normalizedValue = normalizeNumber(value);
const formattedValue = (valueFormatter ?? defaultValueFormatter)({
max: resolvedMax,
min: resolvedMin,
percentage,
value: normalizedValue
});
const gradientId = `gauge-gradient-${useId().replace(/:/g, "")}`;
const trackPath = describeArc(
geometry.centerX,
geometry.centerY,
geometry.radius,
geometry.startAngle,
geometry.endAngle
);
const ticks =
resolvedTickCount > 0
? Array.from({ length: resolvedTickCount }, (_, index) => {
const ratio =
resolvedTickCount === 1 ? 1 : index / (resolvedTickCount - 1);
const angle =
geometry.startAngle +
(geometry.endAngle - geometry.startAngle) * ratio;
return {
index,
path: describeTick(
geometry.centerX,
geometry.centerY,
geometry.tickInnerRadius,
geometry.tickOuterRadius,
angle
)
};
})
: [];
const computedAriaLabel =
ariaLabelProp ?? (typeof label === "string" ? label : undefined);
const computedAriaValueNow =
normalizedValue == null
? undefined
: clampValue(normalizedValue, resolvedMin, resolvedMax);
const computedAriaValueText =
typeof formattedValue === "string" || typeof formattedValue === "number"
? String(formattedValue)
: undefined;
const [disableMotion, setDisableMotion] = useState(motionIsDisabled);
const [displayPercentage, setDisplayPercentage] = useState(() =>
motionIsDisabled() ? percentage ?? 0 : 0
);
const [displayValue, setDisplayValue] = useState<number | null>(() =>
motionIsDisabled()
? computedAriaValueNow ?? null
: computedAriaValueNow == null
? null
: resolvedMin
);
const [isAnimating, setIsAnimating] = useState(false);
const hasAnimatedRef = useRef(false);
const previousPercentageRef = useRef(percentage ?? 0);
const previousValueRef = useRef<number | null>(computedAriaValueNow ?? null);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const syncMotionMode = () => {
setDisableMotion(motionIsDisabled());
};
syncMotionMode();
const observer = new MutationObserver(syncMotionMode);
observer.observe(document.documentElement, {
attributeFilter: ["data-motion"]
});
const mediaQuery: LegacyMediaQueryList | null =
typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
if (mediaQuery) {
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", syncMotionMode);
} else {
mediaQuery.addListener?.(syncMotionMode);
}
}
return () => {
observer.disconnect();
if (mediaQuery) {
if (typeof mediaQuery.removeEventListener === "function") {
mediaQuery.removeEventListener("change", syncMotionMode);
} else {
mediaQuery.removeListener?.(syncMotionMode);
}
}
};
}, []);
useEffect(() => {
const targetPercentage = percentage ?? 0;
const targetValue = computedAriaValueNow ?? null;
if (
disableMotion ||
targetValue == null ||
percentage == null ||
(hasAnimatedRef.current &&
previousPercentageRef.current === targetPercentage &&
previousValueRef.current === targetValue)
) {
setDisplayPercentage(targetPercentage);
setDisplayValue(targetValue);
setIsAnimating(false);
previousPercentageRef.current = targetPercentage;
previousValueRef.current = targetValue;
if (targetValue != null) {
hasAnimatedRef.current = true;
}
return;
}
const fromPercentage = hasAnimatedRef.current ? previousPercentageRef.current : 0;
const fromValue = hasAnimatedRef.current
? previousValueRef.current ?? resolvedMin
: resolvedMin;
const duration = hasAnimatedRef.current ? UPDATE_SWEEP_DURATION : INITIAL_SWEEP_DURATION;
let frame = 0;
let startTime: number | null = null;
setDisplayPercentage(fromPercentage);
setDisplayValue(fromValue);
setIsAnimating(true);
const tick = (timestamp: number) => {
if (startTime == null) {
startTime = timestamp;
}
const progress = Math.min((timestamp - startTime) / duration, 1);
const easedProgress = easeOutCubic(progress);
setDisplayPercentage(fromPercentage + (targetPercentage - fromPercentage) * easedProgress);
setDisplayValue(fromValue + (targetValue - fromValue) * easedProgress);
if (progress < 1) {
frame = window.requestAnimationFrame(tick);
return;
}
setDisplayPercentage(targetPercentage);
setDisplayValue(targetValue);
setIsAnimating(false);
previousPercentageRef.current = targetPercentage;
previousValueRef.current = targetValue;
hasAnimatedRef.current = true;
};
frame = window.requestAnimationFrame(tick);
return () => {
window.cancelAnimationFrame(frame);
};
}, [computedAriaValueNow, disableMotion, percentage, resolvedMin]);
const renderedPercentage = disableMotion ? percentage ?? 0 : displayPercentage;
const renderedValue =
disableMotion || computedAriaValueNow == null ? computedAriaValueNow ?? null : displayValue;
const renderedActiveTickCount = getActiveTickCount(renderedPercentage, resolvedTickCount);
const renderedFormattedValue = (valueFormatter ?? defaultValueFormatter)({
max: resolvedMax,
min: resolvedMin,
percentage: computedAriaValueNow == null ? null : renderedPercentage,
value: renderedValue
});
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating,
ticks: resolvedTickCount,
tone: resolvedTone,
variant: resolvedVariant
})}
aria-describedby={ariaDescribedBy}
aria-label={computedAriaLabel}
aria-valuemax={resolvedMax}
aria-valuemin={resolvedMin}
aria-valuenow={computedAriaValueNow}
aria-valuetext={computedAriaValueText}
className={cn(
gaugeVariants({
shape: resolvedShape,
size: resolvedSize,
tone: resolvedTone,
variant: resolvedVariant
}),
className
)}
ref={ref}
role="meter"
>
<div
{...createSlot("canvas")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating,
ticks: resolvedTickCount,
tone: resolvedTone,
variant: resolvedVariant
})}
className={cn(gaugeCanvasVariants({ shape: resolvedShape }))}
>
<svg
{...createSlot("svg")}
aria-hidden="true"
className={cn(gaugeSvgVariants())}
viewBox={geometry.viewBox}
>
<defs>
<linearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" stopColor="var(--ui-gauge-indicator-start)" />
<stop offset="100%" stopColor="var(--ui-gauge-indicator-end)" />
</linearGradient>
</defs>
{ticks.length > 0 ? (
<g
{...createSlot("ticks")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
ticks: resolvedTickCount
})}
className={cn(gaugeTicksVariants())}
>
{ticks.map((tick) => (
<path
key={tick.index}
{...createSlot("tick")}
{...createDataAttributes({
active: tick.index < renderedActiveTickCount,
shape: resolvedShape,
size: resolvedSize,
state,
variant: resolvedVariant
})}
className={cn(gaugeTickVariants())}
d={tick.path}
strokeLinecap="round"
strokeWidth="2.25"
/>
))}
</g>
) : null}
<path
{...createSlot("track")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating
})}
className={cn(gaugeTrackVariants())}
d={trackPath}
strokeLinecap="round"
strokeWidth="10"
/>
<path
{...createSlot("indicator")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating,
variant: resolvedVariant
})}
className={cn(gaugeIndicatorVariants())}
d={trackPath}
pathLength={100}
stroke={`url(#${gradientId})`}
strokeDasharray={`${renderedPercentage} 100`}
strokeLinecap="round"
strokeWidth="10"
/>
</svg>
<div
{...createSlot("value")}
{...createDataAttributes({
state,
animating: isAnimating
})}
className={cn(gaugeValueVariants())}
>
{renderedFormattedValue}
</div>
</div>
{label ? (
<p
{...createSlot("label")}
className={cn(gaugeLabelVariants())}
>
{label}
</p>
) : null}
{description ? (
<p
{...createSlot("description")}
className={cn(gaugeDescriptionVariants())}
>
{description}
</p>
) : null}
</div>
);
});
@@ -0,0 +1,96 @@
import { cva } from "../lib/cva";
export const gaugeVariants = cva("inline-grid justify-items-center text-center", {
variants: {
size: {
sm:
"gap-2.5 [--ui-gauge-dial-width:7.75rem] [--ui-gauge-semi-width:10rem] [--ui-gauge-value-font-size:1.55rem] [--ui-gauge-value-height:2.5rem] [--ui-gauge-value-padding-inline:0.8rem] [--ui-gauge-label-font-size:0.9rem] [--ui-gauge-description-font-size:0.78rem]",
md:
"gap-3 [--ui-gauge-dial-width:9.5rem] [--ui-gauge-semi-width:12rem] [--ui-gauge-value-font-size:1.95rem] [--ui-gauge-value-height:2.9rem] [--ui-gauge-value-padding-inline:1rem] [--ui-gauge-label-font-size:0.96rem] [--ui-gauge-description-font-size:0.84rem]",
lg:
"gap-3.5 [--ui-gauge-dial-width:12rem] [--ui-gauge-semi-width:15rem] [--ui-gauge-value-font-size:2.45rem] [--ui-gauge-value-height:3.5rem] [--ui-gauge-value-padding-inline:1.15rem] [--ui-gauge-label-font-size:1.05rem] [--ui-gauge-description-font-size:0.92rem]"
},
shape: {
dial: "w-[var(--ui-gauge-dial-width)] [--ui-gauge-value-bottom:18%]",
semi: "w-[var(--ui-gauge-semi-width)] [--ui-gauge-value-bottom:2%]"
},
tone: {
default:
"[--ui-gauge-center-bg:var(--ui-gauge-center-default-bg)] [--ui-gauge-center-shadow:var(--ui-gauge-center-default-shadow)]",
subtle:
"[--ui-gauge-center-bg:var(--ui-gauge-center-subtle-bg)] [--ui-gauge-center-shadow:var(--ui-gauge-center-subtle-shadow)]",
accent:
"[--ui-gauge-center-bg:var(--ui-gauge-center-accent-bg)] [--ui-gauge-center-shadow:var(--ui-gauge-center-accent-shadow)]"
},
variant: {
default:
"[--ui-gauge-indicator-start:var(--ui-gauge-indicator-default-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-default-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-default-end)]",
success:
"[--ui-gauge-indicator-start:var(--ui-gauge-indicator-success-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-success-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-success-end)]",
warning:
"[--ui-gauge-indicator-start:var(--ui-gauge-indicator-warning-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-warning-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-warning-end)]",
destructive:
"[--ui-gauge-indicator-start:var(--ui-gauge-indicator-destructive-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-destructive-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-destructive-end)]"
}
},
defaultVariants: {
shape: "dial",
size: "md",
tone: "default",
variant: "default"
}
});
export const gaugeCanvasVariants = cva(
[
"relative isolate w-full",
"before:pointer-events-none before:absolute before:inset-x-[20%] before:top-[12%] before:h-[38%] before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--ui-gauge-indicator-start)_26%,transparent),transparent_72%)] before:opacity-0 before:blur-xl before:content-['']",
"data-[animating]:before:opacity-100 data-[animating]:before:animate-[aiui-breathe_620ms_var(--ease-standard)_1]"
],
{
variants: {
shape: {
dial: "aspect-square",
semi: "aspect-[120/76]"
}
},
defaultVariants: {
shape: "dial"
}
}
);
export const gaugeSvgVariants = cva("h-full w-full overflow-visible");
export const gaugeTrackVariants = cva("fill-none stroke-[var(--ui-gauge-track-stroke)]");
export const gaugeIndicatorVariants = cva(
[
"fill-none transition-[stroke-dasharray,opacity,filter] duration-[var(--dur-slow)] ease-[var(--ease-emphasized)]",
"data-[state=empty]:opacity-0",
"data-[animating]:[filter:drop-shadow(0_0_0.9rem_color-mix(in_oklch,var(--ui-gauge-indicator-end)_18%,transparent))]"
]
);
export const gaugeTicksVariants = cva("fill-none");
export const gaugeTickVariants = cva(
"fill-none stroke-[var(--ui-gauge-tick-stroke)] opacity-60 transition-[stroke,opacity] duration-[var(--dur-base)] ease-[var(--ease-standard)] data-[active]:stroke-[var(--ui-gauge-active-tick-stroke)] data-[active]:opacity-100"
);
export const gaugeValueVariants = cva(
[
"absolute inset-x-[18%] bottom-[var(--ui-gauge-value-bottom)] inline-flex min-h-[var(--ui-gauge-value-height)] items-center justify-center rounded-[var(--radius-full)]",
"bg-[var(--ui-gauge-center-bg)] px-[var(--ui-gauge-value-padding-inline)] text-[var(--ui-gauge-value-font-size)] font-semibold leading-none tracking-[var(--tracking-tight)] text-[var(--color-foreground)]",
"shadow-[var(--ui-gauge-center-shadow)] transition-[transform,opacity,box-shadow] duration-[var(--dur-slow)] ease-[var(--ease-emphasized)] will-change-transform",
"data-[animating]:animate-[aiui-breathe_620ms_var(--ease-standard)_1]"
]
);
export const gaugeLabelVariants = cva(
"text-[var(--ui-gauge-label-font-size)] font-medium text-[var(--color-foreground)]"
);
export const gaugeDescriptionVariants = cva(
"max-w-[24rem] text-[var(--ui-gauge-description-font-size)] leading-6 text-[var(--color-muted-foreground)]"
);
+52
View File
@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Col, Row } from "./grid";
describe("Grid", () => {
it("renders row and item slots", () => {
render(
<Row data-testid="row">
<Col data-testid="col">Content</Col>
</Row>
);
expect(screen.getByTestId("row")).toHaveAttribute("data-slot", "root");
expect(screen.getByTestId("row")).toHaveAttribute("data-gap", "md");
expect(screen.getByTestId("col")).toHaveAttribute("data-slot", "item");
expect(screen.getByTestId("col")).toHaveAttribute("data-span", "full");
});
it("applies tokenized gap and alignment classes on the row", () => {
render(
<Row align="center" data-testid="row" gap="lg" xGap="xl" yGap="sm">
<Col>Metric</Col>
</Row>
);
expect(screen.getByTestId("row")).toHaveClass("items-center");
expect(screen.getByTestId("row")).toHaveClass("gap-[var(--ui-grid-gap-lg)]");
expect(screen.getByTestId("row")).toHaveClass("gap-x-[var(--ui-grid-gap-xl)]");
expect(screen.getByTestId("row")).toHaveClass("gap-y-[var(--ui-grid-gap-sm)]");
});
it("writes base and responsive placement variables on columns", () => {
render(
<Col
data-testid="col"
lg={{ offset: 2, span: 4 }}
offset={1}
span={6}
xxl="auto"
/>
);
const column = screen.getByTestId("col");
expect(column.style.getPropertyValue("--ui-col-placement")).toBe("2 / span 6");
expect(column.style.getPropertyValue("--ui-col-placement-lg")).toBe("3 / span 4");
expect(column.style.getPropertyValue("--ui-col-placement-2xl")).toBe("auto");
expect(column).toHaveAttribute("data-span", "6");
expect(column).toHaveAttribute("data-offset", "1");
});
});
+191
View File
@@ -0,0 +1,191 @@
import {
forwardRef,
type ComponentPropsWithoutRef,
type CSSProperties
} from "react";
import {
colVariants,
rowGapClasses,
rowVariants,
rowXGapClasses,
rowYGapClasses
} from "./grid.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type GridSpan = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | "auto" | "full";
export type GridOffset = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
export type GridResponsiveValue =
| GridSpan
| {
offset?: GridOffset;
span?: GridSpan;
};
type BreakpointKey = "sm" | "md" | "lg" | "xl" | "xxl";
type GridPlacement = {
offset?: GridOffset;
span?: GridSpan;
};
type GridStyleVarName =
| "--ui-col-placement"
| "--ui-col-placement-sm"
| "--ui-col-placement-md"
| "--ui-col-placement-lg"
| "--ui-col-placement-xl"
| "--ui-col-placement-2xl";
type GridStyle = CSSProperties &
Partial<Record<GridStyleVarName, string>>;
const breakpointVars: Record<BreakpointKey, GridStyleVarName> = {
sm: "--ui-col-placement-sm",
md: "--ui-col-placement-md",
lg: "--ui-col-placement-lg",
xl: "--ui-col-placement-xl",
xxl: "--ui-col-placement-2xl"
};
const breakpointKeys = Object.keys(breakpointVars) as BreakpointKey[];
function normalizePlacement(value?: GridResponsiveValue): GridPlacement | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value === "number" || typeof value === "string") {
return { span: value };
}
return value;
}
function resolvePlacementValue(placement?: GridPlacement) {
if (!placement) {
return undefined;
}
const offset = placement.offset ?? 0;
if (placement.span === undefined && offset === 0) {
return undefined;
}
const span = placement.span ?? "full";
if (span === "full") {
return offset > 0 ? `${offset + 1} / -1` : "1 / -1";
}
if (span === "auto") {
return offset > 0 ? `${offset + 1} / auto` : "auto";
}
return offset > 0 ? `${offset + 1} / span ${span}` : `span ${span} / span ${span}`;
}
export type RowGap = keyof typeof rowGapClasses;
export type RowProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof rowVariants> & {
gap?: RowGap;
xGap?: RowGap;
yGap?: RowGap;
};
export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
{
align = "stretch",
className,
gap = "md",
xGap,
yGap,
...props
},
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
align,
gap
})}
className={cn(
rowVariants({ align }),
rowGapClasses[gap],
xGap ? rowXGapClasses[xGap] : undefined,
yGap ? rowYGapClasses[yGap] : undefined,
className
)}
ref={ref}
/>
);
});
export type ColProps = ComponentPropsWithoutRef<"div"> & {
offset?: GridOffset;
span?: GridSpan;
sm?: GridResponsiveValue;
md?: GridResponsiveValue;
lg?: GridResponsiveValue;
xl?: GridResponsiveValue;
xxl?: GridResponsiveValue;
};
export const Col = forwardRef<HTMLDivElement, ColProps>(function Col(
{
className,
offset,
span,
style,
sm,
md,
lg,
xl,
xxl,
...props
},
ref
) {
const resolvedStyle: GridStyle = { ...(style ?? {}) };
const basePlacement = resolvePlacementValue({ offset, span });
if (basePlacement) {
resolvedStyle["--ui-col-placement"] = basePlacement;
}
const responsiveValues = { sm, md, lg, xl, xxl } satisfies Record<
BreakpointKey,
GridResponsiveValue | undefined
>;
for (const key of breakpointKeys) {
const placement = resolvePlacementValue(normalizePlacement(responsiveValues[key]));
if (placement) {
resolvedStyle[breakpointVars[key]] = placement;
}
}
return (
<div
{...props}
{...createSlot("item")}
{...createDataAttributes({
offset: offset && offset > 0 ? offset : undefined,
span: span ?? "full"
})}
className={cn(colVariants(), className)}
ref={ref}
style={resolvedStyle}
/>
);
});
@@ -0,0 +1,52 @@
import { cva } from "../lib/cva";
export const rowGapClasses = {
none: "gap-0",
xs: "gap-[var(--ui-grid-gap-xs)]",
sm: "gap-[var(--ui-grid-gap-sm)]",
md: "gap-[var(--ui-grid-gap-md)]",
lg: "gap-[var(--ui-grid-gap-lg)]",
xl: "gap-[var(--ui-grid-gap-xl)]"
} as const;
export const rowXGapClasses = {
none: "gap-x-0",
xs: "gap-x-[var(--ui-grid-gap-xs)]",
sm: "gap-x-[var(--ui-grid-gap-sm)]",
md: "gap-x-[var(--ui-grid-gap-md)]",
lg: "gap-x-[var(--ui-grid-gap-lg)]",
xl: "gap-x-[var(--ui-grid-gap-xl)]"
} as const;
export const rowYGapClasses = {
none: "gap-y-0",
xs: "gap-y-[var(--ui-grid-gap-xs)]",
sm: "gap-y-[var(--ui-grid-gap-sm)]",
md: "gap-y-[var(--ui-grid-gap-md)]",
lg: "gap-y-[var(--ui-grid-gap-lg)]",
xl: "gap-y-[var(--ui-grid-gap-xl)]"
} as const;
export const rowVariants = cva("grid w-full min-w-0 auto-rows-auto grid-cols-12", {
variants: {
align: {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch"
}
},
defaultVariants: {
align: "stretch"
}
});
export const colVariants = cva([
"min-w-0",
"[grid-column:var(--ui-col-placement,1_/_-1)]",
"sm:[grid-column:var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1))]",
"md:[grid-column:var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1)))]",
"lg:[grid-column:var(--ui-col-placement-lg,var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1))))]",
"xl:[grid-column:var(--ui-col-placement-xl,var(--ui-col-placement-lg,var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1)))))]",
"2xl:[grid-column:var(--ui-col-placement-2xl,var(--ui-col-placement-xl,var(--ui-col-placement-lg,var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1))))))]"
]);
@@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Field, FieldControl, FieldDescription, FieldError } from "./field";
import { InputGroup, InputGroupPrefix, InputGroupSuffix } from "./input-group";
import { Input } from "./input";
import { Label } from "./label";
describe("InputGroup", () => {
it("renders affix slots and propagates grouped state to the input", () => {
render(
<InputGroup disabled invalid readOnly required size="lg">
<InputGroupPrefix>
<span aria-hidden="true">#</span>
</InputGroupPrefix>
<Input aria-label="Search launches" />
<InputGroupSuffix>
<span>K</span>
</InputGroupSuffix>
</InputGroup>
);
const input = screen.getByRole("textbox", { name: "Search launches" });
const control = input.closest('[data-slot="control"]');
expect(control).toHaveAttribute("data-slot", "control");
expect(control).toHaveAttribute("data-disabled", "");
expect(control).toHaveAttribute("data-invalid", "");
expect(control).toHaveAttribute("data-readonly", "");
expect(control).toHaveAttribute("data-required", "");
expect(control).toHaveAttribute("data-size", "lg");
expect(input).toHaveAttribute("data-slot", "input");
expect(input).toHaveAttribute("data-size", "lg");
expect(input).toBeDisabled();
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAttribute("readonly");
expect(input).toBeRequired();
expect(screen.getByText("#").closest('[data-slot="prefix"]')).toBeInTheDocument();
expect(screen.getByText("⌘K").closest('[data-slot="suffix"]')).toBeInTheDocument();
});
it("keeps field ids and described-by wiring when grouped inside a field control", () => {
render(
<Field invalid required>
<Label requiredIndicator>Lane search</Label>
<FieldControl>
<InputGroup>
<InputGroupPrefix aria-hidden="true">
<span>#</span>
</InputGroupPrefix>
<Input />
</InputGroup>
<FieldDescription>Find the right routing lane before queuing.</FieldDescription>
<FieldError>Search query is required.</FieldError>
</FieldControl>
</Field>
);
const input = screen.getByRole("textbox", { name: "Lane search" });
const description = screen.getByText("Find the right routing lane before queuing.");
const error = screen.getByText("Search query is required.");
expect(input).toHaveAttribute("aria-describedby", `${description.id} ${error.id}`);
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toBeRequired();
expect(input.closest('[data-slot="control"]')).toHaveAttribute("data-invalid", "");
});
});
+110
View File
@@ -0,0 +1,110 @@
import {
createContext,
forwardRef,
useContext,
type ComponentPropsWithoutRef
} from "react";
import {
inputGroupAffixVariants,
inputGroupVariants
} from "./input-group.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import type { FieldStateProps } from "../lib/contracts";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { useFieldContext } from "./field";
type InputGroupContextValue = {
disabled: boolean;
invalid: boolean;
readOnly: boolean;
required: boolean;
size: Exclude<VariantProps<typeof inputGroupVariants>["size"], null | undefined>;
};
const InputGroupContext = createContext<InputGroupContextValue | null>(null);
export function useInputGroupContext() {
return useContext(InputGroupContext);
}
export type InputGroupProps = ComponentPropsWithoutRef<"div"> &
FieldStateProps &
VariantProps<typeof inputGroupVariants>;
export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(function InputGroup(
{
className,
disabled,
invalid,
readOnly,
required,
size = "md",
...props
},
ref
) {
const field = useFieldContext();
const resolvedDisabled = disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? field?.invalid ?? false;
const resolvedReadOnly = readOnly ?? field?.readOnly ?? false;
const resolvedRequired = required ?? field?.required ?? false;
const resolvedSize = size ?? "md";
return (
<InputGroupContext.Provider
value={{
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readOnly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
}}
>
<div
{...props}
{...createSlot("control")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
})}
className={cn(inputGroupVariants({ size: resolvedSize }), className)}
ref={ref}
/>
</InputGroupContext.Provider>
);
});
export type InputGroupPrefixProps = ComponentPropsWithoutRef<"div">;
export const InputGroupPrefix = forwardRef<HTMLDivElement, InputGroupPrefixProps>(
function InputGroupPrefix({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("prefix")}
className={cn(inputGroupAffixVariants(), className)}
ref={ref}
/>
);
}
);
export type InputGroupSuffixProps = ComponentPropsWithoutRef<"div">;
export const InputGroupSuffix = forwardRef<HTMLDivElement, InputGroupSuffixProps>(
function InputGroupSuffix({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("suffix")}
className={cn(inputGroupAffixVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,56 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const inputGroupVariants = cva(
[
"flex w-full min-w-0 items-center rounded-[var(--ui-input-radius)] border bg-[var(--ui-input-bg)]",
"text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none",
"[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]",
"transition-[border-color,box-shadow,background-color,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"focus-within:-translate-y-[var(--ui-input-focus-lift)] focus-within:border-[var(--ui-input-focus-border)] focus-within:shadow-[var(--ui-input-focus-shadow)]",
"focus-within:ring-2 focus-within:ring-[var(--color-ring)] focus-within:ring-offset-2 focus-within:ring-offset-[var(--color-background)]",
"data-[disabled]:cursor-not-allowed data-[disabled]:bg-[var(--ui-input-disabled-bg)] data-[disabled]:text-[var(--color-muted-foreground)]",
"data-[readonly]:bg-[var(--ui-input-readonly-bg)] data-[readonly]:text-[var(--color-muted-foreground)]",
"data-[invalid]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
"data-[invalid]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]",
getMotionRecipeClassNames("ring")
],
{
variants: {
size: {
sm: "h-10 gap-2.5 px-3",
md: "h-11 gap-3 px-4",
lg: "h-12 gap-3 px-4"
}
},
defaultVariants: {
size: "md"
}
}
);
export const inputGroupInputVariants = cva(
[
"h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--ui-input-fg)] shadow-none outline-none",
"placeholder:text-[var(--color-muted-foreground)]",
"focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
"disabled:cursor-not-allowed disabled:text-[var(--color-muted-foreground)] disabled:opacity-100",
"read-only:text-[var(--color-muted-foreground)]"
],
{
variants: {
size: {
sm: "text-sm",
md: "text-sm",
lg: "text-base"
}
},
defaultVariants: {
size: "md"
}
}
);
export const inputGroupAffixVariants = cva(
"flex shrink-0 items-center text-[var(--color-muted-foreground)] [&_svg]:size-4 [&_svg]:shrink-0"
);
@@ -0,0 +1,131 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
import {
MetricCard,
MetricCardActions,
MetricCardAside,
MetricCardDelta,
MetricCardDescription,
MetricCardEyebrow,
MetricCardFooter,
MetricCardHeader,
MetricCardLabel,
MetricCardLeading,
MetricCardMedia,
MetricCardMetric,
MetricCardValue
} from "./metric-card";
describe("MetricCard", () => {
it("renders shared KPI slots plus media, actions, and footer regions", () => {
render(
<MetricCard data-testid="metric-card" interactive layout="split" tone="hero">
<MetricCardHeader>
<MetricCardLeading>
<MetricCardEyebrow>Runway lens</MetricCardEyebrow>
<MetricCardLabel>Operational cost reduction</MetricCardLabel>
</MetricCardLeading>
<MetricCardAside>
<MetricCardDelta tone="primary">42%</MetricCardDelta>
</MetricCardAside>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>$38,250</MetricCardValue>
<MetricCardDelta tone="success">+78</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription>Target progress across the active quarter.</MetricCardDescription>
<MetricCardMedia padding="flush" tone="accent">
<div>Chart surface</div>
</MetricCardMedia>
<MetricCardActions layout="stack">
<Button size="sm">Review forecast</Button>
</MetricCardActions>
<MetricCardFooter>
<div>Quarter target</div>
</MetricCardFooter>
</MetricCard>
);
const card = screen.getByTestId("metric-card");
expect(card).toHaveAttribute("data-layout", "split");
expect(card).toHaveAttribute("data-interactive", "");
expect(card).toHaveAttribute("data-tone", "hero");
expect(card.className).toContain("hover:translate-y-[var(--ui-card-hover-translate)]");
expect(card.className).toContain("[&[data-interactive]:hover>[data-slot=media]]:-translate-y-0.5");
expect(card.className).toContain("motion-reduce:hover:translate-y-0");
expect(screen.getByText("Runway lens")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("Operational cost reduction")).toHaveAttribute("data-slot", "label");
expect(screen.getByText("Operational cost reduction").closest('[data-slot="leading"]')).toBeInTheDocument();
expect(screen.getByText("42%").closest('[data-slot="aside"]')).toBeInTheDocument();
expect(screen.getByText("Chart surface").closest('[data-slot="media"]')).toHaveAttribute(
"data-padding",
"flush"
);
expect(screen.getByText("Chart surface").closest('[data-slot="media"]')).toHaveAttribute(
"data-tone",
"accent"
);
expect(screen.getByText("Chart surface").closest('[data-slot="media"]')?.className).toContain(
"before:pointer-events-none"
);
expect(
screen.getByRole("button", { name: "Review forecast" }).closest('[data-slot="actions"]')
).toHaveAttribute("data-layout", "stack");
expect(screen.getByText("Quarter target").closest('[data-slot="footer"]')).toBeInTheDocument();
});
it("shares the stat-card metric contract inside richer panels", () => {
render(
<MetricCard tone="inverse">
<MetricCardHeader className="items-start text-left">
<MetricCardLeading>
<MetricCardEyebrow>Revenue pulse</MetricCardEyebrow>
<MetricCardLabel>Revenue influence</MetricCardLabel>
</MetricCardLeading>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>31%</MetricCardValue>
<MetricCardDelta tone="primary">+9.2%</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription>
AI-assisted routing is improving close quality instead of just adding volume.
</MetricCardDescription>
</MetricCard>
);
expect(screen.getByText("Revenue influence").closest('[data-slot="header"]')).toHaveClass(
"items-start"
);
expect(screen.getByText("Revenue pulse")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("Revenue influence").closest('[data-slot="root"]')).toHaveAttribute(
"data-tone",
"inverse"
);
expect(screen.getByText("31%")).toHaveAttribute("data-slot", "value");
expect(screen.getByText("+9.2%")).toHaveAttribute("data-tone", "primary");
});
it("keeps the richer hover choreography out when interactive polish is disabled", () => {
render(
<MetricCard data-testid="metric-card" interactive={false}>
<MetricCardHeader>
<MetricCardLeading>
<MetricCardLabel>Forecast confidence</MetricCardLabel>
</MetricCardLeading>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>31%</MetricCardValue>
<MetricCardDelta tone="primary">+9.2%</MetricCardDelta>
</MetricCardMetric>
</MetricCard>
);
const card = screen.getByTestId("metric-card");
expect(card).not.toHaveAttribute("data-interactive");
expect(card.className).not.toContain("hover:translate-y-[var(--ui-card-hover-translate)]");
});
});
+158
View File
@@ -0,0 +1,158 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import {
metricCardAsideVariants,
metricCardActionsVariants,
metricCardFooterVariants,
metricCardHeaderVariants,
metricCardLeadingVariants,
metricCardMediaVariants,
metricCardVariants
} from "./metric-card.variants";
import {
StatCardDelta,
StatCardDescription,
StatCardEyebrow,
StatCardLabel,
StatCardMetric,
StatCardValue,
type StatCardDeltaProps,
type StatCardDescriptionProps,
type StatCardEyebrowProps,
type StatCardLabelProps,
type StatCardMetricProps,
type StatCardValueProps
} from "./stat-card";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type MetricCardProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof metricCardVariants>;
export const MetricCard = forwardRef<HTMLDivElement, MetricCardProps>(function MetricCard(
{ className, interactive, layout, tone, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({ interactive, layout, tone })}
className={cn(metricCardVariants({ interactive, layout, tone }), className)}
ref={ref}
/>
);
});
export type MetricCardHeaderProps = ComponentPropsWithoutRef<"div">;
export const MetricCardHeader = forwardRef<HTMLDivElement, MetricCardHeaderProps>(
function MetricCardHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(metricCardHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type MetricCardLeadingProps = ComponentPropsWithoutRef<"div">;
export const MetricCardLeading = forwardRef<HTMLDivElement, MetricCardLeadingProps>(
function MetricCardLeading({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("leading")}
className={cn(metricCardLeadingVariants(), className)}
ref={ref}
/>
);
}
);
export type MetricCardAsideProps = ComponentPropsWithoutRef<"div">;
export const MetricCardAside = forwardRef<HTMLDivElement, MetricCardAsideProps>(
function MetricCardAside({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("aside")}
className={cn(metricCardAsideVariants(), className)}
ref={ref}
/>
);
}
);
export const MetricCardEyebrow = StatCardEyebrow;
export type MetricCardEyebrowProps = StatCardEyebrowProps;
export const MetricCardLabel = StatCardLabel;
export type MetricCardLabelProps = StatCardLabelProps;
export const MetricCardMetric = StatCardMetric;
export type MetricCardMetricProps = StatCardMetricProps;
export const MetricCardValue = StatCardValue;
export type MetricCardValueProps = StatCardValueProps;
export const MetricCardDelta = StatCardDelta;
export type MetricCardDeltaProps = StatCardDeltaProps;
export const MetricCardDescription = StatCardDescription;
export type MetricCardDescriptionProps = StatCardDescriptionProps;
export type MetricCardMediaProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof metricCardMediaVariants>;
export const MetricCardMedia = forwardRef<HTMLDivElement, MetricCardMediaProps>(
function MetricCardMedia({ className, padding, tone, ...props }, ref) {
return (
<div
{...props}
{...createSlot("media")}
{...createDataAttributes({ padding, tone })}
className={cn(metricCardMediaVariants({ padding, tone }), className)}
ref={ref}
/>
);
}
);
export type MetricCardActionsProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof metricCardActionsVariants>;
export const MetricCardActions = forwardRef<HTMLDivElement, MetricCardActionsProps>(
function MetricCardActions({ className, layout, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
{...createDataAttributes({ layout })}
className={cn(metricCardActionsVariants({ layout }), className)}
ref={ref}
/>
);
}
);
export type MetricCardFooterProps = ComponentPropsWithoutRef<"div">;
export const MetricCardFooter = forwardRef<HTMLDivElement, MetricCardFooterProps>(
function MetricCardFooter({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(metricCardFooterVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,135 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const metricCardVariants = cva(
[
"relative isolate grid gap-5 overflow-hidden rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]",
"px-5 py-5 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] sm:px-6 sm:py-6",
"before:pointer-events-none before:absolute before:inset-x-[10%] before:top-0 before:h-24 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_34%,transparent),transparent_72%)] before:opacity-70 before:blur-2xl before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"[&>[data-slot=media]]:mt-1 [&>[data-slot=media]]:isolate",
"[&>[data-slot=footer]]:mt-1 [&>[data-slot=footer]]:rounded-[calc(var(--ui-card-radius)-0.625rem)] [&>[data-slot=footer]]:border [&>[data-slot=footer]]:[border-width:var(--ui-card-border-width)] [&>[data-slot=footer]]:border-[color-mix(in_oklch,var(--color-border)_62%,transparent)] [&>[data-slot=footer]]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container)_82%,white_18%))] [&>[data-slot=footer]]:px-4 [&>[data-slot=footer]]:py-4 [&>[data-slot=footer]]:shadow-[inset_0_1px_0_rgba(255,255,255,0.48)]",
"[&>[data-slot=actions]]:rounded-[calc(var(--ui-card-radius)-0.625rem)] [&>[data-slot=actions]]:border [&>[data-slot=actions]]:[border-width:var(--ui-card-border-width)] [&>[data-slot=actions]]:border-[color-mix(in_oklch,var(--color-border)_58%,transparent)] [&>[data-slot=actions]]:bg-[color-mix(in_oklch,var(--color-surface-container-low)_76%,white_24%)] [&>[data-slot=actions]]:px-3 [&>[data-slot=actions]]:py-3 [&>[data-slot=actions]]:shadow-[inset_0_1px_0_rgba(255,255,255,0.42)] sm:[&>[data-slot=actions]]:px-4",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default: "border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)]",
subtle:
"border-[var(--ui-card-subtle-border)] bg-[var(--ui-card-subtle-bg)] shadow-[var(--ui-card-subtle-shadow)]",
accent:
"border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)] [&>[data-slot=footer]]:border-[color-mix(in_oklch,var(--color-primary)_18%,transparent)] [&>[data-slot=footer]]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_30%,white_70%),color-mix(in_oklch,var(--color-surface-container)_82%,white_18%))] [&>[data-slot=actions]]:border-[color-mix(in_oklch,var(--color-primary)_16%,transparent)] [&>[data-slot=actions]]:bg-[color-mix(in_oklch,var(--color-primary-container)_16%,white_84%)]",
inverse: [
"border-[color-mix(in_oklch,var(--color-foreground)_10%,transparent)]",
"bg-[linear-gradient(145deg,color-mix(in_oklch,var(--color-foreground)_94%,black_6%),color-mix(in_oklch,var(--color-foreground)_74%,var(--color-primary)_26%))]",
"text-white shadow-[0_28px_72px_color-mix(in_oklch,var(--color-foreground)_22%,transparent)]",
"[&_[data-slot=eyebrow]]:text-white/60",
"[&_[data-slot=label]]:text-white",
"[&_[data-slot=value]]:text-white",
"[&_[data-slot=description]]:text-white/66",
"[&>[data-slot=footer]]:border-white/10 [&>[data-slot=footer]]:bg-white/6",
"[&>[data-slot=actions]]:border-white/10 [&>[data-slot=actions]]:bg-white/7",
"[&_[data-slot=delta][data-tone=neutral]]:border-white/12 [&_[data-slot=delta][data-tone=neutral]]:bg-white/10 [&_[data-slot=delta][data-tone=neutral]]:text-white/82",
"[&_[data-slot=delta][data-tone=primary]]:border-white/12 [&_[data-slot=delta][data-tone=primary]]:bg-white/10 [&_[data-slot=delta][data-tone=primary]]:text-white",
"[&_[data-slot=delta][data-tone=success]]:border-[color-mix(in_oklch,var(--color-success)_24%,transparent)] [&_[data-slot=delta][data-tone=success]]:bg-[color-mix(in_oklch,var(--color-success)_18%,transparent)] [&_[data-slot=delta][data-tone=success]]:text-white",
"[&_[data-slot=delta][data-tone=warning]]:border-[color-mix(in_oklch,var(--color-warning)_28%,transparent)] [&_[data-slot=delta][data-tone=warning]]:bg-[color-mix(in_oklch,var(--color-warning)_18%,transparent)] [&_[data-slot=delta][data-tone=warning]]:text-white"
],
hero: [
"relative overflow-hidden border-transparent",
"bg-[radial-gradient(circle_at_top,color-mix(in_oklch,var(--color-tertiary-container)_58%,transparent),transparent_40%),linear-gradient(145deg,color-mix(in_oklch,var(--color-foreground)_90%,black_10%),color-mix(in_oklch,var(--color-foreground)_78%,var(--color-primary)_22%))]",
"text-white shadow-[0_28px_72px_color-mix(in_oklch,var(--color-foreground)_22%,transparent)]",
"[&_[data-slot=eyebrow]]:text-white/60",
"[&_[data-slot=label]]:text-white",
"[&_[data-slot=value]]:text-white",
"[&_[data-slot=description]]:text-white/64",
"[&>[data-slot=footer]]:border-white/10 [&>[data-slot=footer]]:bg-white/6",
"[&>[data-slot=actions]]:border-white/10 [&>[data-slot=actions]]:bg-white/7",
"[&_[data-slot=delta][data-tone=neutral]]:border-white/12 [&_[data-slot=delta][data-tone=neutral]]:bg-white/10 [&_[data-slot=delta][data-tone=neutral]]:text-white/82",
"[&_[data-slot=delta][data-tone=primary]]:border-white/12 [&_[data-slot=delta][data-tone=primary]]:bg-white/10 [&_[data-slot=delta][data-tone=primary]]:text-white",
"[&_[data-slot=delta][data-tone=success]]:border-[color-mix(in_oklch,var(--color-success)_24%,transparent)] [&_[data-slot=delta][data-tone=success]]:bg-[color-mix(in_oklch,var(--color-success)_18%,transparent)] [&_[data-slot=delta][data-tone=success]]:text-white",
"[&_[data-slot=delta][data-tone=warning]]:border-[color-mix(in_oklch,var(--color-warning)_28%,transparent)] [&_[data-slot=delta][data-tone=warning]]:bg-[color-mix(in_oklch,var(--color-warning)_18%,transparent)] [&_[data-slot=delta][data-tone=warning]]:text-white"
]
},
interactive: {
false: "",
true: [
"hover:translate-y-[var(--ui-card-hover-translate)] hover:scale-[var(--ui-card-hover-scale,1)] hover:shadow-[var(--ui-card-hover-shadow)]",
"[&>[data-slot=media]]:transition-[transform,box-shadow,border-color,background-color] [&>[data-slot=media]]:duration-[var(--dur-slow)] [&>[data-slot=media]]:ease-[var(--ease-emphasized)]",
"[&>[data-slot=actions]]:transition-[transform,box-shadow,border-color,background-color] [&>[data-slot=actions]]:duration-[var(--dur-base)] [&>[data-slot=actions]]:ease-[var(--ease-standard)]",
"[&[data-interactive]:hover>[data-slot=media]]:-translate-y-0.5 [&[data-interactive]:hover>[data-slot=media]]:shadow-[0_18px_34px_color-mix(in_oklch,var(--color-primary)_10%,transparent),inset_0_1px_0_rgba(255,255,255,0.48)]",
"[&[data-interactive]:hover>[data-slot=actions]]:-translate-y-px",
"motion-reduce:hover:translate-y-0 motion-reduce:hover:scale-100 motion-reduce:[&[data-interactive]:hover>[data-slot=media]]:translate-y-0 motion-reduce:[&[data-interactive]:hover>[data-slot=actions]]:translate-y-0"
]
},
layout: {
default: "",
split:
"lg:grid-cols-[minmax(0,1fr)_minmax(0,0.92fr)] lg:items-start lg:[&>[data-slot=media]]:col-start-2 lg:[&>[data-slot=media]]:row-span-3 lg:[&>[data-slot=actions]]:col-span-full lg:[&>[data-slot=footer]]:col-span-full"
}
},
defaultVariants: {
tone: "default",
interactive: false,
layout: "default"
}
}
);
export const metricCardHeaderVariants = cva(
"grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"
);
export const metricCardLeadingVariants = cva("grid min-w-0 gap-3");
export const metricCardAsideVariants = cva(
"flex flex-wrap items-start gap-2 justify-self-start sm:justify-self-end"
);
export const metricCardMediaVariants = cva(
[
"relative isolate overflow-hidden rounded-[calc(var(--ui-card-radius)-0.375rem)] border [border-width:var(--ui-card-border-width)]",
"shadow-[inset_0_1px_0_rgba(255,255,255,0.4)]",
"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-[58%] before:bg-[linear-gradient(180deg,color-mix(in_oklch,white_74%,transparent),transparent)] before:opacity-90 before:content-['']",
"after:pointer-events-none after:absolute after:inset-y-0 after:right-[-12%] after:w-[46%] after:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_24%,transparent),transparent_72%)] after:opacity-80 after:content-['']",
"[&>*]:relative [&>*]:z-[1]"
],
{
variants: {
padding: {
default: "p-4 sm:p-5",
flush: "p-0"
},
tone: {
default:
"border-[var(--ui-card-subtle-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))]",
subtle:
"border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)]",
accent:
"border-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_58%,white_42%),color-mix(in_oklch,var(--color-surface)_82%,white_18%))]",
inverse:
"border-white/10 bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-foreground)_72%,white_6%),color-mix(in_oklch,var(--color-foreground)_62%,var(--color-primary)_18%))]",
hero:
"border-white/10 bg-white/6 shadow-[inset_0_1px_0_rgba(255,255,255,0.09)]"
}
},
defaultVariants: {
padding: "default",
tone: "default"
}
}
);
export const metricCardActionsVariants = cva("flex flex-wrap items-center gap-3", {
variants: {
layout: {
inline: "justify-start",
stack: "flex-col items-stretch sm:flex-row sm:items-center"
}
},
defaultVariants: {
layout: "inline"
}
});
export const metricCardFooterVariants = cva("grid gap-3");
@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SegmentedControl, SegmentedControlItem } from "./segmented-control";
describe("SegmentedControl", () => {
it("switches the checked item when a segment is selected", async () => {
const user = userEvent.setup();
render(
<SegmentedControl aria-label="Revenue lens" defaultValue="sales">
<SegmentedControlItem value="sales">Sales</SegmentedControlItem>
<SegmentedControlItem value="support">Support</SegmentedControlItem>
</SegmentedControl>
);
const sales = screen.getByRole("radio", { name: "Sales" });
const support = screen.getByRole("radio", { name: "Support" });
expect(screen.getByRole("radiogroup")).toHaveAttribute("data-slot", "root");
expect(sales).toHaveAttribute("data-slot", "control");
expect(sales).toHaveAttribute("data-state", "checked");
expect(screen.getByText("Sales").closest('[data-slot="label"]')).toBeInTheDocument();
expect(sales.querySelector('[data-slot="indicator"]')).toBeTruthy();
await user.click(support);
expect(support).toHaveAttribute("data-state", "checked");
expect(sales).toHaveAttribute("data-state", "unchecked");
expect(support.querySelector('[data-slot="indicator"]')).toBeTruthy();
expect(sales.querySelector('[data-slot="indicator"]')).toBeNull();
});
it("supports controlled value changes", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
render(
<SegmentedControl aria-label="Forecast mode" onValueChange={onValueChange} value="sales">
<SegmentedControlItem value="sales">Sales</SegmentedControlItem>
<SegmentedControlItem value="forecast">Forecast</SegmentedControlItem>
</SegmentedControl>
);
await user.click(screen.getByRole("radio", { name: "Forecast" }));
expect(onValueChange).toHaveBeenCalledWith("forecast");
expect(screen.getByRole("radio", { name: "Sales" })).toHaveAttribute(
"data-state",
"checked"
);
});
it("preserves disabled segments and orientation data attributes", async () => {
const user = userEvent.setup();
render(
<SegmentedControl aria-label="Lane switcher" defaultValue="sales" orientation="vertical">
<SegmentedControlItem value="sales">Sales</SegmentedControlItem>
<SegmentedControlItem disabled value="support">
Support
</SegmentedControlItem>
</SegmentedControl>
);
const support = screen.getByRole("radio", { name: "Support" });
expect(screen.getByRole("radiogroup")).toHaveAttribute("data-orientation", "vertical");
await user.click(support);
expect(screen.getByRole("radio", { name: "Sales" })).toHaveAttribute("data-state", "checked");
expect(support).toHaveAttribute("data-disabled", "");
});
});
@@ -0,0 +1,161 @@
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import {
createContext,
forwardRef,
useContext,
useEffect,
useId,
useState,
type ComponentPropsWithoutRef,
type ElementRef
} from "react";
import {
segmentedControlIndicatorVariants,
segmentedControlItemVariants,
segmentedControlLabelVariants,
segmentedControlVariants
} from "./segmented-control.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
type SegmentedControlMotionContextValue = {
activeValue?: string;
disableMotion: boolean;
};
const SegmentedControlMotionContext = createContext<SegmentedControlMotionContextValue | null>(
null
);
function useStaticMotion() {
const prefersReducedMotion = useReducedMotion();
const [isStaticMotion, setIsStaticMotion] = useState(false);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const syncMotionMode = () => {
setIsStaticMotion(document.documentElement.dataset.motion === "static");
};
syncMotionMode();
const observer = new MutationObserver(syncMotionMode);
observer.observe(document.documentElement, {
attributeFilter: ["data-motion"]
});
return () => observer.disconnect();
}, []);
return prefersReducedMotion || isStaticMotion;
}
function useControllableStringState({
controlledValue,
defaultValue,
onChange
}: {
controlledValue?: string | null;
defaultValue?: string | null;
onChange?: (value: string) => void;
}) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? undefined);
const value = controlledValue ?? uncontrolledValue ?? undefined;
const setValue = (nextValue: string) => {
if (controlledValue === undefined) {
setUncontrolledValue(nextValue);
}
onChange?.(nextValue);
};
return [value, setValue] as const;
}
export type SegmentedControlProps =
ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> &
VariantProps<typeof segmentedControlVariants>;
export function SegmentedControl({
children,
className,
defaultValue,
onValueChange,
orientation = "horizontal",
value,
...props
}: SegmentedControlProps) {
const disableMotion = useStaticMotion();
const [currentValue, setCurrentValue] = useControllableStringState({
controlledValue: value,
defaultValue,
onChange: onValueChange
});
const layoutGroupId = useId();
return (
<SegmentedControlMotionContext.Provider
value={{ activeValue: currentValue ?? undefined, disableMotion }}
>
<LayoutGroup id={layoutGroupId}>
<RadioGroupPrimitive.Root
{...props}
{...createSlot("root")}
{...createDataAttributes({ orientation })}
className={cn(segmentedControlVariants({ orientation }), className)}
onValueChange={setCurrentValue}
orientation={orientation}
value={currentValue ?? undefined}
>
{children}
</RadioGroupPrimitive.Root>
</LayoutGroup>
</SegmentedControlMotionContext.Provider>
);
}
export type SegmentedControlItemProps =
ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>;
export const SegmentedControlItem = forwardRef<
ElementRef<typeof RadioGroupPrimitive.Item>,
SegmentedControlItemProps
>(function SegmentedControlItem({ children, className, disabled, value, ...props }, ref) {
const motionContext = useContext(SegmentedControlMotionContext);
const isActive = motionContext?.activeValue === value;
const transition = motionContext?.disableMotion
? { duration: 0.01 }
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const };
return (
<RadioGroupPrimitive.Item
{...props}
{...createSlot("control")}
{...createDataAttributes({ disabled })}
className={cn(segmentedControlItemVariants(), className)}
disabled={disabled}
ref={ref}
value={value}
>
{isActive && motionContext ? (
<motion.span
{...createSlot("indicator")}
aria-hidden="true"
className={segmentedControlIndicatorVariants()}
layoutId="active-pill"
transition={transition}
/>
) : null}
<span {...createSlot("label")} className={segmentedControlLabelVariants()}>
{children}
</span>
</RadioGroupPrimitive.Item>
);
});
@@ -0,0 +1,40 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const segmentedControlVariants = cva(
[
"inline-flex w-fit items-center gap-1 rounded-[calc(var(--ui-control-radius)+0.45rem)] border border-[var(--ui-control-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container)_88%,white_12%),color-mix(in_oklch,var(--color-surface-container-high)_84%,white_16%))] p-1 shadow-[var(--ui-control-shadow)] [border-width:var(--ui-input-border-width)]"
],
{
variants: {
orientation: {
horizontal: "flex-row",
vertical: "flex-col items-stretch"
}
},
defaultVariants: {
orientation: "horizontal"
}
}
);
export const segmentedControlItemVariants = cva([
"relative isolate inline-flex min-w-[5.5rem] cursor-pointer items-center justify-center gap-2 overflow-hidden whitespace-nowrap rounded-[calc(var(--ui-control-radius)+0.1rem)] px-3.5 py-2 text-sm font-medium outline-none",
"text-[var(--color-muted-foreground)] transition-[color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45",
"hover:-translate-y-px hover:text-[var(--color-foreground)]",
"data-[state=checked]:text-[var(--color-foreground)]",
getMotionRecipeClassNames("ring")
]);
export const segmentedControlIndicatorVariants = cva([
"pointer-events-none absolute inset-0 rounded-[inherit] border",
"border-[color-mix(in_oklch,var(--color-primary)_16%,var(--ui-control-border))]",
"bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_74%,white_26%),color-mix(in_oklch,var(--color-surface-container-high)_72%,white_28%))]",
"shadow-[inset_0_1px_0_color-mix(in_oklch,white_58%,transparent),0_10px_22px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]"
]);
export const segmentedControlLabelVariants = cva(
"relative z-[1] inline-flex items-center justify-center gap-2"
);
@@ -0,0 +1,98 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Sparkbar } from "./sparkbar";
describe("Sparkbar", () => {
it("renders the root and bar slots with highlighted range emphasis", () => {
render(
<Sparkbar
aria-label="Revenue trend"
columns={2}
highlightRange={[1, 3]}
tone="contrast"
values={[12, 48, 24, 60]}
variant="success"
/>
);
const sparkbar = screen.getByRole("img", { name: "Revenue trend" });
const bars = sparkbar.querySelectorAll('[data-slot="bar"]');
expect(sparkbar).toHaveAttribute("data-slot", "root");
expect(sparkbar).toHaveAttribute("data-bars", "4");
expect(sparkbar).toHaveAttribute("data-columns", "2");
expect(sparkbar).toHaveAttribute("data-state", "value");
expect(sparkbar).toHaveAttribute("data-tone", "contrast");
expect(sparkbar).toHaveAttribute("data-variant", "success");
expect(bars).toHaveLength(4);
expect(bars[0]).toHaveAttribute("data-state", "inactive");
expect(bars[1]).toHaveAttribute("data-state", "active");
expect(bars[1]).toHaveAttribute("data-active");
expect(bars[2]).toHaveAttribute("data-state", "active");
expect(bars[3]).toHaveAttribute("data-state", "active");
});
it("stays decorative by default and exposes a stable empty state", () => {
const { container } = render(<Sparkbar values={[null, undefined, null]} />);
const sparkbar = container.querySelector('[data-slot="root"]');
const bars = container.querySelectorAll('[data-slot="bar"]');
expect(sparkbar).toHaveAttribute("aria-hidden", "true");
expect(sparkbar).not.toHaveAttribute("role");
expect(sparkbar).toHaveAttribute("data-state", "empty");
expect(bars).toHaveLength(3);
expect(bars[0]).toHaveAttribute("data-state", "empty");
expect(bars[1]).toHaveAttribute("data-state", "empty");
});
it("switches to an image-like accessibility contract when an accessible name is provided", () => {
render(<Sparkbar aria-label="Revenue trend over the last six weeks" values={[4, 8, 6, 10]} />);
const sparkbar = screen.getByRole("img", {
name: "Revenue trend over the last six weeks"
});
expect(sparkbar).toHaveAttribute("aria-roledescription", "sparkbar");
expect(sparkbar).not.toHaveAttribute("aria-hidden");
});
it("uses the explicit height override as the bar height ceiling", () => {
const { container } = render(<Sparkbar height={64} values={[]} />);
const sparkbar = container.querySelector('[data-slot="root"]');
expect(sparkbar).toHaveAttribute("data-state", "empty");
expect(sparkbar).toHaveStyle({ "--ui-sparkbar-bar-max-height": "64px" });
expect(container.querySelectorAll('[data-slot="bar"]')).toHaveLength(0);
});
it("clamps values to the provided min and max range when calculating bar height", () => {
render(
<Sparkbar
aria-label="Capacity bars"
maxValue={80}
minValue={20}
values={[20, 50, 120]}
variant="warning"
/>
);
const sparkbar = screen.getByRole("img", { name: "Capacity bars" });
const bars = sparkbar.querySelectorAll('[data-slot="bar"]');
expect(bars[0]).toHaveStyle({
"--ui-sparkbar-bar-scale":
"calc(var(--ui-sparkbar-bar-min-ratio) + 0.0000 * (1 - var(--ui-sparkbar-bar-min-ratio)))"
});
expect(bars[1]).toHaveStyle({
"--ui-sparkbar-bar-scale":
"calc(var(--ui-sparkbar-bar-min-ratio) + 0.5000 * (1 - var(--ui-sparkbar-bar-min-ratio)))"
});
expect(bars[2]).toHaveStyle({
"--ui-sparkbar-bar-scale":
"calc(var(--ui-sparkbar-bar-min-ratio) + 1.0000 * (1 - var(--ui-sparkbar-bar-min-ratio)))"
});
});
});
+223
View File
@@ -0,0 +1,223 @@
import {
forwardRef,
type ComponentPropsWithoutRef,
type CSSProperties
} from "react";
import { sparkbarBarVariants, sparkbarVariants } from "./sparkbar.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
function normalizeValue(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
return Math.max(0, value);
}
function getResolvedColumns(columns: number | undefined, barCount: number) {
if (!Number.isFinite(columns)) {
return Math.max(barCount, 1);
}
return Math.max(1, Math.round(columns as number));
}
function getResolvedHighlightRange(
highlightRange: SparkbarHighlightRange | undefined,
barCount: number
) {
if (!highlightRange || barCount <= 0) {
return null;
}
const [startIndex, endIndex] = highlightRange;
const clampedStart = Math.max(0, Math.min(barCount - 1, Math.round(startIndex)));
const clampedEnd = Math.max(0, Math.min(barCount - 1, Math.round(endIndex)));
return clampedStart <= clampedEnd
? [clampedStart, clampedEnd]
: [clampedEnd, clampedStart];
}
function getResolvedMinValue(minValue: number | undefined) {
if (typeof minValue === "number" && Number.isFinite(minValue)) {
return Math.max(0, minValue);
}
return 0;
}
function getResolvedMaxValue(
values: readonly (number | null)[],
minValue: number,
maxValue: number | undefined
) {
if (typeof maxValue === "number" && Number.isFinite(maxValue) && maxValue >= minValue) {
return maxValue;
}
const computedMax = values.reduce<number>((currentMax, value) => {
if (value == null) {
return currentMax;
}
return Math.max(currentMax, value);
}, minValue);
return computedMax > minValue ? computedMax : minValue;
}
function getHeightValue(height: number | string | undefined) {
if (height == null) {
return undefined;
}
return typeof height === "number" ? `${height}px` : height;
}
function getBarScale(value: number | null, minValue: number, maxValue: number) {
if (value == null) {
return "var(--ui-sparkbar-bar-min-ratio)";
}
const clampedValue = Math.min(Math.max(value, minValue), maxValue);
const ratio =
maxValue > minValue ? (clampedValue - minValue) / (maxValue - minValue) : value > 0 ? 1 : 0;
return `calc(var(--ui-sparkbar-bar-min-ratio) + ${ratio.toFixed(4)} * (1 - var(--ui-sparkbar-bar-min-ratio)))`;
}
function hasAccessibleName({
ariaLabel,
ariaLabelledBy
}: {
ariaLabel: string | undefined;
ariaLabelledBy: string | undefined;
}) {
return Boolean(ariaLabel || ariaLabelledBy);
}
export type SparkbarHighlightRange = readonly [number, number];
export type SparkbarProps = Omit<ComponentPropsWithoutRef<"div">, "children"> &
VariantProps<typeof sparkbarVariants> &
VariantProps<typeof sparkbarBarVariants> & {
activeIndexes?: readonly number[];
columns?: number;
height?: number | string;
highlightRange?: SparkbarHighlightRange;
maxValue?: number;
minValue?: number;
values: readonly (number | null | undefined)[];
};
export const Sparkbar = forwardRef<HTMLDivElement, SparkbarProps>(function Sparkbar(
{
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
activeIndexes = [],
className,
columns,
height,
highlightRange,
maxValue,
minValue,
role,
size,
style,
tone,
values,
variant,
...props
},
ref
) {
const resolvedSize = size ?? "md";
const resolvedTone = tone ?? "default";
const resolvedVariant = variant ?? "default";
const normalizedValues = values.map((value) => normalizeValue(value));
const resolvedMinValue = getResolvedMinValue(minValue);
const resolvedMaxValue = getResolvedMaxValue(normalizedValues, resolvedMinValue, maxValue);
const resolvedColumns = getResolvedColumns(columns, normalizedValues.length);
const resolvedHighlightRange = getResolvedHighlightRange(highlightRange, normalizedValues.length);
const activeIndexSet = new Set(
activeIndexes
.filter((index) => Number.isFinite(index))
.map((index) => Math.max(0, Math.round(index)))
);
const state =
normalizedValues.length > 0 && normalizedValues.some((value) => value != null)
? "value"
: "empty";
const decorative = role == null && !hasAccessibleName({ ariaLabel, ariaLabelledBy });
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
bars: normalizedValues.length,
columns: resolvedColumns,
size: resolvedSize,
state,
tone: resolvedTone,
variant: resolvedVariant
})}
aria-hidden={decorative ? true : undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-roledescription={decorative || role != null ? undefined : "sparkbar"}
className={cn(sparkbarVariants({ size: resolvedSize, tone: resolvedTone }), className)}
ref={ref}
role={role ?? (decorative ? undefined : "img")}
style={
{
"--ui-sparkbar-bar-max-height": getHeightValue(height),
gridTemplateColumns: `repeat(${resolvedColumns}, minmax(0, 1fr))`,
...style
} as CSSProperties
}
>
{normalizedValues.map((normalizedValue, index) => {
const active =
normalizedValue != null &&
(activeIndexSet.has(index) ||
(resolvedHighlightRange != null &&
index >= resolvedHighlightRange[0] &&
index <= resolvedHighlightRange[1]));
const barState = normalizedValue == null ? "empty" : active ? "active" : "inactive";
return (
<span
key={`${index}-${normalizedValue ?? "empty"}`}
{...createSlot("bar")}
{...createDataAttributes({
active,
index,
state: barState,
value: normalizedValue ?? undefined
})}
aria-hidden="true"
className={cn(sparkbarBarVariants({ variant: resolvedVariant }))}
style={
{
"--ui-sparkbar-bar-bg": active ? undefined : "var(--ui-sparkbar-inactive-bg)",
"--ui-sparkbar-bar-shadow": active
? undefined
: "var(--ui-sparkbar-inactive-shadow)",
"--ui-sparkbar-bar-scale": getBarScale(
normalizedValue,
resolvedMinValue,
resolvedMaxValue
)
} as CSSProperties
}
/>
);
})}
</div>
);
});
@@ -0,0 +1,60 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const sparkbarVariants = cva(
[
"grid min-h-[var(--ui-sparkbar-bar-max-height)] w-full items-end gap-[var(--ui-sparkbar-gap)]",
getMotionRecipeClassNames("transition")
],
{
variants: {
size: {
sm:
"[--ui-sparkbar-bar-max-height:var(--ui-sparkbar-height-sm)] [--ui-sparkbar-bar-min-height:0.25rem] [--ui-sparkbar-bar-min-ratio:0.083333] [--ui-sparkbar-gap:var(--ui-sparkbar-gap-sm)]",
md:
"[--ui-sparkbar-bar-max-height:var(--ui-sparkbar-height-md)] [--ui-sparkbar-bar-min-height:0.3125rem] [--ui-sparkbar-bar-min-ratio:0.073529] [--ui-sparkbar-gap:var(--ui-sparkbar-gap-md)]",
lg:
"[--ui-sparkbar-bar-max-height:var(--ui-sparkbar-height-lg)] [--ui-sparkbar-bar-min-height:0.375rem] [--ui-sparkbar-bar-min-ratio:0.075] [--ui-sparkbar-gap:var(--ui-sparkbar-gap-lg)]"
},
tone: {
default:
"[--ui-sparkbar-inactive-bg:var(--ui-sparkbar-inactive-default-bg)] [--ui-sparkbar-inactive-shadow:var(--ui-sparkbar-inactive-default-shadow)]",
subtle:
"[--ui-sparkbar-inactive-bg:var(--ui-sparkbar-inactive-subtle-bg)] [--ui-sparkbar-inactive-shadow:var(--ui-sparkbar-inactive-subtle-shadow)]",
contrast:
"[--ui-sparkbar-inactive-bg:var(--ui-sparkbar-inactive-contrast-bg)] [--ui-sparkbar-inactive-shadow:var(--ui-sparkbar-inactive-contrast-shadow)]"
}
},
defaultVariants: {
size: "md",
tone: "default"
}
}
);
export const sparkbarBarVariants = cva(
[
"relative min-w-0 self-end h-[var(--ui-sparkbar-bar-max-height)] overflow-hidden rounded-[var(--ui-sparkbar-bar-radius)]",
"transition-[opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-emphasized)] motion-reduce:transition-none",
"before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-full before:origin-bottom before:rounded-[inherit] before:[background:var(--ui-sparkbar-bar-bg,var(--ui-sparkbar-active-bg))] before:shadow-[var(--ui-sparkbar-bar-shadow,var(--ui-sparkbar-active-shadow))] before:transition-[transform,background,box-shadow,opacity] before:duration-[var(--dur-base)] before:ease-[var(--ease-emphasized)] before:will-change-transform before:content-[''] motion-reduce:before:transition-none",
"before:[transform:scaleY(var(--ui-sparkbar-bar-scale,1))]",
"data-[state=active]:-translate-y-px data-[state=inactive]:opacity-92 data-[state=empty]:opacity-30 data-[state=empty]:before:opacity-76"
],
{
variants: {
variant: {
default:
"[--ui-sparkbar-active-bg:var(--ui-sparkbar-active-default-bg)] [--ui-sparkbar-active-shadow:var(--ui-sparkbar-active-default-shadow)]",
success:
"[--ui-sparkbar-active-bg:var(--ui-sparkbar-active-success-bg)] [--ui-sparkbar-active-shadow:var(--ui-sparkbar-active-success-shadow)]",
warning:
"[--ui-sparkbar-active-bg:var(--ui-sparkbar-active-warning-bg)] [--ui-sparkbar-active-shadow:var(--ui-sparkbar-active-warning-shadow)]",
destructive:
"[--ui-sparkbar-active-bg:var(--ui-sparkbar-active-destructive-bg)] [--ui-sparkbar-active-shadow:var(--ui-sparkbar-active-destructive-shadow)]"
}
},
defaultVariants: {
variant: "default"
}
}
);
@@ -0,0 +1,94 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
StatCard,
StatCardDelta,
StatCardDescription,
StatCardEyebrow,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue
} from "./stat-card";
describe("StatCard", () => {
it("renders the common KPI slots with default interactive metadata", () => {
render(
<StatCard data-testid="stat-card" tone="accent">
<StatCardHeader>
<StatCardEyebrow>Revenue</StatCardEyebrow>
<StatCardLabel>Monthly recurring revenue</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$101,820</StatCardValue>
<StatCardDelta tone="success">+8.4%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>Compared with the previous month.</StatCardDescription>
</StatCard>
);
const card = screen.getByTestId("stat-card");
expect(card).toHaveAttribute("data-tone", "accent");
expect(card).toHaveAttribute("data-interactive", "");
expect(card.className).toContain("hover:-translate-y-1.5");
expect(card.className).toContain("hover:scale-[1.012]");
expect(card.className).toContain("motion-reduce:hover:translate-y-0");
expect(screen.getByText("Revenue")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("Monthly recurring revenue")).toHaveAttribute("data-slot", "label");
expect(screen.getByText("$101,820")).toHaveAttribute("data-slot", "value");
expect(screen.getByText("$101,820").className).toContain("transition-[transform,color]");
expect(screen.getByText("+8.4%")).toHaveAttribute("data-slot", "delta");
expect(screen.getByText("+8.4%")).toHaveAttribute("data-tone", "success");
expect(screen.getByText("+8.4%").className).toContain(
"transition-[transform,background-color,border-color,box-shadow,color]"
);
expect(screen.getByText("Compared with the previous month.")).toHaveAttribute(
"data-slot",
"description"
);
});
it("supports className overrides on header and value group slots", () => {
render(
<StatCard tone="subtle">
<StatCardHeader className="items-start text-left">
<StatCardLabel>Qualified pipeline</StatCardLabel>
</StatCardHeader>
<StatCardMetric className="justify-between">
<StatCardValue className="text-left">$82,450</StatCardValue>
<StatCardDelta tone="warning">At risk</StatCardDelta>
</StatCardMetric>
</StatCard>
);
expect(screen.getByText("Qualified pipeline").closest('[data-slot="header"]')).toHaveClass(
"items-start"
);
expect(screen.getByText("$82,450").closest('[data-slot="metric"]')).toHaveClass(
"justify-between"
);
expect(screen.getByText("$82,450")).toHaveClass("text-left");
});
it("allows interactive polish to be turned off explicitly", () => {
render(
<StatCard data-testid="stat-card" interactive={false}>
<StatCardHeader>
<StatCardLabel>Qualified pipeline</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$82,450</StatCardValue>
<StatCardDelta tone="warning">At risk</StatCardDelta>
</StatCardMetric>
</StatCard>
);
const card = screen.getByTestId("stat-card");
expect(card).not.toHaveAttribute("data-interactive");
expect(card.className).not.toContain("hover:-translate-y-1.5");
expect(screen.getByText("At risk")).toHaveAttribute("data-tone", "warning");
});
});
+141
View File
@@ -0,0 +1,141 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import {
statCardDeltaVariants,
statCardDescriptionVariants,
statCardEyebrowVariants,
statCardHeaderVariants,
statCardLabelVariants,
statCardMetricVariants,
statCardValueVariants,
statCardVariants
} from "./stat-card.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type StatCardProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof statCardVariants>;
export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(function StatCard(
{ className, interactive = true, tone, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({ interactive, tone })}
className={cn(statCardVariants({ interactive, tone }), className)}
ref={ref}
/>
);
});
export type StatCardHeaderProps = ComponentPropsWithoutRef<"div">;
export const StatCardHeader = forwardRef<HTMLDivElement, StatCardHeaderProps>(
function StatCardHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(statCardHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardEyebrowProps = ComponentPropsWithoutRef<"p">;
export const StatCardEyebrow = forwardRef<HTMLParagraphElement, StatCardEyebrowProps>(
function StatCardEyebrow({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("eyebrow")}
className={cn(statCardEyebrowVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardLabelProps = ComponentPropsWithoutRef<"h3">;
export const StatCardLabel = forwardRef<HTMLHeadingElement, StatCardLabelProps>(
function StatCardLabel({ className, ...props }, ref) {
return (
<h3
{...props}
{...createSlot("label")}
className={cn(statCardLabelVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardMetricProps = ComponentPropsWithoutRef<"div">;
export const StatCardMetric = forwardRef<HTMLDivElement, StatCardMetricProps>(
function StatCardMetric({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("metric")}
className={cn(statCardMetricVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardValueProps = ComponentPropsWithoutRef<"p">;
export const StatCardValue = forwardRef<HTMLParagraphElement, StatCardValueProps>(
function StatCardValue({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("value")}
className={cn(statCardValueVariants(), className)}
ref={ref}
/>
);
}
);
export type StatCardDeltaProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof statCardDeltaVariants>;
export const StatCardDelta = forwardRef<HTMLDivElement, StatCardDeltaProps>(
function StatCardDelta({ className, tone, ...props }, ref) {
return (
<div
{...props}
{...createSlot("delta")}
{...createDataAttributes({ tone })}
className={cn(statCardDeltaVariants({ tone }), className)}
ref={ref}
/>
);
}
);
export type StatCardDescriptionProps = ComponentPropsWithoutRef<"p">;
export const StatCardDescription = forwardRef<
HTMLParagraphElement,
StatCardDescriptionProps
>(function StatCardDescription({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("description")}
className={cn(statCardDescriptionVariants(), className)}
ref={ref}
/>
);
});
@@ -0,0 +1,81 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const statCardVariants = cva(
[
"group relative isolate overflow-hidden grid gap-5 rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]",
"px-5 py-5 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] sm:px-6 sm:py-6",
"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-24 before:bg-[linear-gradient(180deg,color-mix(in_oklch,white_58%,transparent),transparent)] before:opacity-95 before:content-['']",
"after:pointer-events-none after:absolute after:right-[-2.5rem] after:top-[-2.25rem] after:size-28 after:rounded-full after:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_54%,transparent),transparent_68%)] after:opacity-70 after:content-['']",
"[&>*]:relative [&>*]:z-[1]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default:
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] after:opacity-62",
subtle:
"border-[var(--ui-card-subtle-border)] bg-[var(--ui-card-subtle-bg)] shadow-[var(--ui-card-subtle-shadow)] after:opacity-48",
accent:
"border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)] after:opacity-88"
},
interactive: {
false: "",
true:
"hover:-translate-y-1.5 hover:scale-[1.012] hover:shadow-[var(--ui-card-hover-shadow)] hover:[&_[data-slot=value]]:-translate-y-px hover:[&_[data-slot=value]]:text-[color-mix(in_oklch,var(--color-foreground)_92%,var(--color-primary)_8%)] hover:[&_[data-slot=delta]]:-translate-y-px hover:[&_[data-slot=delta]]:shadow-[0_14px_24px_color-mix(in_oklch,var(--color-primary)_12%,transparent)] motion-reduce:hover:translate-y-0 motion-reduce:hover:scale-100 motion-reduce:hover:[&_[data-slot=value]]:translate-y-0 motion-reduce:hover:[&_[data-slot=delta]]:translate-y-0"
}
},
defaultVariants: {
tone: "default",
interactive: true
}
}
);
export const statCardHeaderVariants = cva("grid gap-2");
export const statCardEyebrowVariants = cva(
"text-[0.72rem] font-semibold uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
);
export const statCardLabelVariants = cva(
"max-w-[20ch] text-base font-semibold leading-6 tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
);
export const statCardMetricVariants = cva("flex flex-wrap items-end gap-3.5 sm:gap-4");
export const statCardValueVariants = cva(
"text-[clamp(2.2rem,4vw,3.2rem)] font-semibold leading-none tracking-[var(--tracking-tight)] text-[var(--color-foreground)] transition-[transform,color] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
);
export const statCardDeltaVariants = cva(
[
"relative inline-flex min-h-7.5 items-center gap-1.5 rounded-[var(--radius-full)] border px-2.75 py-1 text-[0.72rem] font-semibold tracking-[0.01em]",
"before:size-1.5 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 before:content-['']",
"shadow-[var(--ui-control-shadow)] transition-[transform,background-color,border-color,box-shadow,color] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
],
{
variants: {
tone: {
neutral:
"border-[var(--ui-control-border)] bg-[color-mix(in_oklch,var(--ui-control-bg)_86%,white_14%)] text-[var(--color-muted-foreground)]",
primary:
"border-[color-mix(in_oklch,var(--color-primary)_28%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_72%,white_28%),color-mix(in_oklch,var(--color-primary)_8%,var(--color-card)))] text-[color-mix(in_oklch,var(--color-primary)_84%,var(--color-foreground))]",
success:
"border-[color-mix(in_oklch,var(--color-success)_30%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-success)_16%,white_84%),color-mix(in_oklch,var(--color-success)_12%,var(--color-card)))] text-[color-mix(in_oklch,var(--color-success)_76%,var(--color-foreground))]",
warning:
"border-[color-mix(in_oklch,var(--color-warning)_32%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-warning)_16%,white_84%),color-mix(in_oklch,var(--color-warning)_13%,var(--color-card)))] text-[color-mix(in_oklch,var(--color-warning)_78%,var(--color-foreground))]",
destructive:
"border-[color-mix(in_oklch,var(--color-destructive)_30%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-destructive)_14%,white_86%),color-mix(in_oklch,var(--color-destructive)_10%,var(--color-card)))] text-[var(--color-destructive)]"
}
},
defaultVariants: {
tone: "neutral"
}
}
);
export const statCardDescriptionVariants = cva(
"max-w-[32ch] text-sm leading-6 text-[var(--color-muted-foreground)]"
);
@@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
import { Field, FieldControl, FieldDescription, FieldError } from "./field";
import { Label } from "./label";
import {
ValueField,
ValueFieldPrefix,
ValueFieldSuffix,
ValueFieldValue
} from "./value-field";
describe("ValueField", () => {
it("renders value and affix slots with grouped state attributes", () => {
render(
<ValueField disabled invalid required size="lg">
<ValueFieldPrefix aria-hidden="true">#</ValueFieldPrefix>
<ValueFieldValue>ORBT-7X92-KLL9-001P</ValueFieldValue>
<ValueFieldSuffix>
<Button size="icon" type="button" variant="ghost">
copy
</Button>
</ValueFieldSuffix>
</ValueField>
);
const root = screen.getByText("ORBT-7X92-KLL9-001P").closest('[data-slot="root"]');
const value = screen.getByText("ORBT-7X92-KLL9-001P").closest('[data-slot="value"]');
expect(root).toHaveAttribute("data-disabled", "");
expect(root).toHaveAttribute("data-invalid", "");
expect(root).toHaveAttribute("data-readonly", "");
expect(root).toHaveAttribute("data-required", "");
expect(root).toHaveAttribute("data-size", "lg");
expect(screen.getByText("#").closest('[data-slot="prefix"]')).toBeInTheDocument();
expect(screen.getByRole("button", { name: "copy" }).closest('[data-slot="suffix"]')).toBeInTheDocument();
expect(value).toHaveAttribute("data-readonly", "");
expect(value).toHaveAttribute("data-size", "lg");
expect(value).toHaveAttribute("aria-disabled", "true");
expect(value).toHaveAttribute("aria-invalid", "true");
});
it("inherits field ids and described-by wiring for the displayed value", () => {
render(
<Field invalid required>
<Label requiredIndicator>Manual backup code</Label>
<FieldControl>
<ValueField>
<ValueFieldValue>ORBT-7X92-KLL9-001P</ValueFieldValue>
</ValueField>
<FieldDescription>Use this code if scanning is unavailable.</FieldDescription>
<FieldError>Backup code must remain visible.</FieldError>
</FieldControl>
</Field>
);
const value = screen.getByText("ORBT-7X92-KLL9-001P").closest('[data-slot="value"]');
const label = screen.getByText("Manual backup code").closest("label");
const description = screen.getByText("Use this code if scanning is unavailable.");
const error = screen.getByText("Backup code must remain visible.");
expect(value).toHaveAttribute("id");
expect(label).toHaveAttribute("for", value?.getAttribute("id"));
expect(value).toHaveAttribute("aria-describedby", `${description.id} ${error.id}`);
expect(value).toHaveAttribute("aria-invalid", "true");
});
});
+155
View File
@@ -0,0 +1,155 @@
import {
createContext,
forwardRef,
useContext,
type ComponentPropsWithoutRef
} from "react";
import {
valueFieldAffixVariants,
valueFieldValueVariants,
valueFieldVariants
} from "./value-field.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import type { FieldStateProps } from "../lib/contracts";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { useFieldContext } from "./field";
type ValueFieldContextValue = {
disabled: boolean;
invalid: boolean;
readOnly: boolean;
required: boolean;
size: Exclude<VariantProps<typeof valueFieldVariants>["size"], null | undefined>;
};
const ValueFieldContext = createContext<ValueFieldContextValue | null>(null);
function useValueFieldContext() {
return useContext(ValueFieldContext);
}
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
export type ValueFieldProps = ComponentPropsWithoutRef<"div"> &
FieldStateProps &
VariantProps<typeof valueFieldVariants>;
export const ValueField = forwardRef<HTMLDivElement, ValueFieldProps>(function ValueField(
{
className,
disabled,
invalid,
readOnly,
required,
size = "md",
...props
},
ref
) {
const field = useFieldContext();
const resolvedDisabled = disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? field?.invalid ?? false;
const resolvedReadOnly = readOnly ?? field?.readOnly ?? true;
const resolvedRequired = required ?? field?.required ?? false;
const resolvedSize = size ?? "md";
return (
<ValueFieldContext.Provider
value={{
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readOnly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
}}
>
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
})}
className={cn(valueFieldVariants({ size: resolvedSize }), className)}
ref={ref}
/>
</ValueFieldContext.Provider>
);
});
export type ValueFieldValueProps = ComponentPropsWithoutRef<"output"> &
VariantProps<typeof valueFieldValueVariants>;
export const ValueFieldValue = forwardRef<HTMLOutputElement, ValueFieldValueProps>(
function ValueFieldValue({ className, id, size, ...props }, ref) {
const field = useFieldContext();
const valueField = useValueFieldContext();
const resolvedInvalid = field?.invalid ?? valueField?.invalid ?? false;
const resolvedDisabled = field?.disabled ?? valueField?.disabled ?? false;
const resolvedReadOnly = field?.readOnly ?? valueField?.readOnly ?? true;
const resolvedRequired = field?.required ?? valueField?.required ?? false;
const resolvedSize = size ?? valueField?.size ?? "md";
return (
<output
{...props}
{...createSlot("value")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size: resolvedSize
})}
aria-describedby={mergeIds(
props["aria-describedby"],
field?.descriptionId,
resolvedInvalid ? field?.errorId : undefined
)}
aria-disabled={resolvedDisabled || undefined}
aria-invalid={resolvedInvalid || undefined}
className={cn(valueFieldValueVariants({ size: resolvedSize }), className)}
id={id ?? field?.inputId}
ref={ref}
/>
);
}
);
export type ValueFieldPrefixProps = ComponentPropsWithoutRef<"div">;
export const ValueFieldPrefix = forwardRef<HTMLDivElement, ValueFieldPrefixProps>(
function ValueFieldPrefix({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("prefix")}
className={cn(valueFieldAffixVariants(), className)}
ref={ref}
/>
);
}
);
export type ValueFieldSuffixProps = ComponentPropsWithoutRef<"div">;
export const ValueFieldSuffix = forwardRef<HTMLDivElement, ValueFieldSuffixProps>(
function ValueFieldSuffix({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("suffix")}
className={cn(valueFieldAffixVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,48 @@
import { cva } from "../lib/cva";
export const valueFieldVariants = cva(
[
"flex w-full min-w-0 items-center overflow-hidden rounded-[var(--ui-input-radius)] border",
"bg-[var(--ui-input-readonly-bg)] text-[var(--color-foreground)] shadow-[var(--shadow-xs)]",
"[border-width:var(--ui-input-border-width)] border-[var(--color-border)]",
"transition-[border-color,box-shadow,background-color] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"data-[disabled]:cursor-not-allowed data-[disabled]:text-[var(--color-muted-foreground)] data-[disabled]:opacity-72",
"data-[invalid]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
"data-[invalid]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]"
],
{
variants: {
size: {
sm: "min-h-10 gap-2.5 px-3",
md: "min-h-11 gap-3 px-4",
lg: "min-h-12 gap-3 px-4"
}
},
defaultVariants: {
size: "md"
}
}
);
export const valueFieldValueVariants = cva(
[
"min-w-0 flex-1 truncate whitespace-nowrap bg-transparent text-[var(--color-foreground)]",
"data-[disabled]:text-[var(--color-muted-foreground)]"
],
{
variants: {
size: {
sm: "text-sm",
md: "text-sm",
lg: "text-base"
}
},
defaultVariants: {
size: "md"
}
}
);
export const valueFieldAffixVariants = cva(
"flex shrink-0 items-center text-[var(--color-muted-foreground)] [&_svg]:size-4 [&_svg]:shrink-0"
);
+334
View File
@@ -96,6 +96,244 @@ export {
cardTitleVariants,
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,
+16
View File
@@ -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");
});
});
+107
View File
@@ -0,0 +1,107 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import {
appShellBodyVariants,
appShellFooterVariants,
appShellHeaderVariants,
appShellMainVariants,
appShellSidebarVariants,
appShellVariants
} from "./app-shell.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type AppShellProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof appShellVariants>;
export const AppShell = forwardRef<HTMLDivElement, AppShellProps>(function AppShell(
{ className, layout, sidebarWidth, surface, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({ layout, "sidebar-width": sidebarWidth, surface })}
className={cn(appShellVariants({ layout, sidebarWidth, surface }), className)}
ref={ref}
/>
);
});
export type AppShellSidebarProps = ComponentPropsWithoutRef<"aside">;
export const AppShellSidebar = forwardRef<HTMLElement, AppShellSidebarProps>(
function AppShellSidebar({ className, ...props }, ref) {
return (
<aside
{...props}
{...createSlot("sidebar")}
className={cn(appShellSidebarVariants(), className)}
ref={ref}
/>
);
}
);
export type AppShellBodyProps = ComponentPropsWithoutRef<"div">;
export const AppShellBody = forwardRef<HTMLDivElement, AppShellBodyProps>(
function AppShellBody({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("body")}
className={cn(appShellBodyVariants(), className)}
ref={ref}
/>
);
}
);
export type AppShellHeaderProps = ComponentPropsWithoutRef<"div">;
export const AppShellHeader = forwardRef<HTMLDivElement, AppShellHeaderProps>(
function AppShellHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(appShellHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type AppShellMainProps = ComponentPropsWithoutRef<"main">;
export const AppShellMain = forwardRef<HTMLElement, AppShellMainProps>(function AppShellMain(
{ className, ...props },
ref
) {
return (
<main
{...props}
{...createSlot("main")}
className={cn(appShellMainVariants(), className)}
ref={ref}
/>
);
});
export type AppShellFooterProps = ComponentPropsWithoutRef<"div">;
export const AppShellFooter = forwardRef<HTMLDivElement, AppShellFooterProps>(
function AppShellFooter({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(appShellFooterVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,49 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const appShellVariants = cva(
[
"relative isolate grid gap-4 text-[var(--color-foreground)]",
"[&>[data-slot]]:relative [&>[data-slot]]:z-[1]",
"[&>[data-slot=sidebar]]:transition-[transform,box-shadow] [&>[data-slot=sidebar]]:duration-[var(--dur-base)] [&>[data-slot=sidebar]]:ease-[var(--ease-standard)]",
"[&>[data-slot=body]]:transition-[transform,box-shadow] [&>[data-slot=body]]:duration-[var(--dur-base)] [&>[data-slot=body]]:ease-[var(--ease-standard)]",
"focus-within:[&>[data-slot=sidebar]]:-translate-y-px focus-within:[&>[data-slot=body]]:-translate-y-px",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
surface: {
default: "",
panel:
"overflow-hidden rounded-[2.35rem] border border-[color-mix(in_oklch,var(--color-outline-variant)_90%,white_10%)] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-surface)_84%,white_16%),color-mix(in_oklch,var(--color-surface-container-low)_90%,white_10%))] p-4 shadow-[0_28px_90px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] before:pointer-events-none before:absolute before:left-[7%] before:top-0 before:h-32 before:w-40 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_34%,transparent),transparent_72%)] before:opacity-72 before:blur-3xl before:content-[''] after:pointer-events-none after:absolute after:right-[4%] after:top-10 after:h-24 after:w-24 after:rounded-full after:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-tertiary-container)_24%,transparent),transparent_72%)] after:opacity-58 after:blur-3xl after:content-[''] focus-within:shadow-[0_34px_104px_color-mix(in_oklch,var(--color-primary)_12%,transparent)] lg:p-5"
},
layout: {
default: "",
sidebar: "xl:grid-cols-[18.5rem_minmax(0,1fr)]"
},
sidebarWidth: {
md: "xl:[grid-template-columns:16rem_minmax(0,1fr)]",
lg: "xl:[grid-template-columns:18.5rem_minmax(0,1fr)]",
xl: "xl:[grid-template-columns:20rem_minmax(0,1fr)]"
}
},
defaultVariants: {
surface: "default",
layout: "default"
}
}
);
export const appShellSidebarVariants = cva(
"min-w-0 self-start transition-[transform,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
);
export const appShellBodyVariants = cva(
"grid min-w-0 gap-4 transition-[transform,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
);
export const appShellHeaderVariants = cva("grid gap-4");
export const appShellMainVariants = cva("grid gap-4");
export const appShellFooterVariants = cva("grid gap-4");
@@ -0,0 +1,78 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ChallengeProgress } from "./challenge-progress";
describe("ChallengeProgress", () => {
it("renders the title, repeated rows, and progress semantics", () => {
render(
<ChallengeProgress
items={[
{
max: 8_000,
maxLabel: "$8,000",
resultValue: "$8,000",
statusLabel: "Passed",
statusTone: "success",
targetLabel: "Profit target",
targetValue: "$8,000",
value: 8_000,
variant: "success"
},
{
max: 10_000,
maxLabel: "$10,000",
progressLabel: "40%",
resultValue: "$4,000",
statusLabel: "Phase 2",
statusTone: "primary",
targetLabel: "Profit target",
targetValue: "$10,000",
value: 4_000
}
]}
title="Challenge progress"
/>
);
const root = screen.getByText("Challenge progress").closest('[data-slot="root"]');
const items = screen.getAllByText(/Profit target/).map((label) => label.closest('[data-slot="item"]'));
const completedProgress = screen.getByRole("progressbar", {
name: "Profit target $8,000 progress"
});
expect(root).toHaveAttribute("data-count", "2");
expect(screen.getByText("Challenge progress")).toHaveAttribute("data-slot", "title");
expect(items).toHaveLength(2);
expect(items[0]).toHaveAttribute("data-state", "complete");
expect(items[1]).toHaveAttribute("data-state", "loading");
expect(completedProgress).toHaveAttribute("data-pattern", "segmented");
expect(screen.getByText("Passed")).toHaveTextContent("Passed");
expect(screen.getByText("40%")).toHaveTextContent("40%");
});
it("falls back to indeterminate row state and pending label", () => {
render(
<ChallengeProgress
items={[
{
progressAriaLabel: "Review pending progress",
resultValue: "$0",
statusLabel: "Queued",
targetLabel: "Review target",
targetValue: "$5,000",
value: null
}
]}
title="Challenge progress"
/>
);
const progressbar = screen.getByRole("progressbar", { name: "Review pending progress" });
const item = progressbar.closest('[data-slot="item"]');
expect(item).toHaveAttribute("data-state", "indeterminate");
expect(progressbar).toHaveAttribute("data-state", "indeterminate");
expect(screen.getByText("Pending")).toHaveTextContent("Pending");
});
});
@@ -0,0 +1,277 @@
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from "react";
import {
challengeProgressBadgeIconVariants,
challengeProgressFooterVariants,
challengeProgressHeaderVariants,
challengeProgressIconVariants,
challengeProgressItemHeaderVariants,
challengeProgressItemVariants,
challengeProgressListVariants,
challengeProgressMaxVariants,
challengeProgressMeterVariants,
challengeProgressResultValueVariants,
challengeProgressResultVariants,
challengeProgressStatusVariants,
challengeProgressTargetValueVariants,
challengeProgressTargetVariants,
challengeProgressTitleVariants,
challengeProgressVariants
} from "./challenge-progress.variants";
import { Badge, type BadgeProps } from "../components/badge";
import { Progress, type ProgressProps } from "../components/progress";
import { Spinner } from "../components/spinner";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
function clampValue(value: number, max: number) {
return Math.min(Math.max(value, 0), max);
}
function getResolvedMax(max: number | undefined) {
return max && max > 0 ? max : 100;
}
function getItemState(value: number | null | undefined, max: number) {
if (value == null) {
return "indeterminate" as const;
}
return clampValue(value, max) >= max ? ("complete" as const) : ("loading" as const);
}
function getProgressLabel(value: number | null | undefined, max: number, progressLabel?: ReactNode) {
if (progressLabel !== undefined && progressLabel !== null) {
return progressLabel;
}
if (value == null) {
return "Pending";
}
return `${Math.round((clampValue(value, max) / max) * 100)}%`;
}
function getProgressVariant(
variant: ProgressProps["variant"] | undefined,
tone: BadgeProps["tone"] | undefined
): NonNullable<ProgressProps["variant"]> {
if (variant) {
return variant;
}
switch (tone) {
case "success":
return "success";
case "warning":
return "warning";
case "destructive":
return "destructive";
default:
return "default";
}
}
function getDefaultProgressAriaLabel(targetLabel: ReactNode, targetValue: ReactNode) {
if (typeof targetLabel === "string" && typeof targetValue === "string") {
return `${targetLabel} ${targetValue} progress`;
}
if (typeof targetLabel === "string") {
return `${targetLabel} progress`;
}
return "Challenge progress";
}
function StatusCheckIcon() {
return (
<svg aria-hidden="true" className="size-2.5" fill="none" viewBox="0 0 12 12">
<path
d="M2.5 6.25 4.75 8.5 9.5 3.75"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
/>
</svg>
);
}
function ProgressBadgeAdornment({ state }: { state: "complete" | "loading" | "indeterminate" }) {
if (state === "complete") {
return (
<span
{...createSlot("badge-icon")}
aria-hidden="true"
className={cn(
challengeProgressBadgeIconVariants(),
"bg-[color-mix(in_oklch,var(--color-success)_16%,white_84%)] text-[color-mix(in_oklch,var(--color-success)_72%,var(--color-foreground))]"
)}
>
<StatusCheckIcon />
</span>
);
}
if (state === "indeterminate") {
return (
<span
{...createSlot("badge-icon")}
aria-hidden="true"
className={cn(
challengeProgressBadgeIconVariants(),
"border border-[color-mix(in_oklch,var(--color-outline-variant)_78%,white_22%)] bg-[color-mix(in_oklch,var(--color-surface-container-highest)_76%,white_24%)]"
)}
/>
);
}
return <Spinner className="opacity-78" size="sm" tone="current" />;
}
export type ChallengeProgressItem = {
id?: string;
max?: number;
maxLabel?: ReactNode;
progressAriaLabel?: string;
progressLabel?: ReactNode;
progressTone?: BadgeProps["tone"];
progressVariant?: BadgeProps["variant"];
resultLabel?: ReactNode;
resultValue: ReactNode;
segmentCount?: number;
statusLabel: ReactNode;
statusTone?: BadgeProps["tone"];
statusVariant?: BadgeProps["variant"];
targetLabel: ReactNode;
targetValue: ReactNode;
tone?: ProgressProps["tone"];
value: number | null;
variant?: ProgressProps["variant"];
};
export type ChallengeProgressProps = ComponentPropsWithoutRef<"section"> & {
icon?: ReactNode;
items: readonly ChallengeProgressItem[];
title: ReactNode;
};
export const ChallengeProgress = forwardRef<HTMLElement, ChallengeProgressProps>(
function ChallengeProgress({ className, icon, items, title, ...props }, ref) {
return (
<section
{...props}
{...createSlot("root")}
{...createDataAttributes({ count: items.length })}
className={cn(challengeProgressVariants(), className)}
ref={ref}
>
<div {...createSlot("header")} className={challengeProgressHeaderVariants()}>
{icon ? (
<div {...createSlot("icon")} className={challengeProgressIconVariants()}>
{icon}
</div>
) : null}
<h3 {...createSlot("title")} className={challengeProgressTitleVariants()}>
{title}
</h3>
</div>
<div {...createSlot("list")} className={challengeProgressListVariants()}>
{items.map((item, index) => {
const resolvedMax = getResolvedMax(item.max);
const state = getItemState(item.value, resolvedMax);
const progressVariant = getProgressVariant(item.variant, item.statusTone);
const meterLabel = getProgressLabel(item.value, resolvedMax, item.progressLabel);
return (
<article
key={item.id ?? index}
{...createSlot("item")}
{...createDataAttributes({
state,
variant: progressVariant
})}
className={challengeProgressItemVariants({ state })}
>
<div
{...createSlot("item-header")}
className={challengeProgressItemHeaderVariants()}
>
<p {...createSlot("target")} className={challengeProgressTargetVariants()}>
{item.targetLabel}:{" "}
<span
{...createSlot("target-value")}
className={challengeProgressTargetValueVariants()}
>
{item.targetValue}
</span>
</p>
<div {...createSlot("status")} className={challengeProgressStatusVariants()}>
<Badge
className="rounded-[999px] px-3 py-1.5"
size="md"
tone={
item.statusTone ??
(state === "complete"
? "success"
: state === "indeterminate"
? "neutral"
: "primary")
}
variant={item.statusVariant ?? "subtle"}
>
{item.statusLabel}
</Badge>
<Badge
className="rounded-[999px] px-3 py-1.5 text-[color-mix(in_oklch,var(--color-muted-foreground)_90%,var(--color-foreground)_10%)]"
size="md"
tone={item.progressTone ?? "neutral"}
variant={item.progressVariant ?? "outline"}
>
<ProgressBadgeAdornment state={state} />
<span>{meterLabel}</span>
</Badge>
</div>
</div>
<div {...createSlot("meter")} className={challengeProgressMeterVariants()}>
<Progress
aria-label={
item.progressAriaLabel ??
getDefaultProgressAriaLabel(item.targetLabel, item.targetValue)
}
max={resolvedMax}
pattern="segmented"
segmentCount={item.segmentCount ?? 32}
size="lg"
tone={item.tone ?? "subtle"}
value={item.value}
variant={progressVariant}
/>
</div>
<div {...createSlot("footer")} className={challengeProgressFooterVariants()}>
<p {...createSlot("result")} className={challengeProgressResultVariants()}>
{item.resultLabel ?? "Result"}:{" "}
<span
{...createSlot("result-value")}
className={challengeProgressResultValueVariants()}
>
{item.resultValue}
</span>
</p>
<span {...createSlot("max")} className={challengeProgressMaxVariants()}>
{item.maxLabel ?? item.targetValue}
</span>
</div>
</article>
);
})}
</div>
</section>
);
}
);
@@ -0,0 +1,104 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const challengeProgressVariants = cva(
[
"grid gap-4 rounded-[2rem] border px-4 py-4 text-[var(--color-foreground)] sm:px-5 sm:py-5",
"border-[color-mix(in_oklch,var(--color-outline-variant)_82%,white_18%)]",
"bg-[radial-gradient(circle_at_top_left,color-mix(in_oklch,var(--color-primary-container)_52%,transparent),transparent_38%),linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_74%,white_26%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))]",
"shadow-[0_26px_72px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]",
getMotionRecipeClassNames("transition", "ring")
]
);
export const challengeProgressHeaderVariants = cva(
"flex items-center gap-3 px-1"
);
export const challengeProgressIconVariants = cva(
[
"flex size-11 shrink-0 items-center justify-center rounded-full border",
"border-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-border))]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_74%,white_26%),color-mix(in_oklch,var(--color-card)_82%,var(--color-primary-container)_18%))]",
"text-[var(--color-primary)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_64%,transparent),0_12px_24px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]"
]
);
export const challengeProgressTitleVariants = cva(
"[font-family:var(--font-display)] font-semibold tracking-[var(--tracking-tight)] text-[1.125rem] text-[var(--color-foreground)] sm:text-[1.25rem]"
);
export const challengeProgressListVariants = cva("grid gap-3");
export const challengeProgressItemVariants = cva(
[
"grid gap-4 rounded-[1.65rem] border px-4 py-4 shadow-[0_18px_36px_color-mix(in_oklch,var(--color-primary)_8%,transparent)]",
getMotionRecipeClassNames("transition")
],
{
variants: {
state: {
complete: [
"border-[color-mix(in_oklch,var(--color-success)_20%,var(--color-border))]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-tertiary-container)_56%,white_44%),color-mix(in_oklch,var(--color-card)_86%,var(--color-tertiary-container)_14%))]"
],
loading: [
"border-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-border))]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_42%,white_58%),color-mix(in_oklch,var(--color-card)_88%,var(--color-primary-container)_12%))]"
],
indeterminate: [
"border-[color-mix(in_oklch,var(--color-outline-variant)_84%,white_16%)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container)_78%,white_22%),color-mix(in_oklch,var(--color-card)_90%,var(--color-surface-container)_10%))]"
]
}
},
defaultVariants: {
state: "loading"
}
}
);
export const challengeProgressItemHeaderVariants = cva(
"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
);
export const challengeProgressTargetVariants = cva(
"text-sm leading-6 text-[var(--color-muted-foreground)]"
);
export const challengeProgressTargetValueVariants = cva(
"font-semibold text-[var(--color-foreground)]"
);
export const challengeProgressStatusVariants = cva(
"flex flex-wrap items-center gap-2 sm:justify-end"
);
export const challengeProgressBadgeIconVariants = cva(
"inline-flex size-4 shrink-0 items-center justify-center rounded-full"
);
export const challengeProgressMeterVariants = cva(
[
"rounded-[1.35rem] border p-1.5",
"border-[color-mix(in_oklch,var(--color-outline-variant)_84%,white_16%)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_80%,white_20%),color-mix(in_oklch,var(--color-surface)_84%,var(--color-surface-container-low)_16%))]",
"shadow-[inset_0_1px_0_color-mix(in_oklch,white_62%,transparent),0_12px_24px_color-mix(in_oklch,var(--color-primary)_8%,transparent)]"
]
);
export const challengeProgressFooterVariants = cva(
"flex items-center justify-between gap-4 text-sm leading-6"
);
export const challengeProgressResultVariants = cva(
"text-[var(--color-muted-foreground)]"
);
export const challengeProgressResultValueVariants = cva(
"font-semibold text-[var(--color-foreground)]"
);
export const challengeProgressMaxVariants = cva(
"font-medium text-[color-mix(in_oklch,var(--color-muted-foreground)_88%,var(--color-foreground)_12%)]"
);
@@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
PageFooter,
PageFooterActions,
PageFooterDescription,
PageFooterLeading,
PageFooterMeta,
PageFooterTitle
} from "./page-footer";
describe("PageFooter", () => {
it("renders the footer slots and tone contract", () => {
render(
<PageFooter data-testid="page-footer" tone="accent">
<PageFooterLeading>
<PageFooterMeta>Synced</PageFooterMeta>
<PageFooterTitle>Ready for review</PageFooterTitle>
<PageFooterDescription>All supporting signals are up to date.</PageFooterDescription>
</PageFooterLeading>
<PageFooterActions>
<button type="button">Open audit log</button>
</PageFooterActions>
</PageFooter>
);
expect(screen.getByTestId("page-footer")).toHaveAttribute("data-tone", "accent");
expect(screen.getByTestId("page-footer")).toHaveClass("motion-transition");
expect(screen.getByText("Synced")).toHaveAttribute("data-slot", "meta");
expect(screen.getByText("Ready for review")).toHaveAttribute("data-slot", "title");
expect(screen.getByText("All supporting signals are up to date.")).toHaveAttribute(
"data-slot",
"description"
);
expect(screen.getByRole("button", { name: "Open audit log" }).closest('[data-slot="actions"]')).toHaveClass(
"motion-safe:[&>*:hover]:-translate-y-px"
);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import {
pageFooterActionsVariants,
pageFooterDescriptionVariants,
pageFooterLeadingVariants,
pageFooterMetaVariants,
pageFooterTitleVariants,
pageFooterVariants
} from "./page-footer.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type PageFooterProps = ComponentPropsWithoutRef<"footer"> &
VariantProps<typeof pageFooterVariants>;
export const PageFooter = forwardRef<HTMLElement, PageFooterProps>(function PageFooter(
{ className, tone, ...props },
ref
) {
return (
<footer
{...props}
{...createSlot("root")}
{...createDataAttributes({ tone })}
className={cn(pageFooterVariants({ tone }), className)}
ref={ref}
/>
);
});
export type PageFooterLeadingProps = ComponentPropsWithoutRef<"div">;
export const PageFooterLeading = forwardRef<HTMLDivElement, PageFooterLeadingProps>(
function PageFooterLeading({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("leading")}
className={cn(pageFooterLeadingVariants(), className)}
ref={ref}
/>
);
}
);
export type PageFooterMetaProps = ComponentPropsWithoutRef<"div">;
export const PageFooterMeta = forwardRef<HTMLDivElement, PageFooterMetaProps>(
function PageFooterMeta({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("meta")}
className={cn(pageFooterMetaVariants(), className)}
ref={ref}
/>
);
}
);
export type PageFooterActionsProps = ComponentPropsWithoutRef<"div">;
export const PageFooterActions = forwardRef<HTMLDivElement, PageFooterActionsProps>(
function PageFooterActions({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
className={cn(pageFooterActionsVariants(), className)}
ref={ref}
/>
);
}
);
export type PageFooterTitleProps = ComponentPropsWithoutRef<"p">;
export const PageFooterTitle = forwardRef<HTMLParagraphElement, PageFooterTitleProps>(
function PageFooterTitle({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("title")}
className={cn(pageFooterTitleVariants(), className)}
ref={ref}
/>
);
}
);
export type PageFooterDescriptionProps = ComponentPropsWithoutRef<"p">;
export const PageFooterDescription = forwardRef<
HTMLParagraphElement,
PageFooterDescriptionProps
>(function PageFooterDescription({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("description")}
className={cn(pageFooterDescriptionVariants(), className)}
ref={ref}
/>
);
});
@@ -0,0 +1,46 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const pageFooterVariants = cva(
[
"relative isolate grid gap-4 overflow-hidden rounded-[calc(var(--ui-card-radius)-0.25rem)] border [border-width:var(--ui-card-border-width)]",
"px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.42)] sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center sm:px-5",
"before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-20 before:w-28 before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_24%,transparent),transparent_72%)] before:opacity-68 before:blur-3xl before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"motion-safe:hover:-translate-y-px focus-within:-translate-y-[0.5px] motion-reduce:hover:translate-y-0",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default:
"border-[color-mix(in_oklch,var(--color-border)_62%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container)_82%,white_18%))]",
subtle:
"border-[color-mix(in_oklch,var(--color-border)_52%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)]",
accent:
"border-[color-mix(in_oklch,var(--color-primary)_18%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_28%,white_72%),color-mix(in_oklch,var(--color-surface-container)_82%,white_18%))]"
}
},
defaultVariants: {
tone: "default"
}
}
);
export const pageFooterLeadingVariants = cva("grid gap-2");
export const pageFooterMetaVariants = cva(
"flex flex-wrap items-center gap-2.5 [&>*]:transition-[transform,box-shadow,background-color,border-color,opacity] [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)] motion-safe:[&>*:hover]:-translate-y-px"
);
export const pageFooterActionsVariants = cva(
"flex flex-wrap items-center gap-3 [&>*]:transition-[transform,box-shadow,background-color,border-color,opacity] [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)] motion-safe:[&>*:hover]:-translate-y-px"
);
export const pageFooterTitleVariants = cva(
"text-sm font-medium text-[var(--color-foreground)] sm:text-[0.95rem]"
);
export const pageFooterDescriptionVariants = cva(
"text-sm leading-6 text-[var(--color-muted-foreground)]"
);
@@ -0,0 +1,56 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderEyebrow,
PageHeaderLeading,
PageHeaderMeta,
PageHeaderTitle
} from "./page-header";
describe("PageHeader", () => {
it("renders the public header slots and root layout contract", () => {
render(
<PageHeader align="end" density="compact" variant="compact">
<PageHeaderLeading>
<PageHeaderMeta>
<PageHeaderEyebrow>Friday</PageHeaderEyebrow>
</PageHeaderMeta>
<PageHeaderTitle>Welcome back</PageHeaderTitle>
<PageHeaderDescription>Revenue operations is holding a calm pulse today.</PageHeaderDescription>
</PageHeaderLeading>
<PageHeaderActions>
<button type="button">Morning brief</button>
</PageHeaderActions>
</PageHeader>
);
expect(screen.getByText("Friday")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("Welcome back")).toHaveAttribute("data-slot", "title");
expect(screen.getByText("Welcome back").closest('[data-slot="root"]')).toHaveAttribute(
"data-variant",
"compact"
);
expect(screen.getByText("Welcome back").closest('[data-slot="root"]')).toHaveClass(
"motion-transition"
);
expect(screen.getByText("Welcome back").closest('[data-slot="root"]')).toHaveAttribute(
"data-density",
"compact"
);
expect(screen.getByText("Welcome back").closest('[data-slot="root"]')).toHaveAttribute(
"data-align",
"end"
);
expect(screen.getByText("Revenue operations is holding a calm pulse today.")).toHaveAttribute(
"data-slot",
"description"
);
expect(screen.getByRole("button", { name: "Morning brief" }).closest('[data-slot="actions"]')).toHaveClass(
"motion-safe:[&>*:hover]:-translate-y-px"
);
});
});
+203
View File
@@ -0,0 +1,203 @@
import {
createContext,
forwardRef,
useContext,
type ComponentPropsWithoutRef,
type CSSProperties
} from "react";
import {
pageHeaderActionsVariants,
pageHeaderDescriptionVariants,
pageHeaderEyebrowVariants,
pageHeaderLeadingVariants,
pageHeaderMetaVariants,
pageHeaderTitleVariants,
pageHeaderVariants
} from "./page-header.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
type PageHeaderContextValue = {
align: Exclude<VariantProps<typeof pageHeaderVariants>["align"], null | undefined>;
density: Exclude<VariantProps<typeof pageHeaderVariants>["density"], null | undefined>;
variant: Exclude<VariantProps<typeof pageHeaderVariants>["variant"], null | undefined>;
};
const PageHeaderContext = createContext<PageHeaderContextValue | null>(null);
function usePageHeaderContext() {
return useContext(PageHeaderContext);
}
export type PageHeaderProps = ComponentPropsWithoutRef<"section"> &
VariantProps<typeof pageHeaderVariants>;
export const PageHeader = forwardRef<HTMLElement, PageHeaderProps>(function PageHeader(
{
align = "start",
className,
density = "comfortable",
variant = "default",
...props
},
ref
) {
const resolvedAlign = align ?? "start";
const resolvedDensity = density ?? "comfortable";
const resolvedVariant = variant ?? "default";
return (
<PageHeaderContext.Provider
value={{
align: resolvedAlign,
density: resolvedDensity,
variant: resolvedVariant
}}
>
<section
{...props}
{...createSlot("root")}
{...createDataAttributes({
align: resolvedAlign,
density: resolvedDensity,
variant: resolvedVariant
})}
className={cn(
pageHeaderVariants({
align: resolvedAlign,
density: resolvedDensity,
variant: resolvedVariant
}),
className
)}
ref={ref}
/>
</PageHeaderContext.Provider>
);
});
export type PageHeaderLeadingProps = ComponentPropsWithoutRef<"div">;
export const PageHeaderLeading = forwardRef<HTMLDivElement, PageHeaderLeadingProps>(
function PageHeaderLeading({ className, ...props }, ref) {
const context = usePageHeaderContext();
return (
<div
{...props}
{...createSlot("leading")}
className={cn(
pageHeaderLeadingVariants({
density: context?.density,
variant: context?.variant
}),
className
)}
ref={ref}
/>
);
}
);
export type PageHeaderMetaProps = ComponentPropsWithoutRef<"div">;
export const PageHeaderMeta = forwardRef<HTMLDivElement, PageHeaderMetaProps>(
function PageHeaderMeta({ className, ...props }, ref) {
const context = usePageHeaderContext();
return (
<div
{...props}
{...createSlot("meta")}
className={cn(pageHeaderMetaVariants({ density: context?.density }), className)}
ref={ref}
/>
);
}
);
export type PageHeaderActionsProps = ComponentPropsWithoutRef<"div">;
export const PageHeaderActions = forwardRef<HTMLDivElement, PageHeaderActionsProps>(
function PageHeaderActions({ className, ...props }, ref) {
const context = usePageHeaderContext();
return (
<div
{...props}
{...createSlot("actions")}
className={cn(
pageHeaderActionsVariants({
align: context?.align,
density: context?.density
}),
className
)}
ref={ref}
/>
);
}
);
export type PageHeaderEyebrowProps = ComponentPropsWithoutRef<"p">;
export const PageHeaderEyebrow = forwardRef<HTMLParagraphElement, PageHeaderEyebrowProps>(
function PageHeaderEyebrow({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("eyebrow")}
className={cn(pageHeaderEyebrowVariants(), className)}
ref={ref}
/>
);
}
);
export type PageHeaderTitleProps = ComponentPropsWithoutRef<"h1">;
export const PageHeaderTitle = forwardRef<HTMLHeadingElement, PageHeaderTitleProps>(
function PageHeaderTitle({ className, style, ...props }, ref) {
const context = usePageHeaderContext();
const resolvedStyle: CSSProperties = {
fontFamily: "var(--font-display)",
...(style ?? {})
};
return (
<h1
{...props}
{...createSlot("title")}
className={cn(
pageHeaderTitleVariants({
density: context?.density,
variant: context?.variant
}),
className
)}
ref={ref}
style={resolvedStyle}
/>
);
}
);
export type PageHeaderDescriptionProps = ComponentPropsWithoutRef<"p">;
export const PageHeaderDescription = forwardRef<
HTMLParagraphElement,
PageHeaderDescriptionProps
>(function PageHeaderDescription({ className, ...props }, ref) {
const context = usePageHeaderContext();
return (
<p
{...props}
{...createSlot("description")}
className={cn(pageHeaderDescriptionVariants({ variant: context?.variant }), className)}
ref={ref}
/>
);
});
@@ -0,0 +1,141 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const pageHeaderVariants = cva(
[
"relative isolate grid",
"[&>*]:relative [&>*]:z-[1]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
variant: {
hero: [
"xl:grid-cols-[minmax(0,1fr)_auto]",
"before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-28 before:w-36 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_44%,transparent),transparent_72%)] before:opacity-80 before:blur-3xl before:content-['']"
],
default: [
"lg:grid-cols-[minmax(0,1fr)_auto]",
"before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-20 before:w-28 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_30%,transparent),transparent_72%)] before:opacity-72 before:blur-3xl before:content-['']"
],
compact:
"lg:grid-cols-[minmax(0,1fr)_auto] before:pointer-events-none before:absolute before:left-0 before:top-0 before:h-16 before:w-20 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_18%,transparent),transparent_72%)] before:opacity-55 before:blur-3xl before:content-['']"
},
density: {
comfortable: "gap-4",
compact: "gap-3"
},
align: {
start: "lg:items-start",
end: "lg:items-end"
}
},
defaultVariants: {
variant: "default",
density: "comfortable",
align: "start"
}
}
);
export const pageHeaderLeadingVariants = cva("grid", {
variants: {
variant: {
hero: "max-w-4xl",
default: "max-w-3xl",
compact: "max-w-2xl"
},
density: {
comfortable: "gap-3",
compact: "gap-2"
}
},
defaultVariants: {
variant: "default",
density: "comfortable"
}
});
export const pageHeaderMetaVariants = cva(
[
"flex flex-wrap items-center",
"[&>*]:transition-[transform,box-shadow,background-color,border-color,opacity] [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)]",
"motion-safe:[&>*:hover]:-translate-y-px"
],
{
variants: {
density: {
comfortable: "gap-3",
compact: "gap-2"
}
},
defaultVariants: {
density: "comfortable"
}
}
);
export const pageHeaderActionsVariants = cva(
[
"flex flex-wrap items-center",
"[&>*]:transition-[transform,box-shadow,background-color,border-color,opacity] [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)]",
"motion-safe:[&>*:hover]:-translate-y-px"
],
{
variants: {
density: {
comfortable: "gap-3",
compact: "gap-2"
},
align: {
start: "justify-start lg:justify-self-start",
end: "justify-start lg:justify-self-end"
}
},
defaultVariants: {
density: "comfortable",
align: "start"
}
}
);
export const pageHeaderEyebrowVariants = cva(
"text-sm uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
);
export const pageHeaderTitleVariants = cva(
"font-semibold text-[var(--color-foreground)]",
{
variants: {
variant: {
hero: "text-[clamp(2.75rem,5vw,4.7rem)] leading-[0.96] tracking-[-0.06em]",
default: "text-[clamp(2rem,3.6vw,3.15rem)] leading-[1] tracking-[-0.045em]",
compact: "text-[clamp(1.45rem,2.2vw,2.1rem)] leading-[1.06] tracking-[-0.03em]"
},
density: {
comfortable: "",
compact: "leading-[1.04]"
}
},
defaultVariants: {
variant: "default",
density: "comfortable"
}
}
);
export const pageHeaderDescriptionVariants = cva(
"text-[var(--color-muted-foreground)]",
{
variants: {
variant: {
hero: "max-w-3xl text-[1.02rem] leading-7",
default: "max-w-2xl text-[0.98rem] leading-7",
compact: "max-w-xl text-sm leading-6"
}
},
defaultVariants: {
variant: "default"
}
}
);
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
SidebarNav,
SidebarNavContent,
SidebarNavFooter,
SidebarNavHeader,
SidebarNavItem,
SidebarNavItemBadge,
SidebarNavItemIcon,
SidebarNavItemLabel,
SidebarNavItems,
SidebarNavSection,
SidebarNavSectionLabel
} from "./sidebar-nav";
describe("SidebarNav", () => {
it("renders the public rail slots and active item contract", () => {
render(
<SidebarNav data-testid="sidebar-nav" tone="accent">
<SidebarNavHeader>Header</SidebarNavHeader>
<SidebarNavContent>
<SidebarNavSection>
<SidebarNavSectionLabel>Main</SidebarNavSectionLabel>
<SidebarNavItems>
<SidebarNavItem active>
<SidebarNavItemIcon>O</SidebarNavItemIcon>
<SidebarNavItemLabel>Overview</SidebarNavItemLabel>
<SidebarNavItemBadge>2</SidebarNavItemBadge>
</SidebarNavItem>
</SidebarNavItems>
</SidebarNavSection>
</SidebarNavContent>
<SidebarNavFooter>Footer</SidebarNavFooter>
</SidebarNav>
);
expect(screen.getByTestId("sidebar-nav")).toHaveAttribute("data-tone", "accent");
expect(screen.getByText("Header")).toHaveAttribute("data-slot", "header");
expect(screen.getByText("Main")).toHaveAttribute("data-slot", "section-label");
expect(screen.getByText("Overview").closest('[data-slot="item"]')).toHaveAttribute(
"data-active",
""
);
expect(screen.getByText("2")).toHaveAttribute("data-slot", "badge");
expect(screen.getByText("Footer")).toHaveAttribute("data-slot", "footer");
});
});
+202
View File
@@ -0,0 +1,202 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from "react";
import {
sidebarNavContentVariants,
sidebarNavFooterVariants,
sidebarNavHeaderVariants,
sidebarNavItemBadgeVariants,
sidebarNavItemIconVariants,
sidebarNavItemLabelVariants,
sidebarNavItemsVariants,
sidebarNavItemVariants,
sidebarNavSectionLabelVariants,
sidebarNavSectionVariants,
sidebarNavVariants
} from "./sidebar-nav.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot, type AsChildProp } from "../lib/contracts";
export type SidebarNavProps = ComponentPropsWithoutRef<"aside"> &
VariantProps<typeof sidebarNavVariants>;
export const SidebarNav = forwardRef<HTMLElement, SidebarNavProps>(function SidebarNav(
{ className, tone, ...props },
ref
) {
return (
<aside
{...props}
{...createSlot("root")}
{...createDataAttributes({ tone })}
className={cn(sidebarNavVariants({ tone }), className)}
ref={ref}
/>
);
});
export type SidebarNavHeaderProps = ComponentPropsWithoutRef<"div">;
export const SidebarNavHeader = forwardRef<HTMLDivElement, SidebarNavHeaderProps>(
function SidebarNavHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(sidebarNavHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavContentProps = ComponentPropsWithoutRef<"div">;
export const SidebarNavContent = forwardRef<HTMLDivElement, SidebarNavContentProps>(
function SidebarNavContent({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("content")}
className={cn(sidebarNavContentVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavFooterProps = ComponentPropsWithoutRef<"div">;
export const SidebarNavFooter = forwardRef<HTMLDivElement, SidebarNavFooterProps>(
function SidebarNavFooter({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(sidebarNavFooterVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavSectionProps = ComponentPropsWithoutRef<"section">;
export const SidebarNavSection = forwardRef<HTMLElement, SidebarNavSectionProps>(
function SidebarNavSection({ className, ...props }, ref) {
return (
<section
{...props}
{...createSlot("section")}
className={cn(sidebarNavSectionVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavSectionLabelProps = ComponentPropsWithoutRef<"p">;
export const SidebarNavSectionLabel = forwardRef<
HTMLParagraphElement,
SidebarNavSectionLabelProps
>(function SidebarNavSectionLabel({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("section-label")}
className={cn(sidebarNavSectionLabelVariants(), className)}
ref={ref}
/>
);
});
export type SidebarNavItemsProps = ComponentPropsWithoutRef<"div">;
export const SidebarNavItems = forwardRef<HTMLDivElement, SidebarNavItemsProps>(
function SidebarNavItems({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("items")}
className={cn(sidebarNavItemsVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavItemProps = Omit<ComponentPropsWithoutRef<"button">, "children"> &
AsChildProp &
VariantProps<typeof sidebarNavItemVariants> & {
children?: ReactNode;
};
export const SidebarNavItem = forwardRef<HTMLButtonElement, SidebarNavItemProps>(
function SidebarNavItem(
{ active, asChild = false, children, className, disabled, type, ...props },
ref
) {
const Component = asChild ? Slot : "button";
return (
<Component
{...props}
{...createSlot("item")}
{...createDataAttributes({ active, disabled })}
className={cn(sidebarNavItemVariants({ active }), className)}
disabled={asChild ? undefined : disabled}
ref={ref}
type={asChild ? undefined : type ?? "button"}
>
{asChild ? <Slottable>{children}</Slottable> : children}
</Component>
);
}
);
export type SidebarNavItemIconProps = ComponentPropsWithoutRef<"span">;
export const SidebarNavItemIcon = forwardRef<HTMLSpanElement, SidebarNavItemIconProps>(
function SidebarNavItemIcon({ className, ...props }, ref) {
return (
<span
{...props}
{...createSlot("icon")}
className={cn(sidebarNavItemIconVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavItemLabelProps = ComponentPropsWithoutRef<"span">;
export const SidebarNavItemLabel = forwardRef<HTMLSpanElement, SidebarNavItemLabelProps>(
function SidebarNavItemLabel({ className, ...props }, ref) {
return (
<span
{...props}
{...createSlot("label")}
className={cn(sidebarNavItemLabelVariants(), className)}
ref={ref}
/>
);
}
);
export type SidebarNavItemBadgeProps = ComponentPropsWithoutRef<"span">;
export const SidebarNavItemBadge = forwardRef<HTMLSpanElement, SidebarNavItemBadgeProps>(
function SidebarNavItemBadge({ className, ...props }, ref) {
return (
<span
{...props}
{...createSlot("badge")}
className={cn(sidebarNavItemBadgeVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,79 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const sidebarNavVariants = cva(
[
"relative isolate flex h-full flex-col gap-7 overflow-hidden rounded-[calc(var(--ui-card-radius)+0.55rem)] border [border-width:var(--ui-card-border-width)]",
"px-5 py-5 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] sm:px-6 sm:py-6",
"before:pointer-events-none before:absolute before:left-6 before:top-0 before:h-24 before:w-24 before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_48%,transparent),transparent_72%)] before:blur-3xl before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default:
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] shadow-[var(--ui-card-default-shadow)]",
subtle:
"border-[var(--ui-card-subtle-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_84%,white_16%),color-mix(in_oklch,var(--color-surface)_88%,white_12%))] shadow-[var(--ui-card-subtle-shadow)]",
accent:
"border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)]"
}
},
defaultVariants: {
tone: "subtle"
}
}
);
export const sidebarNavHeaderVariants = cva("grid gap-5");
export const sidebarNavContentVariants = cva("grid gap-6");
export const sidebarNavFooterVariants = cva("mt-auto");
export const sidebarNavSectionVariants = cva("grid gap-2.5");
export const sidebarNavSectionLabelVariants = cva(
"px-2 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
);
export const sidebarNavItemsVariants = cva("grid gap-1.5");
export const sidebarNavItemVariants = cva(
[
"relative isolate flex min-h-12 w-full items-center gap-3 rounded-[1.1rem] px-3.5 py-3 text-left text-[0.95rem] outline-none",
"transition-[background-color,color,box-shadow,transform,border-color] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45",
"[&>[data-slot=icon]]:flex [&>[data-slot=icon]]:size-7 [&>[data-slot=icon]]:shrink-0 [&>[data-slot=icon]]:items-center [&>[data-slot=icon]]:justify-center [&>[data-slot=icon]]:rounded-[0.85rem]",
"[&>[data-slot=icon]]:transition-[background-color,color,transform] [&>[data-slot=icon]]:duration-[var(--dur-base)] [&>[data-slot=icon]]:ease-[var(--ease-standard)]",
"[&>[data-slot=label]]:min-w-0 [&>[data-slot=label]]:flex-1 [&>[data-slot=label]]:text-left",
"[&>[data-slot=badge]]:shrink-0"
],
{
variants: {
active: {
false: [
"text-[var(--color-muted-foreground)]",
"hover:-translate-y-px hover:bg-[color-mix(in_oklch,var(--color-surface)_88%,white_12%)] hover:text-[var(--color-foreground)]",
"[&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-surface)_88%,white_12%)] [&>[data-slot=icon]]:text-[var(--color-muted-foreground)]"
],
true: [
"bg-[var(--color-foreground)] text-[var(--color-background)] shadow-[0_20px_36px_color-mix(in_oklch,var(--color-foreground)_18%,transparent)]",
"hover:bg-[color-mix(in_oklch,var(--color-foreground)_94%,white_6%)]",
"[&>[data-slot=icon]]:bg-white/12 [&>[data-slot=icon]]:text-white"
]
}
},
defaultVariants: {
active: false
}
}
);
export const sidebarNavItemIconVariants = cva("");
export const sidebarNavItemLabelVariants = cva("truncate");
export const sidebarNavItemBadgeVariants = cva("inline-flex items-center");
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
WorkspaceToolbar,
WorkspaceToolbarActions,
WorkspaceToolbarContent,
WorkspaceToolbarFilters,
WorkspaceToolbarLeading,
WorkspaceToolbarSearch,
WorkspaceToolbarStatus
} from "./workspace-toolbar";
describe("WorkspaceToolbar", () => {
it("renders the public toolbar slots and surface contract", () => {
render(
<WorkspaceToolbar data-testid="workspace-toolbar" surface="panel">
<WorkspaceToolbarLeading>Release desk</WorkspaceToolbarLeading>
<WorkspaceToolbarContent>
<WorkspaceToolbarSearch>Search</WorkspaceToolbarSearch>
<WorkspaceToolbarFilters>Filters</WorkspaceToolbarFilters>
<WorkspaceToolbarStatus>Status</WorkspaceToolbarStatus>
<WorkspaceToolbarActions>
<button type="button">Export board</button>
</WorkspaceToolbarActions>
</WorkspaceToolbarContent>
</WorkspaceToolbar>
);
expect(screen.getByTestId("workspace-toolbar")).toHaveAttribute("data-surface", "panel");
expect(screen.getByText("Release desk")).toHaveAttribute("data-slot", "leading");
expect(screen.getByText("Search")).toHaveAttribute("data-slot", "search");
expect(screen.getByText("Filters")).toHaveAttribute("data-slot", "filters");
expect(screen.getByText("Status")).toHaveAttribute("data-slot", "status");
expect(
screen.getByRole("button", { name: "Export board" }).closest('[data-slot="actions"]')
).toBeInTheDocument();
});
});
@@ -0,0 +1,123 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import {
workspaceToolbarActionsVariants,
workspaceToolbarContentVariants,
workspaceToolbarFiltersVariants,
workspaceToolbarLeadingVariants,
workspaceToolbarSearchVariants,
workspaceToolbarStatusVariants,
workspaceToolbarVariants
} from "./workspace-toolbar.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type WorkspaceToolbarProps = ComponentPropsWithoutRef<"section"> &
VariantProps<typeof workspaceToolbarVariants>;
export const WorkspaceToolbar = forwardRef<HTMLElement, WorkspaceToolbarProps>(
function WorkspaceToolbar({ className, surface, ...props }, ref) {
return (
<section
{...props}
{...createSlot("root")}
{...createDataAttributes({ surface })}
className={cn(workspaceToolbarVariants({ surface }), className)}
ref={ref}
/>
);
}
);
export type WorkspaceToolbarLeadingProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarLeading = forwardRef<
HTMLDivElement,
WorkspaceToolbarLeadingProps
>(function WorkspaceToolbarLeading({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("leading")}
className={cn(workspaceToolbarLeadingVariants(), className)}
ref={ref}
/>
);
});
export type WorkspaceToolbarContentProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarContent = forwardRef<
HTMLDivElement,
WorkspaceToolbarContentProps
>(function WorkspaceToolbarContent({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("content")}
className={cn(workspaceToolbarContentVariants(), className)}
ref={ref}
/>
);
});
export type WorkspaceToolbarSearchProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarSearch = forwardRef<HTMLDivElement, WorkspaceToolbarSearchProps>(
function WorkspaceToolbarSearch({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("search")}
className={cn(workspaceToolbarSearchVariants(), className)}
ref={ref}
/>
);
}
);
export type WorkspaceToolbarFiltersProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarFilters = forwardRef<HTMLDivElement, WorkspaceToolbarFiltersProps>(
function WorkspaceToolbarFilters({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("filters")}
className={cn(workspaceToolbarFiltersVariants(), className)}
ref={ref}
/>
);
}
);
export type WorkspaceToolbarStatusProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarStatus = forwardRef<HTMLDivElement, WorkspaceToolbarStatusProps>(
function WorkspaceToolbarStatus({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("status")}
className={cn(workspaceToolbarStatusVariants(), className)}
ref={ref}
/>
);
}
);
export type WorkspaceToolbarActionsProps = ComponentPropsWithoutRef<"div">;
export const WorkspaceToolbarActions = forwardRef<HTMLDivElement, WorkspaceToolbarActionsProps>(
function WorkspaceToolbarActions({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
className={cn(workspaceToolbarActionsVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,36 @@
import { cva } from "../lib/cva";
export const workspaceToolbarVariants = cva("grid gap-4 text-[var(--color-foreground)]", {
variants: {
surface: {
default: "",
panel:
"rounded-[calc(var(--ui-card-radius)-0.15rem)] border [border-width:var(--ui-card-border-width)] border-[color-mix(in_oklch,var(--color-outline-variant)_86%,white_14%)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] px-4 py-4 shadow-[var(--shadow-sm)] sm:px-5"
}
},
defaultVariants: {
surface: "default"
}
});
export const workspaceToolbarLeadingVariants = cva("grid max-w-3xl gap-1.5");
export const workspaceToolbarContentVariants = cva(
"flex min-w-0 flex-wrap items-center gap-3"
);
export const workspaceToolbarSearchVariants = cva(
"min-w-0 basis-full xl:max-w-xl xl:flex-[1_1_20rem]"
);
export const workspaceToolbarFiltersVariants = cva(
"flex flex-wrap items-center gap-3"
);
export const workspaceToolbarStatusVariants = cva(
"flex flex-wrap items-center gap-3"
);
export const workspaceToolbarActionsVariants = cva(
"flex flex-wrap items-center gap-3 xl:ml-auto"
);