feat(ui): polish core component surfaces

This commit is contained in:
2026-03-25 19:49:15 +08:00
parent eccaacece7
commit cc1509d2f6
64 changed files with 2707 additions and 353 deletions
@@ -31,6 +31,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Alert is the inline callout surface for status, guidance, and blocking information that should stay inside the current page flow. It now enters with a restrained rise/fade and uses a softly plated icon treatment so state changes feel present without turning the component into a hero panel."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -30,6 +30,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Avatar is the compact identity surface for people, teams, and owners. The treatment stays quiet, but the image now resolves over the fallback with a soft crossfade so identity changes feel polished instead of abrupt."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -34,6 +34,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Badge is the smallest status and chip surface in the system. It still reads as a compact token, but subtle and outline badges now carry a lighter tactile response so filter-like and live-status usage stops feeling mechanically flat."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -70,3 +76,40 @@ export const Matrix: Story = {
</div>
)
};
export const LiveAndFilters: Story = {
parameters: {
docs: {
description: {
story:
"Use `Badge` as a small status token by default, and as a tactile chip when it is rendered through `asChild` on an actual interactive element. The motion should stay restrained and chip-like, not button-heavy."
}
}
},
render: () => (
<div className="grid w-[720px] gap-4">
<div className="flex flex-wrap items-center gap-3">
<Badge tone="success">Live</Badge>
<Badge tone="warning">Reviewing</Badge>
<Badge tone="primary">Forecast</Badge>
</div>
<div className="flex flex-wrap items-center gap-3">
<Badge asChild tone="primary" variant="outline">
<button aria-pressed="true" type="button">
Design
</button>
</Badge>
<Badge asChild tone="neutral" variant="subtle">
<button aria-pressed="false" type="button">
Editorial
</button>
</Badge>
<Badge asChild tone="success" variant="outline">
<button aria-pressed="false" type="button">
Quiet queue
</button>
</Badge>
</div>
</div>
)
};
+62 -9
View File
@@ -1,4 +1,12 @@
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@ai-ui/ui";
import {
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const meta = {
@@ -6,7 +14,7 @@ const meta = {
component: Card,
args: {
tone: "default",
interactive: false
interactive: true
},
argTypes: {
className: {
@@ -21,6 +29,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Card is the base slab surface for panels, compact workflows, and supporting information blocks. It now defaults to a gentle interactive lift so common business cards feel alive by default, while `interactive={false}` remains available for deliberately static surfaces."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -34,22 +48,61 @@ export const Playground: Story = {
render: (args) => (
<Card {...args} className="w-[420px]">
<CardHeader>
<CardTitle>Release card</CardTitle>
<CardDescription>Summarize state, ownership, and next action.</CardDescription>
<CardTitle>Routing overview</CardTitle>
<CardDescription>Keep status, ownership, and the next action in one calm surface.</CardDescription>
</CardHeader>
<CardContent>
This surface is tuned for editorial dashboards and settings views.
This base card now carries a light hover lift by default so common workflow slabs do not
feel inert.
</CardContent>
<CardFooter>
<Button size="sm">Open</Button>
<Button size="sm">Open queue</Button>
<Button size="sm" variant="ghost">
Dismiss
Snooze
</Button>
</CardFooter>
</Card>
)
};
export const States: Story = {
parameters: {
docs: {
description: {
story:
"Use the default interactive treatment for navigable or actionable slabs, and opt out only when the card is meant to behave like a static document section."
}
}
},
render: () => (
<div className="grid w-[920px] gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Default interactive</CardTitle>
<CardDescription>Gentle lift and internal light cue for most business cards.</CardDescription>
</CardHeader>
<CardContent>Good for dashboards, queues, and next-step summaries.</CardContent>
</Card>
<Card interactive={false} tone="subtle">
<CardHeader>
<CardTitle>Static document slab</CardTitle>
<CardDescription>Opt out when the surface should read as anchored reference content.</CardDescription>
</CardHeader>
<CardContent>Useful for long-form notes, legal copy, and passive explanations.</CardContent>
</Card>
<Card tone="accent">
<CardHeader>
<CardTitle>Accent emphasis</CardTitle>
<CardDescription>Carry the same motion language while keeping the tonal emphasis richer.</CardDescription>
</CardHeader>
<CardContent>Use when the card itself is part of the call to action.</CardContent>
</Card>
</div>
)
};
export const Grid: Story = {
render: () => (
<div className="relative grid w-[940px] gap-6 overflow-hidden rounded-[2.3rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-background)_88%,white_12%))] p-6 shadow-[0_24px_72px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] md:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
@@ -80,11 +133,11 @@ export const Grid: Story = {
<Card className="motion-float">
<CardHeader>
<CardTitle>Default tone</CardTitle>
<CardDescription>Standard elevated panel for data and form sections.</CardDescription>
<CardDescription>Standard elevated panel with the new default hover cue.</CardDescription>
</CardHeader>
<CardContent>Reliable baseline for most admin surfaces.</CardContent>
</Card>
<Card className="motion-float-delayed justify-self-end md:w-[88%]" interactive tone="accent">
<Card className="motion-float-delayed justify-self-end md:w-[88%]" tone="accent">
<CardHeader>
<CardTitle>Interactive accent</CardTitle>
<CardDescription>Hover-capable treatment for navigable cards.</CardDescription>
@@ -222,6 +222,12 @@ const meta = {
title: "Components/Combobox",
component: ControlledDemo,
parameters: {
docs: {
description: {
component:
"Combobox is the searchable single-select surface for longer option sets, recent picks, and async lookup flows. The list should feel gently staged as results appear, with active rows gliding into place instead of snapping."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -240,6 +246,14 @@ export const RecentAndSuggested: Story = {
};
export const AsyncResults: Story = {
parameters: {
docs: {
description: {
story:
"Async comboboxes should crossfade between loading, empty, and result states. Keep the motion short and directional so the user understands the state change without losing the current context."
}
}
},
render: () => <AsyncResultsDemo />
};
+114 -10
View File
@@ -42,7 +42,18 @@ function InlineCommandShowcase() {
<CommandGroup heading={group.heading}>
{group.items.map((item) => (
<CommandItem key={item.value} value={item.value}>
{item.label}
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">{item.label}</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
{item.value === "open-docs"
? "Jump into authoring guidance, slots, and motion references."
: item.value === "go-releases"
? "Open the current release desk and recent launch notes."
: item.value === "publish-update"
? "Queue the current workspace update for approval."
: "Assign the next reviewer without leaving the palette."}
</span>
</span>
<CommandShortcut>{item.shortcut}</CommandShortcut>
</CommandItem>
))}
@@ -77,22 +88,42 @@ function OperationsWorkbenchShowcase() {
<CommandEmpty>No matching actions.</CommandEmpty>
<CommandGroup heading="Recent">
<CommandItem keywords={["launch", "release"]} value="recent-launch-review">
Launch review
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Launch review</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Reopen the last routed launch brief and approvals queue.
</span>
</span>
<CommandShortcut>R</CommandShortcut>
</CommandItem>
<CommandItem keywords={["blocked", "queue"]} value="recent-blocked-queue">
Blocked queue
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Blocked queue</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Inspect stalled items, owners, and next unblock steps.
</span>
</span>
<CommandShortcut>B</CommandShortcut>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Suggestions">
<CommandItem keywords={["docs", "stories"]} value="docs-storybook">
Open Storybook docs
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Open Storybook docs</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Review the live component contract and interaction stories.
</span>
</span>
<CommandShortcut>G D</CommandShortcut>
</CommandItem>
<CommandItem disabled value="compliance-review">
Compliance review locked
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Compliance review locked</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
This action unlocks after policy artifacts finish syncing.
</span>
</span>
<CommandShortcut>Locked</CommandShortcut>
</CommandItem>
</CommandGroup>
@@ -127,18 +158,49 @@ function DialogCommandShowcase() {
<CommandList>
<CommandEmpty>No commands available.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem value="launch-checklist">Launch checklist</CommandItem>
<CommandItem value="rollout-audit">Rollout audit</CommandItem>
<CommandItem value="brand-theme">Brand theme tokens</CommandItem>
<CommandItem value="launch-checklist">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Launch checklist</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Open the current milestone handoff and owner checklist.
</span>
</span>
</CommandItem>
<CommandItem value="rollout-audit">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Rollout audit</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Review timing, communications, and incident readiness.
</span>
</span>
</CommandItem>
<CommandItem value="brand-theme">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Brand theme tokens</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Jump to the token surface that controls tonal palette shifts.
</span>
</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="People">
<CommandItem value="jordan-lee">
Jordan Lee
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Jordan Lee</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Product lead for launch sequencing and review coordination.
</span>
</span>
<CommandShortcut>@</CommandShortcut>
</CommandItem>
<CommandItem value="avery-carter">
Avery Carter
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Avery Carter</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Owns release notes, docs framing, and approval summaries.
</span>
</span>
<CommandShortcut>@</CommandShortcut>
</CommandItem>
</CommandGroup>
@@ -152,6 +214,16 @@ function LoadingResultsShowcase() {
return (
<Command
className="w-[520px]"
footer={
<div className="flex items-center justify-between gap-3">
<p className="m-0 text-xs text-[var(--color-muted-foreground)]">
Cached suggestions stay visible while the workspace search refreshes.
</p>
<Button size="sm" variant="ghost">
View queue
</Button>
</div>
}
label="Remote workspace search"
loading
loadingMessage="Searching remote workspace actions…"
@@ -159,6 +231,24 @@ function LoadingResultsShowcase() {
<CommandInput placeholder="Search actions from all workspaces" />
<CommandList>
<CommandEmpty>No commands available.</CommandEmpty>
<CommandGroup heading="Cached suggestions">
<CommandItem value="cached-launch-checklist">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Launch checklist</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Reopen the previous launch checklist while fresh results stream in.
</span>
</span>
</CommandItem>
<CommandItem value="cached-review-queue">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Review queue</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Keep the last known reviewer list visible during the remote refresh.
</span>
</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
@@ -168,6 +258,12 @@ const meta = {
title: "Components/Command",
component: InlineCommandShowcase,
parameters: {
docs: {
description: {
component:
"Command is the system surface for in-app palettes and quick action search. The highlighted row should feel adhesive and spatial, with enough motion to clarify focus and loading state without turning the palette into a busy animation surface."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -190,5 +286,13 @@ export const DialogPalette: Story = {
};
export const LoadingResults: Story = {
parameters: {
docs: {
description: {
story:
"Loading should not feel like a hard swap. Keep a soft status band at the top, let cached results dim underneath, and preserve the same highlighted-row motion language."
}
}
},
render: () => <LoadingResultsShowcase />
};
+17 -17
View File
@@ -189,15 +189,15 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
function SignalGlyph() {
return (
<div className="grid gap-2">
<div className="grid grid-cols-[0.8rem_2.8rem_1.5rem] gap-2">
<span className="h-3 rounded-[var(--radius-full)] bg-[var(--color-primary)]" />
<span className="h-3 rounded-[var(--radius-full)] bg-[var(--color-surface-strong)]" />
<span className="h-3 rounded-[var(--radius-full)] bg-[var(--color-accent)]" />
<div className="grid gap-3">
<div className="grid grid-cols-[0.9rem_3.1rem_1.75rem] gap-2">
<span className="h-3.5 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-primary)_88%,white_12%)]" />
<span className="h-3.5 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-surface-strong)_82%,white_18%)]" />
<span className="h-3.5 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-accent)_82%,white_18%)]" />
</div>
<div className="grid gap-2">
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border-strong)]" />
<span className="h-2 w-16 rounded-[var(--radius-full)] bg-[var(--color-border)]" />
<span className="h-2.5 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-border-strong)_78%,white_22%)]" />
<span className="h-2.5 w-16 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-border)_88%,white_12%)]" />
</div>
</div>
);
@@ -244,7 +244,7 @@ function RoutingEmptyState({ onReset }: { onReset: () => void }) {
function RoutingLaneDetail({ row }: { row: RoutingLaneRow }) {
return (
<div className="grid gap-4">
<div className="grid gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-xs)]">
<div className="grid gap-3 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_82%,white_18%),color-mix(in_oklch,var(--color-surface)_90%,white_10%))] p-4 shadow-[var(--shadow-sm)]">
<div className="flex flex-wrap items-center gap-2">
<Badge size="sm" tone={getStateTone(row.state)} variant="outline">
{row.state}
@@ -260,14 +260,14 @@ function RoutingLaneDetail({ row }: { row: RoutingLaneRow }) {
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-background)_86%,white_14%),color-mix(in_oklch,var(--color-surface-container-low)_82%,white_18%))] p-4 shadow-[var(--shadow-xs)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Owner
</p>
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">{row.owner}</p>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">{row.ownerEmail}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-background)_86%,white_14%),color-mix(in_oklch,var(--color-surface-container-low)_82%,white_18%))] p-4 shadow-[var(--shadow-xs)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Next gate
</p>
@@ -307,8 +307,8 @@ function DataTablePlayground() {
}
return (
<div className="grid gap-4 text-[var(--color-foreground)]">
<div className="grid gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="grid gap-5 text-[var(--color-foreground)]">
<div className="grid gap-3 rounded-[calc(var(--radius-xl)+0.2rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-surface-container-low)_78%,white_22%))] p-5 shadow-[var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Standalone workflow
@@ -321,7 +321,7 @@ function DataTablePlayground() {
exercising built-in search, sorting, pagination, selection, and toolbar actions.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3">
<div className="rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_52%,white_48%),color-mix(in_oklch,var(--color-background)_84%,white_16%))] px-4 py-3 shadow-[var(--shadow-xs)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Last action
</p>
@@ -329,7 +329,7 @@ function DataTablePlayground() {
</div>
</div>
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<div className="w-full max-w-[1120px] rounded-[calc(var(--radius-xl)+0.2rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_76%,white_24%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-4 shadow-[var(--shadow-sm)]">
<DataTable
columns={routingColumns}
defaultDensity="comfortable"
@@ -430,7 +430,7 @@ function DataTablePlayground() {
function DataTableLoadingState() {
return (
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<div className="w-full max-w-[1120px] rounded-[calc(var(--radius-xl)+0.2rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_76%,white_24%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-4 shadow-[var(--shadow-sm)]">
<DataTable
columns={routingColumns}
getRowId={(row) => row.id}
@@ -448,7 +448,7 @@ function DataTableLoadingState() {
function DataTableEmptyStateDemo() {
return (
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<div className="w-full max-w-[1120px] rounded-[calc(var(--radius-xl)+0.2rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_76%,white_24%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-4 shadow-[var(--shadow-sm)]">
<DataTable
columns={routingColumns}
empty={<RoutingEmptyState onReset={() => undefined} />}
@@ -475,7 +475,7 @@ const meta = {
docs: {
description: {
component:
"A production-style table primitive with built-in search, sorting, pagination, row selection, and external toolbar actions. This standalone page isolates the component from the larger workspace scene while keeping a realistic routing-desk narrative."
"A production-style table primitive with built-in search, sorting, pagination, row selection, and external toolbar actions. This standalone page isolates the component from the larger workspace scene while keeping a realistic routing-desk narrative. Motion should stay calm: rows deepen slightly on hover, sort feedback stays crisp, selected surfaces feel staged instead of loud, and the surrounding control chrome stays mostly anchored instead of all lifting at once."
}
},
layout: "padded"
@@ -9,13 +9,24 @@ import {
EmptyStateTitle
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import type { ReactNode } from "react";
function EmptyStateGlyph() {
return (
<div className="grid gap-2">
<div className="grid grid-cols-[1.3rem_3.4rem] gap-2">
<span className="h-5 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-card))]" />
<span className="h-5 rounded-[var(--radius-sm)] bg-[var(--color-surface-strong)]" />
<div className="grid gap-3">
<div className="grid grid-cols-[1.1rem_3.6rem] items-center gap-2">
<span className="size-4 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_22%,var(--color-card))] shadow-[0_0_0_4px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]" />
<span className="h-4 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-surface-strong)_86%,white_14%)]" />
</div>
<div className="grid gap-2 rounded-[1rem] border border-[color-mix(in_oklch,var(--color-border)_70%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_72%,white_28%)] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
<div className="grid grid-cols-[1.2rem_3.4rem] gap-2">
<span className="h-4 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-tertiary-container)_60%,white_40%)]" />
<span className="h-4 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-surface-strong)_80%,white_20%)]" />
</div>
<div className="grid grid-cols-[4.8rem] gap-2">
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border-strong)]" />
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border)]" />
</div>
</div>
<div className="grid grid-cols-[4.8rem] gap-2">
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border-strong)]" />
@@ -25,11 +36,43 @@ function EmptyStateGlyph() {
);
}
function WorkspaceSetupGlyph() {
return (
<div className="grid w-full gap-3">
<div className="flex items-center justify-between gap-3 rounded-[1rem] border border-[color-mix(in_oklch,var(--color-border)_70%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_72%,white_28%)] px-3 py-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
<div className="grid gap-1.5">
<span className="h-2.5 w-20 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_20%,var(--color-card))]" />
<span className="h-2.5 w-12 rounded-full bg-[var(--color-border)]" />
</div>
<span className="grid size-10 place-items-center rounded-[1rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_76%,white_24%),color-mix(in_oklch,var(--color-tertiary-container)_72%,white_28%))] shadow-[0_10px_22px_color-mix(in_oklch,var(--color-primary)_14%,transparent)]">
<span className="size-4 rounded-full border-2 border-[color-mix(in_oklch,var(--color-primary)_58%,white_42%)]" />
</span>
</div>
<div className="grid gap-2 rounded-[1.25rem] border border-[color-mix(in_oklch,var(--color-border)_68%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-bright)_76%,white_24%)] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.52)]">
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-[color-mix(in_oklch,var(--color-success)_72%,white_28%)]" />
<span className="h-2.5 w-18 rounded-full bg-[var(--color-border-strong)]" />
</div>
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_56%,white_44%)]" />
<span className="h-2.5 w-24 rounded-full bg-[var(--color-border)]" />
</div>
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary)_58%,white_42%)]" />
<span className="h-2.5 w-16 rounded-full bg-[var(--color-border)]" />
</div>
</div>
</div>
);
}
function ReleaseEmptyState({
actionsLayout = "inline",
align = "center",
className,
description = "Adjust the current filters or create a new release to start routing work.",
eyebrow = "No results",
glyph,
layout = "default",
mediaSize = "default",
tone = "default",
@@ -37,8 +80,10 @@ function ReleaseEmptyState({
}: {
actionsLayout?: "inline" | "stack";
align?: "center" | "start";
className?: string;
description?: string;
eyebrow?: string;
glyph?: ReactNode;
layout?: "compact" | "default" | "split";
mediaSize?: "compact" | "default" | "hero";
title?: string;
@@ -47,12 +92,14 @@ function ReleaseEmptyState({
return (
<EmptyState
align={align}
className="w-[min(100%,42rem)]"
className={[layout === "split" ? "w-full max-w-none" : "w-[min(100%,42rem)]", className]
.filter(Boolean)
.join(" ")}
layout={layout}
tone={tone}
>
<EmptyStateMedia size={mediaSize}>
<EmptyStateGlyph />
{glyph ?? <EmptyStateGlyph />}
</EmptyStateMedia>
<EmptyStateHeader align={align}>
<EmptyStateEyebrow>{eyebrow}</EmptyStateEyebrow>
@@ -140,8 +187,10 @@ export const SplitWorkspace: Story = {
<div className="w-[940px] rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-background)] p-6">
<ReleaseEmptyState
align="start"
className="shadow-[0_26px_60px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]"
description="Invite operators, connect the first runtime, and define the initial routing lane to turn this empty workspace into an active control surface."
eyebrow="Workspace setup"
glyph={<WorkspaceSetupGlyph />}
layout="split"
mediaSize="hero"
title="This command center needs its first operator"
@@ -151,6 +200,42 @@ export const SplitWorkspace: Story = {
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"EmptyState keeps motion local to the decorative media and the call-to-action group. The slab itself stays anchored while the media drifts gently and the actions enter in a short sequence. In static motion mode those effects collapse back to near-instant feedback."
}
}
},
render: () => (
<div className="grid w-[960px] gap-4 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<ReleaseEmptyState
actionsLayout="stack"
align="start"
description="Keep the empty queue readable without turning it into a hero banner. The media should feel alive, not restless."
eyebrow="Queue calm"
layout="compact"
mediaSize="default"
title="No routed work is waiting right now"
tone="subtle"
/>
<ReleaseEmptyState
align="start"
className="shadow-[0_26px_60px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]"
description="The split workspace uses the larger hero media, but the slab stays planted. Motion belongs in the supporting ornament and the CTA entrance, not in the whole panel."
eyebrow="Workspace setup"
glyph={<WorkspaceSetupGlyph />}
layout="split"
mediaSize="hero"
title="Invite the first operator to activate this command center"
tone="accent"
/>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
+246 -19
View File
@@ -1,10 +1,60 @@
import { Progress } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function ProgressCard({
accent,
label,
note,
pattern = "linear",
segmentCount,
tone = "default",
value,
variant = "default"
}: {
accent: string;
label: string;
note: string;
pattern?: "linear" | "segmented";
segmentCount?: number;
tone?: "default" | "subtle";
value: number | null;
variant?: "default" | "success" | "warning" | "destructive";
}) {
const displayValue = value == null ? "Syncing" : `${value}%`;
return (
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p
className="text-xs uppercase tracking-[var(--tracking-caps)]"
style={{ color: accent }}
>
{label}
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">{note}</p>
</div>
<span className="text-sm font-medium text-[var(--color-foreground)]">{displayValue}</span>
</div>
<Progress
aria-label={label}
className="mt-4"
pattern={pattern}
segmentCount={segmentCount}
tone={tone}
value={value}
variant={variant}
/>
</article>
);
}
const meta = {
title: "Components/Progress",
component: Progress,
args: {
pattern: "linear",
segmentCount: 24,
size: "md",
value: 64,
variant: "default"
@@ -17,6 +67,18 @@ const meta = {
control: "radio",
options: ["sm", "md", "lg"]
},
pattern: {
control: "radio",
options: ["linear", "segmented"]
},
segmentCount: {
control: {
type: "range",
min: 6,
max: 36,
step: 1
}
},
tone: {
control: "radio",
options: ["default", "subtle"]
@@ -35,6 +97,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Progress is the system's inline status channel for work that is actively advancing inside the current view. Use the default linear pattern for classic completion bars, or switch to the segmented pattern when a dashboard metric needs a more discrete meter like rollout targets, cost reduction goals, and operational scorecards."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -45,35 +113,194 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => <Progress aria-label="Upload progress" className="w-[320px]" {...args} />
render: (args) => (
<div className="w-[380px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Asset Upload
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)]">
Publishing release visuals
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Use the same component for classic uploads and segmented dashboard meters by swapping
only the pattern instead of reaching for a separate one-off implementation.
</p>
</div>
<span className="rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-primary-container)_70%,white_30%)] px-3 py-1 text-sm font-medium text-[var(--color-on-primary-container)]">
{args.value}%
</span>
</div>
<Progress aria-label="Upload progress" className="mt-5" {...args} />
</div>
)
};
export const Variants: Story = {
export const Patterns: Story = {
render: () => (
<div className="grid w-[360px] gap-4">
<Progress aria-label="Primary progress" value={42} variant="default" />
<Progress aria-label="Success progress" value={74} variant="success" />
<Progress aria-label="Warning progress" value={58} variant="warning" />
<Progress aria-label="Destructive progress" value={86} variant="destructive" />
<div className="grid w-[720px] gap-4 md:grid-cols-2">
<ProgressCard
accent="var(--color-primary)"
label="Release Assets"
note="The marketing bundle is halfway through the upload queue."
value={46}
/>
<ProgressCard
accent="var(--color-foreground)"
label="Operational Cost Reduction"
note="Segmented meters work better for dashboard targets where discrete progress reads faster than a single slab."
pattern="segmented"
segmentCount={24}
tone="subtle"
value={42}
/>
<ProgressCard
accent="color-mix(in oklch, var(--color-success) 78%, var(--color-foreground))"
label="Audience Sync"
note="Subscriber segments are reconciling against the latest CRM export."
tone="subtle"
value={74}
variant="success"
/>
<ProgressCard
accent="color-mix(in oklch, var(--color-warning) 72%, var(--color-foreground))"
label="Compliance Review"
note="A few rule checks are still pending before the rollout can advance."
value={58}
variant="warning"
/>
<ProgressCard
accent="var(--color-destructive)"
label="Recovery Job"
note="The pipeline is retrying a failed package publish after signature drift."
tone="subtle"
value={91}
variant="destructive"
/>
</div>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[360px] gap-4">
<div className="space-y-2">
<p className="text-sm font-medium">Determinate</p>
<Progress aria-label="Determinate progress" value={68} />
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Indeterminate</p>
<Progress aria-label="Indeterminate progress" value={null} variant="success" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Complete</p>
<Progress aria-label="Complete progress" value={100} variant="success" />
<div className="grid w-[720px] gap-4 md:grid-cols-3">
<ProgressCard
accent="var(--color-primary)"
label="Determinate"
note="Linear progress remains the best fit for straightforward task completion."
value={68}
/>
<ProgressCard
accent="var(--color-foreground)"
label="Segmented Goal"
note="The segmented pattern makes discrete target progress easier to scan in dense dashboards."
pattern="segmented"
segmentCount={20}
tone="subtle"
value={42}
/>
<ProgressCard
accent="color-mix(in oklch, var(--color-success) 78%, var(--color-foreground))"
label="Complete"
note="Completion stays quiet and stable instead of introducing a second celebratory treatment."
value={100}
variant="success"
/>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-4">
<div className="space-y-1">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Progress anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The public styling contract stays intentionally small: a root track and one indicator
layer, with the data attributes carrying the meaningful state.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-5">
<div className="grid gap-4">
<Progress aria-label="Progress anatomy example" size="lg" value={68} />
<Progress
aria-label="Progress anatomy segmented example"
pattern="segmented"
segmentCount={20}
size="lg"
tone="subtle"
value={42}
/>
</div>
</div>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="root"</code> is the tonal
track. It exposes <code className="text-[var(--color-foreground)]">data-size</code>,{" "}
<code className="text-[var(--color-foreground)]">data-pattern</code>,{" "}
<code className="text-[var(--color-foreground)]">data-tone</code>,{" "}
<code className="text-[var(--color-foreground)]">data-variant</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-state</code> so consumers can
respond to contract-level state instead of incidental class names.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="indicator"</code> is the
filled value layer for the linear pattern. It mirrors pattern, size, variant, and
state so tests and downstream themes can target the active bar without reaching into
private markup.
</p>
<p>
In segmented mode, <code className="text-[var(--color-foreground)]">data-slot="segments"</code>{" "}
wraps the repeated meter cells and each{" "}
<code className="text-[var(--color-foreground)]">data-slot="segment"</code> exposes
active state for dashboard-style styling and assertions.
</p>
</div>
</div>
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Determinate values should feel calm and weighted, with the filled slab scaling into place instead of animating layout width. Indeterminate progress keeps a restrained sweep in interactive mode, then falls back to a static reading when reduced or static motion is active."
}
}
},
render: () => (
<div className="grid w-[720px] gap-4 md:grid-cols-3">
<ProgressCard
accent="var(--color-primary)"
label="Determinate"
note="A known value keeps the indicator anchored and scales the filled slab instead of animating layout width."
value={68}
/>
<ProgressCard
accent="var(--color-foreground)"
label="Segmented Meter"
note="Discrete bars stay crisp at a glance and match dashboard KPI treatments."
pattern="segmented"
segmentCount={24}
tone="subtle"
value={42}
/>
<ProgressCard
accent="color-mix(in oklch, var(--color-success) 78%, var(--color-foreground))"
label="Segmented Indeterminate"
note="Unknown duration shifts the segmented pattern into a staggered pulse instead of pretending there is a precise width."
pattern="segmented"
segmentCount={20}
tone="subtle"
value={null}
variant="success"
/>
</div>
)
};
+71 -2
View File
@@ -1,4 +1,18 @@
import { Field, FieldControl, FieldDescription, FieldError, Label, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@ai-ui/ui";
import {
Field,
FieldControl,
FieldDescription,
FieldError,
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
async function waitForCondition(
@@ -23,6 +37,12 @@ const meta = {
title: "Components/Select",
component: Select,
parameters: {
docs: {
description: {
component:
"Select is the compact choice surface for a fixed option set. It should feel lighter than Combobox, but it should still share the same motion language: a restrained rise on open, adhesive highlighted rows, and a selected state that reads as a settled tonal slab instead of a hard admin-menu snap."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -34,7 +54,7 @@ type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
<div className="w-[320px]">
<div className="w-[360px]">
<Select defaultValue="editorial">
<SelectTrigger aria-label="Review lane">
<SelectValue placeholder="Choose a review lane" />
@@ -44,7 +64,10 @@ export const Playground: Story = {
<SelectLabel>Review lane</SelectLabel>
<SelectItem value="editorial">Editorial review</SelectItem>
<SelectItem value="design">Design review</SelectItem>
<SelectSeparator />
<SelectLabel>Specialist lane</SelectLabel>
<SelectItem value="legal">Legal review</SelectItem>
<SelectItem value="ops">Operations review</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
@@ -111,3 +134,49 @@ export const WithField: Story = {
</Field>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Open the list and move through the options with the pointer or keyboard. The menu should fade and rise as one surface, highlighted rows should glide lightly instead of snapping, and the checked row should feel a touch more settled than the transient highlight."
}
}
},
render: () => (
<div className="grid w-[420px] gap-4 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Keep Select lighter than Combobox, but not mechanically flat.
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
This surface is still a simple fixed-choice select. The motion should clarify focus and
settled selection without turning the menu into an animated showcase.
</p>
</div>
<Select defaultValue="design">
<SelectTrigger aria-label="Motion review lane">
<SelectValue placeholder="Choose a review lane" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Core lanes</SelectLabel>
<SelectItem value="editorial">Editorial review</SelectItem>
<SelectItem value="design">Design review</SelectItem>
<SelectItem value="engineering">Engineering review</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Specialist lanes</SelectLabel>
<SelectItem value="legal">Legal review</SelectItem>
<SelectItem value="ops">Operations review</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
)
};
+13 -1
View File
@@ -51,7 +51,7 @@ const meta = {
docs: {
description: {
component:
"Tabs is the system's in-place content switcher for peer views that belong to the same context. It exposes stable slots for the root, list, triggers, and panels, supports orientation changes, and keeps active-state styling on the trigger rather than moving users into a new page."
"Tabs is the system's in-place content switcher for peer views that belong to the same context. It exposes stable slots for the root, list, triggers, and panels, supports orientation changes, and now uses a shared active pill so state changes feel like one continuous surface sliding between peer views instead of isolated button toggles."
}
},
layout: "centered"
@@ -104,6 +104,18 @@ export const Anatomy: Story = {
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"The active state now uses a shared pill that glides between triggers with restrained timing. It should feel like one calm slab moving across the control, not like each tab is popping independently."
}
}
},
render: () => <ReleaseTabs includeDisabled />
};
export const Accessibility: Story = {
parameters: {
docs: {
+1 -1
View File
@@ -57,7 +57,7 @@ const meta = {
docs: {
description: {
component:
"Tooltip is the system's smallest assistive overlay. Use it for terse clarification, shortcut hints, or icon-only controls that benefit from a compact label on hover and focus. If the content needs interaction or more than a sentence, switch to popover instead."
"Tooltip is the system's smallest assistive overlay. Use it for terse clarification, shortcut hints, or icon-only controls that benefit from a compact label on hover and focus. It uses a short rise-and-fade so the label still feels spatial without carrying the heavier panel motion reserved for popovers and dialogs. If the content needs interaction or more than a sentence, switch to popover instead."
}
},
layout: "centered"
@@ -0,0 +1,89 @@
# Component Polish Pass
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Run a focused polish pass across six existing component surfaces so the current Material runtime
direction feels more intentional in high-traffic docs and product-like stories. The goal is not to
add new APIs. The goal is to tighten hierarchy, depth, and interaction feedback while keeping the
existing contract stable.
## Scope
- In scope:
- refine `StatCard` hierarchy, default interaction treatment, and delta emphasis
- refine `DataTable` row, header, toolbar, and selection presentation
- refine `EmptyState` composition, especially the `split` layout media and narrative weight
- refine `Command`, `Combobox`, and `Select` list item selection and active-state treatment
- refine `MetricCard` cohesion across media, footer, and CTA regions
- refine base `Card` interactive behavior so general business cards feel less static
- update the closest Storybook stories and tests where behavior or reviewability changes
- Out of scope:
- adding new component families or expanding public props beyond what the current contract needs
- introducing a second motion language or non-token visual escape hatches
- broad scene rewrites outside the stories that best demonstrate the updated behavior
## Constraints
- Follow the active visual and motion direction in `DESIGN.md`.
- Preserve reduced-motion safety and the current component contract.
- Keep write scopes isolated so six workers can operate in parallel without conflicting.
- Keep shared integration surfaces on the main thread where practical.
- Do not revert or overwrite unrelated in-progress repo changes.
## Affected Surfaces
- `packages/ui/src/components/card*`
- `packages/ui/src/components/stat-card*`
- `packages/ui/src/components/data-table*`
- `packages/ui/src/components/empty-state*`
- `packages/ui/src/components/command*`
- `packages/ui/src/components/combobox*`
- `packages/ui/src/components/select*`
- `packages/ui/src/components/metric-card*`
- `apps/docs/src/components/card.stories.tsx`
- `apps/docs/src/components/stat-card.stories.tsx`
- `apps/docs/src/components/data-table.stories.tsx`
- `apps/docs/src/components/empty-state.stories.tsx`
- `apps/docs/src/components/command.stories.tsx`
- `apps/docs/src/components/combobox.stories.tsx`
- `apps/docs/src/components/select.stories.tsx`
- `apps/docs/src/components/metric-card.stories.tsx`
- `docs/exec-plans`
## Plan
1. Write and commit an execution plan for the six-slice polish pass so delegation stays resumable.
2. Dispatch six isolated implementation workers, one per polish slice, each owning its component,
nearby stories, and nearby tests only.
3. Review worker diffs in the main thread, resolve any cross-slice visual inconsistencies, and keep
shared integration concerns centralized.
4. Run the narrowest useful harness and component validation suites, then fix any regressions in the
main thread.
5. Summarize what changed, what was validated, and any remaining gaps.
## Validation
- `pnpm harness:validate:component`
- `pnpm harness:validate:docs`
- `pnpm harness:validate:changed`
## Orchestration Task Sketch
- `T1`: polish `StatCard`
- `T2`: polish `DataTable`
- `T3`: polish `EmptyState`
- `T4`: polish `Command`, `Combobox`, and `Select`
- `T5`: polish `MetricCard`
- `T6`: polish base `Card`
- `T7 -> T1,T2,T3,T4,T5,T6`: integrate worker results and run focused validation
## Status Log
- `2026-03-25 16:26` started the six-slice polish pass after reviewing the current design language, component implementations, and representative Storybook stories
- `2026-03-25 16:29` wrote the execution plan and prepared six isolated worker slices plus a main-thread integration pass
- `2026-03-25 16:38` completed the delegated `Card`, `StatCard`, `EmptyState`, `MetricCard`, `Command/Combobox/Select`, and `DataTable` slices and reviewed the combined result set in the main thread
- `2026-03-25 16:41` validated the combined changes with targeted Vitest coverage (`card`, `stat-card`, `empty-state`, `metric-card`, `command`, `combobox`, `select`, `data-table`), `pnpm build:docs`, `pnpm --filter @ai-ui/ui exec tsc --noEmit`, and `pnpm --dir apps/docs exec tsc --noEmit`
@@ -0,0 +1,82 @@
# Foundation And Pattern Motion Pass
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Run a focused third-pass polish on four remaining motion gaps: overly busy `DataTable` chrome,
under-animated foundation surfaces, static-feeling workspace patterns, and missing docs/test
coverage for `StatCard` / `MetricCard` motion behavior.
## Scope
- In scope:
- reduce excess hover lift around `DataTable` selection, search, and pagination chrome while
preserving row hover and sort-state feedback
- add restrained component-specific polish to `Alert`, `Avatar`, and `Badge`
- bring `PageHeader`, `AppShell`, and `PageFooter` closer to the existing pattern motion quality
bar set by `SidebarNav`
- add dedicated motion stories and targeted regression tests for `StatCard` and `MetricCard`
- Out of scope:
- adding new animation dependencies
- broad visual redesign unrelated to motion and interaction feedback
- touching unrelated in-progress component work
## Constraints
- Respect `DESIGN.md` and keep one calm, spatial motion language.
- Keep write scopes isolated so workers do not collide.
- Preserve existing public APIs unless a very small compatibility-safe addition is justified.
- Do not revert unrelated work already present in the dirty worktree.
## Ownership
- Main thread:
- plan, review, integration, and validation
- Worker 1:
- `packages/ui/src/components/data-table*`
- `apps/docs/src/components/data-table.stories.tsx`
- Worker 2:
- `packages/ui/src/components/alert*`
- `packages/ui/src/components/avatar*`
- `packages/ui/src/components/badge*`
- nearest owned stories/tests if needed
- Worker 3:
- `packages/ui/src/patterns/page-header*`
- `packages/ui/src/patterns/app-shell*`
- `packages/ui/src/patterns/page-footer*`
- nearest owned stories/tests if needed
- Worker 4:
- `packages/ui/src/components/stat-card*`
- `packages/ui/src/components/metric-card*`
- `apps/docs/src/components/stat-card.stories.tsx`
- `apps/docs/src/components/metric-card.stories.tsx`
## Plan
1. Write the execution plan and confirm the four disjoint ownership slices.
2. Dispatch four workers with isolated write scopes and keep main-thread ownership limited to
review, integration, and validation.
3. Review the returned slices, fix any integration gaps, and avoid broad shared-file churn.
4. Run focused tests and typechecks for the affected components and docs surfaces.
## Validation
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/data-table.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/alert.test.tsx packages/ui/src/components/avatar.test.tsx packages/ui/src/components/badge.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/stat-card.test.tsx packages/ui/src/components/metric-card.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/patterns/page-header.test.tsx packages/ui/src/patterns/app-shell.test.tsx packages/ui/src/patterns/page-footer.test.tsx`
- `pnpm --filter @ai-ui/ui exec tsc --noEmit`
- `pnpm --dir apps/docs exec tsc --noEmit`
## Status Log
- `2026-03-25 17:27` started the pass after the previous motion review identified remaining polish
gaps in `DataTable`, foundation surfaces, workspace patterns, and motion-story/test coverage
- `2026-03-25 17:28` wrote the execution plan and dispatched four isolated workers for
`DataTable`, foundation components, workspace patterns, and KPI-card coverage
- `2026-03-25 17:33` reviewed and accepted all four returned slices, then validated the combined
result set with focused Vitest coverage plus `pnpm --filter @ai-ui/ui exec tsc --noEmit` and
`pnpm --dir apps/docs exec tsc --noEmit`
@@ -0,0 +1,93 @@
# Motion Accessibility And Selection Pass
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Polish the remaining low-level interaction motion gaps called out in the motion review, with an
emphasis on reduced-motion correctness, selection-state feedback, and bringing lower-tier
components closer to the established `Command` / `Combobox` / `Tabs` motion quality bar.
## Scope
- In scope:
- add explicit reduced-motion handling for continuous loader animation surfaces
- soften `Checkbox` and `RadioGroup` selection feedback with short scale/fade indicator motion
- give `Tooltip` a light rise to match the rest of the overlay family
- reduce layout-heavy motion where it is currently doing too much work in `Accordion`,
`Sparkbar`, and `Progress`
- polish `Select` list motion so it feels closer to `Combobox` and `Command`
- update focused tests and stories when the behavior contract changes
- Out of scope:
- introducing new animation dependencies
- expanding public APIs unless a small compatibility-safe prop is justified
- broad visual restyling unrelated to motion or reduced-motion behavior
## Constraints
- Respect `DESIGN.md` and keep one calm, spatial motion language.
- Treat `packages/ui/src/components/progress*` as main-thread-owned because reduced-motion and
motion-refinement requirements both land there.
- Do not revert or overwrite unrelated in-progress repo changes.
- Preserve static-motion behavior through `data-motion="static"` and make OS-level reduced motion
behave equivalently for continuous animation surfaces.
## Ownership
- Main thread:
- `packages/ui/src/components/progress*`
- final review, integration, and validation
- Worker 1:
- `packages/ui/src/components/spinner.tsx`
- `packages/ui/src/components/skeleton.tsx`
- nearest tests/stories if needed
- Worker 2:
- `packages/ui/src/components/checkbox*`
- `packages/ui/src/components/radio-group*`
- nearest tests/stories if needed
- Worker 3:
- `packages/ui/src/components/tooltip*`
- nearest tests/stories if needed
- Worker 4:
- `packages/ui/src/components/accordion*`
- `packages/ui/src/components/sparkbar*`
- nearest tests/stories if needed
- Worker 5:
- `packages/ui/src/components/select*`
- nearest tests/stories if needed
## Plan
1. Write the execution plan and keep `Progress` under main-thread ownership.
2. Dispatch five isolated workers for loaders, selection controls, tooltip, accordion/sparkbar,
and select.
3. Implement `Progress` reduced-motion and motion refinements locally.
4. Review and integrate the worker slices, then fix any shared token or contract issues.
5. Run focused tests for the changed surfaces and a narrow typecheck/docs pass if needed.
## Validation
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/progress.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/checkbox.test.tsx packages/ui/src/components/radio-group.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/tooltip.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/accordion.test.tsx packages/ui/src/components/sparkbar.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/select.test.tsx`
- `pnpm --filter @ai-ui/ui exec tsc --noEmit`
## Status Log
- `2026-03-25 17:18` started the focused pass after motion review identified reduced-motion gaps,
low-fidelity selection feedback, and one remaining `Select` polish gap
- `2026-03-25 17:19` wrote the execution plan, kept `progress.*` under main-thread ownership, and
dispatched five isolated workers for loaders, selection controls, tooltip, accordion/sparkbar,
and select
- `2026-03-25 17:22` landed the main-thread `Progress` refactor: reduced-motion detection now
collapses indeterminate animation, the linear bar scales with `transform` instead of animating
layout width, and segmented indeterminate progress falls back to a static partial fill when
motion is reduced
- `2026-03-25 17:24` reviewed and accepted worker slices for loaders, selection controls, tooltip,
accordion/sparkbar, and select polish
- `2026-03-25 17:25` validated the combined result set with focused component tests plus
`pnpm --filter @ai-ui/ui exec tsc --noEmit` and `pnpm --dir apps/docs exec tsc --noEmit`
@@ -0,0 +1,89 @@
# Motion Polish Pass
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Run a second polish pass focused on interaction motion across six existing component surfaces. The
goal is to add the missing sense of staged, spatial feedback described in `DESIGN.md` without
introducing a second motion language, widening public APIs unnecessarily, or making the system feel
busy.
## Scope
- In scope:
- add polished active-guide, tooltip, and marker motion to `Chart`
- add a one-time sweep and value count-up feel to `Gauge`
- add shared active-pill motion to `Tabs` and `SegmentedControl`
- add lighter interaction transitions to the newly polished `DataTable`
- add subtle media float/breathe and CTA stagger to `EmptyState`
- add active-row glide, list crossfade, and softer loading treatment to `Command` and `Combobox`
- update the nearest stories and tests when behavior changes need review or coverage
- Out of scope:
- introducing new animation dependencies
- changing shared token files unless the main thread explicitly decides it is necessary
- turning core surfaces into ambient animation showcases
## Constraints
- Respect reduced motion and the repo's current Material-runtime design direction.
- Favor `transform`, `opacity`, `box-shadow`, and state-driven transitions over layout-heavy motion.
- Keep each worker on an isolated write scope.
- Preserve existing public component contracts unless a small compatibility-safe extension is
justified.
- Do not revert or overwrite unrelated in-progress repo changes.
## Affected Surfaces
- `packages/ui/src/components/chart*`
- `packages/ui/src/components/gauge*`
- `packages/ui/src/components/tabs*`
- `packages/ui/src/components/segmented-control*`
- `packages/ui/src/components/data-table*`
- `packages/ui/src/components/empty-state*`
- `packages/ui/src/components/command*`
- `packages/ui/src/components/combobox*`
- `apps/docs/src/components/chart.stories.tsx`
- `apps/docs/src/components/gauge.stories.tsx`
- `apps/docs/src/components/tabs.stories.tsx`
- `apps/docs/src/components/segmented-control.stories.tsx`
- `apps/docs/src/components/data-table.stories.tsx`
- `apps/docs/src/components/empty-state.stories.tsx`
- `apps/docs/src/components/command.stories.tsx`
- `apps/docs/src/components/combobox.stories.tsx`
- `docs/exec-plans`
## Plan
1. Write a dedicated execution plan for the motion pass so the second round of work is resumable.
2. Dispatch six isolated workers, each owning one motion slice and its closest tests/docs.
3. Review the combined result set in the main thread and fix any compatibility or API-semantics
issues before broader validation.
4. Run focused component, typecheck, and docs validation on the combined motion changes.
5. Record results, remaining risks, and any intentionally deferred motion refinements.
## Validation
- `pnpm --filter @ai-ui/ui exec vitest run <owned test files>`
- `pnpm --filter @ai-ui/ui exec tsc --noEmit`
- `pnpm --dir apps/docs exec tsc --noEmit`
- `pnpm build:docs`
## Orchestration Task Sketch
- `T1`: polish `Chart` motion
- `T2`: polish `Gauge` motion
- `T3`: polish `Tabs` and `SegmentedControl` motion
- `T4`: polish `DataTable` motion
- `T5`: polish `EmptyState` motion
- `T6`: polish `Command` and `Combobox` motion
- `T7 -> T1,T2,T3,T4,T5,T6`: integrate results and validate
## Status Log
- `2026-03-25 16:45` started the motion-focused second pass after confirming the current component state from the first visual polish round
- `2026-03-25 16:47` wrote the motion pass plan and prepared six isolated worker slices with main-thread integration reserved for compatibility fixes and validation
- `2026-03-25 16:59` all six motion slices returned from delegated workers: `Chart`, `Gauge`, `Tabs/SegmentedControl`, `DataTable`, `EmptyState`, and `Command/Combobox`
- `2026-03-25 17:00` validated the combined result set with targeted Vitest coverage, `pnpm --filter @ai-ui/ui exec tsc --noEmit`, `pnpm --dir apps/docs exec tsc --noEmit`, and `pnpm build:docs`
@@ -0,0 +1,60 @@
# Progress Patterns
- Status: `completed`
- Owner: `Codex`
- Date: `2026-03-25`
## Goal
Extend `Progress` beyond the default linear fill so the component can also render segmented
dashboard-style progress bars like the operational cost reduction example, without splitting the
API into separate one-off components.
## Scope
- In scope:
- add a structural `pattern` dimension to `Progress`
- support a segmented dashboard meter mode
- keep the existing linear pattern untouched as the default
- update tests, exports, and Storybook docs
- Out of scope:
- radial gauge visuals
- introducing a separate `Gauge` component
- modifying unrelated dashboard story drafts
## Constraints
- Keep `variant` reserved for semantic color intent.
- Preserve the existing ARIA progressbar behavior.
- Expose stable slots and `data-*` hooks for the segmented structure.
- Stay within the current Cadence UI tonal and motion language.
## Affected Surfaces
- `packages/ui/src/components/progress.tsx`
- `packages/ui/src/components/progress.variants.ts`
- `packages/ui/src/components/progress.test.tsx`
- `packages/ui/src/skins.css`
- `packages/ui/src/index.ts`
- `apps/docs/src/components/progress.stories.tsx`
## Plan
1. Add a `pattern` API for `linear` and `segmented` progress structures.
2. Implement segmented rendering with stable slots and deterministic filled-state logic.
3. Refresh docs so the segmented meter is shown in a realistic dashboard context.
4. Run targeted validation for the package and docs surfaces.
## Validation
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm --filter @ai-ui/ui test -- --run packages/ui/src/components/progress.test.tsx`
- `pnpm harness:validate:docs`
## Status Log
- `2026-03-25 17:58` Confirmed the existing Progress API is linear-only and inspected the local revenue dashboard draft for the target segmented visual language.
- `2026-03-25 18:10` Added a `pattern` dimension with segmented rendering, repeated segment slots, and progress-specific skin tokens for dashboard meters.
- `2026-03-25 18:14` Updated Storybook docs and package exports to document and expose the segmented mode.
- `2026-03-25 18:18` Verified `pnpm --filter @ai-ui/ui typecheck`, `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/progress.test.tsx --config ../../vitest.config.ts`, and targeted `eslint` on the changed files.
- `2026-03-25 18:19` `pnpm harness:validate:docs` is currently blocked by an unrelated local Storybook draft: `apps/docs/src/components/metric-card.stories.tsx` imports `MetricCard` from `@ai-ui/ui`, but that export is not present in the built package yet.
@@ -0,0 +1,57 @@
# Progress Refresh
- Status: `completed`
- Owner: `Codex`
- Date: `2026-03-25`
## Goal
Bring `Progress` closer to the active Cadence UI visual language so it reads like a tonal system
component instead of a generic utility bar, while keeping the existing API and accessibility
contract intact.
## Scope
- In scope:
- refresh the `Progress` track and indicator styling
- add progress-specific skin tokens in `packages/ui/src/skins.css`
- update Storybook stories so the new anatomy, state, and motion behavior are reviewable
- keep unit coverage aligned with the component contract
- Out of scope:
- adding labels, percentages, or new public props
- changing exports or introducing new dependencies
## Constraints
- Preserve the current `Progress` API surface (`size`, `tone`, `variant`, `value`, `max`).
- Keep the stable `data-slot` and `data-state` hooks.
- Use existing token roles and motion primitives instead of hardcoded one-off colors.
- Keep indeterminate behavior usable under static/reduced motion.
## Affected Surfaces
- `packages/ui/src/components/progress.tsx`
- `packages/ui/src/components/progress.variants.ts`
- `packages/ui/src/components/progress.test.tsx`
- `packages/ui/src/skins.css`
- `apps/docs/src/components/progress.stories.tsx`
## Plan
1. Add progress skin tokens for track depth, highlight, and indicator gloss.
2. Rework the component styling around those tokens without changing the public props.
3. Refresh docs stories to show the component in more intentional, reviewable product contexts.
4. Run the narrowest harness suites that cover the changed component and docs surfaces.
## Validation
- `pnpm harness:validate:component`
- `pnpm harness:validate:docs`
## Status Log
- `2026-03-25 17:20` Read system-of-record files and inspected the current Progress source, tests, and stories.
- `2026-03-25 17:25` Started implementation for the visual refresh and docs alignment.
- `2026-03-25 17:42` Added progress skin tokens, refreshed the track and indicator styling, and rewrote the Storybook review surface.
- `2026-03-25 17:48` Verified `pnpm --filter @ai-ui/ui typecheck`, `pnpm --filter @ai-ui/ui test -- --run packages/ui/src/components/progress.test.tsx`, and `pnpm harness:validate:docs`.
- `2026-03-25 17:49` `pnpm harness:validate:component` still fails on pre-existing type errors in `apps/docs/src/revenue-dashboard.stories.tsx`, unrelated to the Progress work.
+3
View File
@@ -338,6 +338,9 @@ export const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps
>
<div
{...props}
{...createDataAttributes({
state: item.open ? "open" : "closed"
})}
className={cn(accordionContentInnerVariants(), className)}
ref={ref}
style={{
@@ -39,11 +39,18 @@ export const accordionIconVariants = cva(
export const accordionContentVariants = cva(
[
"grid overflow-hidden border-t border-[color-mix(in_oklch,var(--ui-card-default-border)_88%,transparent)]",
"transition-[grid-template-rows,opacity] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]",
"data-[state=closed]:grid-rows-[0fr] data-[state=closed]:opacity-70",
"transition-[grid-template-rows,opacity,border-color] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[state=closed]:grid-rows-[0fr] data-[state=closed]:opacity-72",
"data-[state=open]:grid-rows-[1fr] data-[state=open]:opacity-100",
"motion-reduce:transition-none",
getMotionRecipeClassNames("transition")
]
);
export const accordionContentInnerVariants = cva("min-h-0 px-5 pb-5 pt-1");
export const accordionContentInnerVariants = cva([
"min-h-0 px-5 pb-5 pt-1",
"transition-[transform,opacity,filter] duration-[var(--dur-base)] ease-[var(--ease-emphasized)] will-change-transform",
"data-[state=closed]:-translate-y-[calc(var(--distance-xs)*0.75)] data-[state=closed]:scale-[0.985] data-[state=closed]:opacity-0",
"data-[state=open]:translate-y-0 data-[state=open]:scale-100 data-[state=open]:opacity-100",
"motion-reduce:transition-none motion-reduce:data-[state=closed]:translate-y-0 motion-reduce:data-[state=closed]:scale-100"
]);
+11
View File
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Alert, AlertDescription, AlertTitle } from "./alert";
import { alertIconVariants, alertVariants } from "./alert.variants";
describe("Alert", () => {
it("renders root, icon, title, and description slots", () => {
@@ -42,4 +43,14 @@ describe("Alert", () => {
expect(screen.getByRole("status")).toHaveAttribute("data-variant", "default");
});
it("includes restrained entry and icon polish hooks", () => {
expect(alertVariants({ hasIcon: true, variant: "success" })).toContain(
"motion-safe:[animation:aiui-fade-in_var(--dur-base)_var(--ease-standard)_both,aiui-slide-up-sm_var(--dur-fast)_var(--ease-standard)_both]"
);
expect(alertVariants({ hasIcon: true, variant: "success" })).toContain(
"[&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-success)_16%,white_84%)]"
);
expect(alertIconVariants()).toContain("size-8");
});
});
+14 -6
View File
@@ -3,21 +3,24 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const alertVariants = cva(
[
"relative grid gap-x-3 gap-y-1 rounded-[var(--ui-card-radius)] border p-4 shadow-[var(--ui-card-subtle-shadow)] [border-width:var(--ui-card-border-width)]",
"relative isolate grid gap-x-3 gap-y-1 overflow-hidden rounded-[var(--ui-card-radius)] border p-4 shadow-[var(--ui-card-subtle-shadow)] [border-width:var(--ui-card-border-width)]",
"text-[var(--color-foreground)]",
"before:pointer-events-none before:absolute before:inset-x-[14%] before:top-0 before:h-[58%] before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_24%,transparent),transparent_74%)] before:opacity-70 before:blur-2xl before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"motion-safe:[animation:aiui-fade-in_var(--dur-base)_var(--ease-standard)_both,aiui-slide-up-sm_var(--dur-fast)_var(--ease-standard)_both] motion-reduce:animate-none",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
variant: {
default:
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)]",
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] [&>[data-slot=icon]]:border-[color-mix(in_oklch,var(--color-border)_68%,transparent)] [&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-surface-bright)_82%,white_18%)] [&>[data-slot=icon]]:text-[var(--color-primary)]",
success:
"border-[color-mix(in_oklch,var(--color-success)_34%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-success)_10%,var(--ui-card-default-bg))]",
"border-[color-mix(in_oklch,var(--color-success)_34%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-success)_10%,var(--ui-card-default-bg))] [&>[data-slot=icon]]:border-[color-mix(in_oklch,var(--color-success)_18%,transparent)] [&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-success)_16%,white_84%)] [&>[data-slot=icon]]:text-[color-mix(in_oklch,var(--color-success)_82%,var(--color-foreground))]",
warning:
"border-[color-mix(in_oklch,var(--color-warning)_34%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-warning)_12%,var(--ui-card-default-bg))]",
"border-[color-mix(in_oklch,var(--color-warning)_34%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-warning)_12%,var(--ui-card-default-bg))] [&>[data-slot=icon]]:border-[color-mix(in_oklch,var(--color-warning)_18%,transparent)] [&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-warning)_18%,white_82%)] [&>[data-slot=icon]]:text-[color-mix(in_oklch,var(--color-warning)_84%,var(--color-foreground))]",
destructive:
"border-[color-mix(in_oklch,var(--color-destructive)_38%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-destructive)_10%,var(--ui-card-default-bg))]"
"border-[color-mix(in_oklch,var(--color-destructive)_38%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-destructive)_10%,var(--ui-card-default-bg))] [&>[data-slot=icon]]:border-[color-mix(in_oklch,var(--color-destructive)_20%,transparent)] [&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-destructive)_14%,white_86%)] [&>[data-slot=icon]]:text-[var(--color-destructive)]"
},
hasIcon: {
false: "",
@@ -32,7 +35,12 @@ export const alertVariants = cva(
);
export const alertIconVariants = cva(
"row-span-2 mt-0.5 inline-flex size-5 items-center justify-center rounded-[var(--radius-full)] text-[var(--color-muted-foreground)]"
[
"row-span-2 mt-0.5 inline-flex size-8 items-center justify-center rounded-[var(--radius-full)] border",
"shadow-[inset_0_1px_0_rgba(255,255,255,0.42)]",
"transition-[transform,background-color,color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"motion-reduce:transition-none"
]
);
export const alertTitleVariants = cva(
@@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Avatar, AvatarFallback } from "./avatar";
import { avatarFallbackVariants, avatarImageVariants, avatarVariants } from "./avatar.variants";
describe("Avatar", () => {
it("renders root slot metadata and fallback content", async () => {
@@ -33,4 +34,12 @@ describe("Avatar", () => {
expect(screen.getByText("JD")).toHaveAttribute("data-slot", "fallback");
});
});
it("exposes image/fallback crossfade hooks", () => {
expect(avatarVariants()).toContain(
"[&>[data-slot=image][data-state=loaded]~[data-slot=fallback]]:opacity-0"
);
expect(avatarImageVariants()).toContain("data-[state=loaded]:opacity-100");
expect(avatarFallbackVariants()).toContain("transition-[opacity,transform,background-color]");
});
});
+11 -3
View File
@@ -3,8 +3,11 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const avatarVariants = cva(
[
"relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden border shadow-[var(--ui-control-shadow)]",
"relative isolate inline-flex shrink-0 select-none items-center justify-center overflow-hidden border shadow-[var(--ui-control-shadow)]",
"bg-[var(--ui-control-bg)] text-[var(--color-foreground)] [border-width:var(--ui-input-border-width)]",
"before:pointer-events-none before:absolute before:inset-[14%] before:rounded-[inherit] before:bg-[radial-gradient(circle_at_top,color-mix(in_oklch,white_68%,transparent),transparent_74%)] before:opacity-82 before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"[&>[data-slot=image][data-state=loaded]~[data-slot=fallback]]:opacity-0 [&>[data-slot=image][data-state=loaded]~[data-slot=fallback]]:scale-[0.96]",
getMotionRecipeClassNames("transition", "ring")
],
{
@@ -35,9 +38,14 @@ export const avatarVariants = cva(
);
export const avatarImageVariants = cva([
"size-full object-cover object-center"
"absolute inset-0 size-full object-cover object-center",
"opacity-0 scale-[1.035] transition-[opacity,transform,filter] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]",
"data-[state=loaded]:opacity-100 data-[state=loaded]:scale-100",
"motion-reduce:transition-none motion-reduce:data-[state=loaded]:scale-100"
]);
export const avatarFallbackVariants = cva([
"flex size-full items-center justify-center bg-[inherit] text-inherit font-medium uppercase tracking-[0.08em]"
"absolute inset-0 flex size-full items-center justify-center bg-[inherit] text-inherit font-medium uppercase tracking-[0.08em]",
"transition-[opacity,transform,background-color] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"motion-reduce:transition-none"
]);
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "./badge";
import { badgeVariants } from "./badge.variants";
describe("Badge", () => {
it("renders with root and label slots plus data hooks", () => {
@@ -32,4 +33,12 @@ describe("Badge", () => {
expect(link).toHaveAttribute("data-slot", "root");
expect(link).toHaveAttribute("data-tone", "primary");
});
it("includes tactile chip hooks for hover and pressed states", () => {
const subtle = badgeVariants({ variant: "subtle" });
expect(subtle).toContain("aria-[pressed=true]:before:opacity-100");
expect(subtle).toContain("motion-safe:hover:-translate-y-px");
expect(subtle).toContain("before:translate-x-[-18%]");
});
});
+7 -4
View File
@@ -3,8 +3,11 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const badgeVariants = cva(
[
"inline-flex shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-[var(--ui-control-radius)] border font-medium [border-width:var(--ui-input-border-width)]",
"relative isolate inline-flex shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-[var(--ui-control-radius)] border font-medium [border-width:var(--ui-input-border-width)]",
"outline-none select-none",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:bg-[radial-gradient(circle_at_left,color-mix(in_oklch,white_54%,transparent),transparent_74%)] before:opacity-0 before:translate-x-[-18%] before:transition-[opacity,transform] before:duration-[var(--dur-fast)] before:ease-[var(--ease-standard)] before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"aria-[pressed=true]:before:opacity-100 aria-[pressed=true]:before:translate-x-0 aria-[pressed=true]:shadow-[0_10px_20px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]",
getMotionRecipeClassNames("transition", "ring")
],
{
@@ -15,11 +18,11 @@ export const badgeVariants = cva(
},
variant: {
subtle:
"border-[var(--ui-control-border)] bg-[var(--ui-control-bg)] text-[var(--color-foreground)] shadow-[var(--ui-control-shadow)]",
"border-[var(--ui-control-border)] bg-[var(--ui-control-bg)] text-[var(--color-foreground)] shadow-[var(--ui-control-shadow)] motion-safe:hover:-translate-y-px motion-safe:hover:before:opacity-100 motion-safe:hover:before:translate-x-0 motion-safe:hover:shadow-[0_10px_20px_color-mix(in_oklch,var(--color-primary)_8%,transparent),var(--ui-control-shadow)] motion-reduce:hover:translate-y-0",
solid:
"border-transparent bg-[var(--color-foreground)] text-[var(--color-background)]",
"border-transparent bg-[var(--color-foreground)] text-[var(--color-background)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_28%,transparent)] motion-safe:hover:-translate-y-px motion-safe:hover:before:opacity-72 motion-safe:hover:before:translate-x-0 motion-safe:hover:shadow-[0_10px_18px_color-mix(in_oklch,var(--color-foreground)_14%,transparent)] motion-reduce:hover:translate-y-0",
outline:
"border-[var(--ui-control-border)] bg-transparent text-[var(--color-foreground)]"
"border-[var(--ui-control-border)] bg-transparent text-[var(--color-foreground)] motion-safe:hover:-translate-y-px motion-safe:hover:bg-[color-mix(in_oklch,var(--ui-control-bg)_72%,white_28%)] motion-safe:hover:before:opacity-100 motion-safe:hover:before:translate-x-0 motion-safe:hover:shadow-[0_10px_20px_color-mix(in_oklch,var(--color-primary)_8%,transparent)] motion-reduce:hover:translate-y-0"
},
tone: {
neutral: "",
+12 -2
View File
@@ -36,13 +36,23 @@ describe("Card", () => {
expect(screen.getByText("Updated 2h ago")).toHaveAttribute("data-slot", "footer");
});
it("supports interactive state hooks", () => {
it("defaults to the interactive state hook", () => {
render(
<Card data-testid="card" interactive>
<Card data-testid="card">
<CardContent>Hover capable</CardContent>
</Card>
);
expect(screen.getByTestId("card")).toHaveAttribute("data-interactive", "");
});
it("supports explicitly opting out of interactive treatment", () => {
render(
<Card data-testid="card" interactive={false}>
<CardContent>Static detail</CardContent>
</Card>
);
expect(screen.getByTestId("card")).not.toHaveAttribute("data-interactive");
});
});
+13 -11
View File
@@ -24,15 +24,17 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
},
ref
) {
const resolvedInteractive = interactive ?? true;
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
interactive,
interactive: resolvedInteractive,
tone
})}
className={cn(cardVariants({ interactive, tone }), className)}
className={cn(cardVariants({ interactive: resolvedInteractive, tone }), className)}
ref={ref}
/>
);
@@ -60,15 +62,15 @@ export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(function
{ className, ...props },
ref
) {
return (
<h3
{...props}
{...createSlot("label")}
className={cn(cardTitleVariants(), className)}
ref={ref}
/>
);
}
return (
<h3
{...props}
{...createSlot("label")}
className={cn(cardTitleVariants(), className)}
ref={ref}
/>
);
}
);
export type CardDescriptionProps = React.ComponentPropsWithoutRef<"p">;
+10 -3
View File
@@ -19,13 +19,20 @@ export const cardVariants = cva(
},
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)]"
true: [
"relative isolate overflow-hidden",
"before:pointer-events-none before:absolute before:inset-px before:rounded-[calc(var(--ui-card-radius)-1px)] before:bg-[radial-gradient(circle_at_top,color-mix(in_oklch,var(--color-primary-container)_28%,transparent),transparent_62%)] before:opacity-0 before:content-['']",
"before:transition-opacity before:duration-[var(--dur-base)] before:ease-[var(--ease-standard)]",
"hover:border-[color-mix(in_oklch,var(--color-primary)_16%,var(--ui-card-default-border))]",
"hover:translate-y-[calc(var(--ui-card-hover-translate)*0.45)] hover:scale-[var(--ui-card-hover-scale,1)] hover:shadow-[var(--ui-card-hover-shadow)] hover:before:opacity-100",
"focus-within:translate-y-[calc(var(--ui-card-hover-translate)*0.2)] focus-within:shadow-[var(--ui-card-hover-shadow)] focus-within:before:opacity-80",
"motion-reduce:hover:translate-y-0 motion-reduce:hover:scale-100 motion-reduce:focus-within:translate-y-0"
]
}
},
defaultVariants: {
tone: "default",
interactive: false
interactive: true
}
}
);
+4 -1
View File
@@ -12,16 +12,19 @@ describe("Checkbox", () => {
render(<Checkbox aria-label="Accept terms" onCheckedChange={onCheckedChange} />);
const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
const icon = checkbox.querySelector('[data-slot="icon"]');
expect(checkbox).toHaveAttribute("data-slot", "root");
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(icon).toHaveAttribute("data-state", "unchecked");
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(checkbox).toHaveAttribute("data-state", "checked");
expect(onCheckedChange).toHaveBeenCalledWith(true);
expect(checkbox.querySelector('[data-slot="icon"] svg')).toBeInTheDocument();
expect(icon).toHaveAttribute("data-state", "checked");
expect(icon?.querySelector("svg")).toBeInTheDocument();
});
it("supports keyboard interaction", async () => {
+1
View File
@@ -29,6 +29,7 @@ export const Checkbox = forwardRef<
>
<CheckboxPrimitive.Indicator
{...createSlot("icon")}
forceMount
className={checkboxIndicatorVariants()}
>
<CheckIcon className="size-3.5" />
@@ -15,5 +15,9 @@ export const checkboxVariants = cva(
);
export const checkboxIndicatorVariants = cva([
"inline-flex items-center justify-center text-[0.8rem] leading-none"
"inline-flex items-center justify-center text-[0.8rem] leading-none",
"will-change-transform transition-[opacity,transform] duration-[var(--dur-fast)] ease-[var(--ease-emphasized)]",
"data-[state=unchecked]:scale-[0.72] data-[state=unchecked]:opacity-0",
"data-[state=checked]:scale-100 data-[state=checked]:opacity-100",
"motion-reduce:transition-none motion-reduce:data-[state=unchecked]:scale-100"
]);
+15 -1
View File
@@ -75,6 +75,12 @@ describe("Combobox", () => {
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
await user.click(trigger);
expect(await screen.findByRole("option", { name: /Legal review/i })).toHaveAttribute(
"data-selected",
""
);
});
it("supports a controlled value and reports updates", async () => {
@@ -186,6 +192,14 @@ describe("Combobox", () => {
await user.click(trigger);
expect(screen.getByText("Searching review lanes…")).toBeInTheDocument();
expect(document.querySelector('[data-slot="root"][data-loading]')).toHaveAttribute(
"data-loading",
""
);
expect(document.querySelector('[data-slot="root"][data-loading]')).toHaveAttribute(
"aria-busy",
"true"
);
expect(screen.getByText("Manage routing lanes")).toBeInTheDocument();
loadingView.unmount();
@@ -221,7 +235,7 @@ describe("Combobox", () => {
const refreshedSearchbox = screen.getByRole("searchbox", { name: "Search options" });
await user.clear(refreshedSearchbox);
await user.type(refreshedSearchbox, "security");
expect(screen.getByText("Create “security” as a new lane")).toBeInTheDocument();
expect(await screen.findByText("Create “security” as a new lane")).toBeInTheDocument();
await user.keyboard("{Tab}");
await waitFor(() => {
+172 -82
View File
@@ -1,4 +1,5 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
forwardRef,
useEffect,
@@ -32,6 +33,32 @@ function mergeIds(...ids: Array<string | undefined>) {
return value.length > 0 ? value : undefined;
}
function useMotionDisabled() {
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 getNextEnabledIndex(
items: ComboboxItem[],
currentIndex: number,
@@ -122,6 +149,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
const searchRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const disableMotion = useMotionDisabled();
const resolvedOpen = open ?? uncontrolledOpen;
const resolvedValue = value ?? uncontrolledValue;
const resolvedSearchValue = searchValue ?? uncontrolledSearchValue;
@@ -158,7 +186,15 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
return haystack.includes(query);
});
}, [filter, items, resolvedSearchValue]);
const listboxId = filteredItems.length > 0 ? `${controlId}-listbox` : undefined;
const listboxId = !loading && filteredItems.length > 0 ? `${controlId}-listbox` : undefined;
const presenceTransition = {
duration: disableMotion ? 0.01 : 0.18,
ease: [0.22, 1, 0.36, 1]
} as const;
const rowTransition = {
duration: disableMotion ? 0.01 : 0.16,
ease: [0.22, 1, 0.36, 1]
} as const;
const groupedItems = useMemo(() => {
const groups = new Map<string, ComboboxItem[]>();
@@ -341,9 +377,11 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
loading,
open: resolvedOpen,
value: resolvedValue || undefined
})}
aria-busy={loading || undefined}
className="grid gap-2"
>
<PopoverPrimitive.Root onOpenChange={setOpenState} open={resolvedOpen}>
@@ -402,88 +440,140 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
value={resolvedSearchValue}
/>
</div>
{loading ? (
<div {...createSlot("empty")} className={comboboxEmptyVariants()}>
<span className="inline-flex items-center gap-2">
<SpinnerIcon className="size-4 animate-spin" />
{loadingMessage}
</span>
</div>
) : filteredItems.length === 0 ? (
<div {...createSlot("empty")} className={comboboxEmptyVariants()}>
{renderedEmptyMessage}
</div>
) : (
<div
{...createSlot("list")}
className={comboboxListVariants()}
id={listboxId}
role="listbox"
>
{groupedItems.map(([group, groupItems]) => (
<div
key={group || "default"}
{...createSlot("group")}
className={comboboxGroupVariants()}
>
{group ? (
<div {...createSlot("label")} className={comboboxLabelVariants()}>
{group}
</div>
) : null}
{groupItems.map((item) => {
const itemIndex = filteredItems.findIndex((entry) => entry.value === item.value);
const isSelected = item.value === resolvedValue;
const isActive = itemIndex === activeIndex;
return (
<button
key={item.value}
{...createSlot("item")}
{...createDataAttributes({
active: isActive,
disabled: item.disabled,
selected: isSelected
})}
aria-selected={isSelected}
className={comboboxItemVariants()}
onClick={() => {
handleSelect(item);
}}
onMouseEnter={() => {
setActiveIndex(itemIndex);
}}
ref={(node) => {
itemRefs.current[itemIndex] = node;
}}
role="option"
type="button"
>
<span
{...createSlot("icon")}
aria-hidden="true"
className={cn(
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-[var(--color-primary)]",
!isSelected && "opacity-0"
)}
>
<CheckIcon className="size-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{item.label}</span>
{item.description ? (
<span className="mt-0.5 block text-xs leading-5 text-[var(--color-muted-foreground)]">
{item.description}
</span>
) : null}
</span>
</button>
);
})}
<AnimatePresence initial={false} mode="wait">
{loading ? (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: disableMotion ? 0 : -4 }}
initial={{ opacity: 0, y: disableMotion ? 0 : 6 }}
key={`loading:${resolvedSearchValue}`}
transition={presenceTransition}
>
<div {...createSlot("empty")} className={comboboxEmptyVariants()}>
<span className="inline-flex items-center gap-2">
<SpinnerIcon className="size-4 animate-spin text-[var(--color-primary)]" />
{loadingMessage}
</span>
</div>
))}
</div>
)}
</motion.div>
) : filteredItems.length === 0 ? (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: disableMotion ? 0 : -4 }}
initial={{ opacity: 0, y: disableMotion ? 0 : 6 }}
key={`empty:${resolvedSearchValue.trim()}`}
transition={presenceTransition}
>
<div {...createSlot("empty")} className={comboboxEmptyVariants()}>
{renderedEmptyMessage}
</div>
</motion.div>
) : (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: disableMotion ? 0 : -4 }}
initial={{ opacity: 0, y: disableMotion ? 0 : 6 }}
key={`results:${resolvedSearchValue.trim()}:${filteredItems.map((item) => item.value).join("|")}`}
transition={presenceTransition}
>
<div
{...createSlot("list")}
className={comboboxListVariants()}
id={listboxId}
role="listbox"
>
{groupedItems.map(([group, groupItems]) => (
<div
key={group || "default"}
{...createSlot("group")}
className={comboboxGroupVariants()}
>
{group ? (
<div {...createSlot("label")} className={comboboxLabelVariants()}>
{group}
</div>
) : null}
{groupItems.map((item) => {
const itemIndex = filteredItems.findIndex((entry) => entry.value === item.value);
const isSelected = item.value === resolvedValue;
const isActive = itemIndex === activeIndex;
return (
<button
key={item.value}
{...createSlot("item")}
{...createDataAttributes({
active: isActive,
disabled: item.disabled,
selected: isSelected
})}
aria-selected={isSelected}
className={comboboxItemVariants()}
onClick={() => {
handleSelect(item);
}}
onMouseEnter={() => {
setActiveIndex(itemIndex);
}}
ref={(node) => {
itemRefs.current[itemIndex] = node;
}}
role="option"
type="button"
>
<motion.span
animate={
isSelected
? { opacity: 1, scale: 1, x: 0 }
: isActive
? { opacity: 0.78, scale: 1, x: 0 }
: { opacity: 0, scale: 0.985, x: disableMotion ? 0 : -10 }
}
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-[radial-gradient(circle_at_left,color-mix(in_oklch,var(--color-primary-container)_48%,transparent),transparent_74%)]"
transition={rowTransition}
/>
<span
{...createSlot("icon")}
aria-hidden="true"
className={cn(
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-[var(--color-primary)] transition-[opacity,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
isSelected ? "scale-100 opacity-100" : "scale-75 opacity-0"
)}
>
<CheckIcon className="size-3.5" />
</span>
<motion.span
animate={{
x: isSelected ? 1.5 : isActive ? 0.75 : 0
}}
className="min-w-0 flex-1"
transition={rowTransition}
>
<span
{...createSlot("label")}
className="block truncate font-medium"
>
{item.label}
</span>
{item.description ? (
<span
{...createSlot("description")}
className="mt-0.5 block text-xs leading-5 text-[var(--color-muted-foreground)]"
>
{item.description}
</span>
) : null}
</motion.span>
</button>
);
})}
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{footer ? (
<div {...createSlot("footer")} className={comboboxFooterVariants()}>
{footer}
@@ -27,26 +27,36 @@ export const comboboxSearchVariants = cva([
]);
export const comboboxListVariants = cva([
"max-h-[18rem] overflow-y-auto p-1"
"max-h-[18rem] overflow-y-auto p-1.5 transition-[opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
]);
export const comboboxGroupVariants = cva([
"grid gap-1"
"grid gap-1.5 p-0.5 transition-opacity duration-[var(--dur-fast)] ease-[var(--ease-standard)]"
]);
export const comboboxLabelVariants = cva([
"px-2.5 py-2 text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
"px-3 py-1.5 text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
]);
export const comboboxItemVariants = cva([
"relative flex w-full cursor-default select-none items-start gap-3 rounded-[var(--ui-control-radius)] px-3 py-2 text-left text-sm text-[var(--color-foreground)] outline-none",
"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[active=true]:bg-[var(--ui-control-bg)] data-[selected=true]:bg-[color-mix(in_oklch,var(--color-primary)_10%,var(--ui-panel-bg))]",
"data-[selected=true]:text-[var(--color-foreground)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45"
"relative isolate flex w-full cursor-default select-none items-start gap-3 overflow-hidden rounded-[calc(var(--ui-control-radius)+0.1rem)] border border-transparent px-3 py-2.5 text-left text-sm text-[var(--color-foreground)] outline-none",
"bg-transparent shadow-[inset_0_1px_0_transparent]",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:bg-[radial-gradient(circle_at_left,color-mix(in_oklch,var(--color-primary-container)_42%,transparent),transparent_74%)] before:opacity-0 before:translate-x-[-12%] before:transition-[opacity,transform] before:duration-[var(--dur-base)] before:ease-[var(--ease-emphasized)] before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"hover:-translate-y-px hover:border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] hover:bg-[color-mix(in_oklch,var(--color-surface-container-high)_72%,white_28%)] hover:shadow-[inset_0_1px_0_color-mix(in_oklch,white_52%,transparent)] hover:before:opacity-72 hover:before:translate-x-0",
"data-[active=true]:-translate-y-px data-[active=true]:translate-x-[1px] data-[active=true]:border-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-border))] data-[active=true]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-high)_84%,white_16%),color-mix(in_oklch,var(--ui-control-bg)_82%,white_18%))] data-[active=true]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_54%,transparent),0_10px_22px_color-mix(in_oklch,var(--color-primary)_8%,transparent)] data-[active=true]:before:opacity-80 data-[active=true]:before:translate-x-0",
"data-[selected=true]:translate-x-[2px] data-[selected=true]:border-[color-mix(in_oklch,var(--color-primary)_22%,var(--color-border))] data-[selected=true]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_56%,white_44%),color-mix(in_oklch,var(--ui-control-bg)_74%,white_26%))] data-[selected=true]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_60%,transparent),0_12px_24px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] data-[selected=true]:before:opacity-100 data-[selected=true]:before:translate-x-0",
"[&_[data-slot=label]]:transition-colors [&_[data-slot=label]]:duration-[var(--dur-fast)] [&_[data-slot=description]]:transition-colors [&_[data-slot=description]]:duration-[var(--dur-fast)]",
"[&[data-active]_[data-slot=description]]:text-[color-mix(in_oklch,var(--color-foreground)_74%,var(--color-muted-foreground))]",
"[&[data-selected]_[data-slot=description]]:text-[color-mix(in_oklch,var(--color-primary)_46%,var(--color-muted-foreground))]",
"motion-reduce:hover:-translate-y-0 motion-reduce:data-[active=true]:-translate-y-0 motion-reduce:data-[active=true]:translate-x-0 motion-reduce:data-[selected=true]:translate-x-0",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45",
getMotionRecipeClassNames("ring")
]);
export const comboboxEmptyVariants = cva([
"px-3 py-6 text-center text-sm text-[var(--color-muted-foreground)]"
"mx-1.5 my-1.5 rounded-[calc(var(--ui-control-radius)+0.15rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_76%,white_24%)] px-4 py-6 text-center text-sm text-[var(--color-muted-foreground)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_44%,transparent)] motion-enter-fade motion-enter-rise"
]);
export const comboboxFooterVariants = cva([
+13 -1
View File
@@ -40,13 +40,23 @@ describe("Command", () => {
const root = document.querySelector('[data-slot="root"]');
const input = screen.getByPlaceholderText("Search actions");
const control = input.closest('[data-slot="control"]');
expect(root).toHaveAttribute("data-slot", "root");
expect(control).toBeInTheDocument();
expect(input).toHaveAttribute("data-slot", "input");
expect(control?.querySelector('[data-slot="prefix"]')).toBeTruthy();
expect(document.querySelector('[data-slot="list"]')).toBeInTheDocument();
expect(screen.getByText("Open legal review").closest('[data-slot="item"]')).toBeInTheDocument();
expect(screen.getByText("G L")).toHaveAttribute("data-slot", "shortcut");
await user.click(input);
await user.keyboard("{ArrowDown}");
expect(screen.getByText("Open legal review").closest('[data-slot="item"]')).toHaveAttribute(
"data-selected",
"true"
);
await user.type(input, "zzz");
expect(await screen.findByText("No results.")).toHaveAttribute("data-slot", "empty");
});
@@ -113,7 +123,9 @@ describe("Command", () => {
</Command>
);
expect(screen.getByText("Searching workspace…")).toHaveAttribute("data-slot", "loading");
expect(document.querySelector('[data-slot="root"]')).toHaveAttribute("data-loading", "");
expect(document.querySelector('[data-slot="root"]')).toHaveAttribute("aria-busy", "true");
expect(screen.getByText("Searching workspace…").closest('[data-slot="loading"]')).toBeInTheDocument();
expect(screen.getByText("Manage commands")).toBeInTheDocument();
});
+64 -14
View File
@@ -1,7 +1,10 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
forwardRef,
useEffect,
useState,
type ComponentPropsWithoutRef,
type ElementRef,
type ReactNode
@@ -28,6 +31,8 @@ import {
DialogHeader,
DialogTitle
} from "./dialog";
import { InputGroup, InputGroupPrefix } from "./input-group";
import { SpinnerIcon } from "../lib/icons";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
@@ -46,6 +51,32 @@ function SearchIcon() {
);
}
function useMotionDisabled() {
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;
}
export type CommandProps = ComponentPropsWithoutRef<typeof CommandPrimitive> & {
footer?: ReactNode;
loading?: boolean;
@@ -64,21 +95,41 @@ export const Command = forwardRef<ElementRef<typeof CommandPrimitive>, CommandPr
},
ref
) {
const disableMotion = useMotionDisabled();
return (
<CommandPrimitive
{...props}
{...createSlot("root")}
{...createDataAttributes({ loading })}
aria-busy={loading || undefined}
className={cn(commandVariants(), className)}
ref={ref}
>
{loading ? (
<div
{...createSlot("loading")}
className={commandLoadingVariants()}
>
{loadingMessage}
</div>
) : null}
<AnimatePresence initial={false}>
{loading ? (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: disableMotion ? 0 : -4 }}
initial={{ opacity: 0, y: disableMotion ? 0 : 6 }}
transition={{
duration: disableMotion ? 0.01 : 0.16,
ease: [0.22, 1, 0.36, 1]
}}
>
<div
{...createSlot("loading")}
className={commandLoadingVariants()}
>
<SpinnerIcon
aria-hidden="true"
className="size-4 animate-spin text-[var(--color-primary)]"
/>
<span>{loadingMessage}</span>
</div>
</motion.div>
) : null}
</AnimatePresence>
{children}
{footer ? (
<div
@@ -150,18 +201,17 @@ export const CommandInput = forwardRef<
CommandInputProps
>(function CommandInput({ className, wrapperClassName, ...props }, ref) {
return (
<div
{...createSlot("control")}
className={cn(commandInputWrapperVariants(), wrapperClassName)}
>
<SearchIcon />
<InputGroup className={cn(commandInputWrapperVariants(), wrapperClassName)} size="lg">
<InputGroupPrefix>
<SearchIcon />
</InputGroupPrefix>
<CommandPrimitive.Input
{...props}
{...createSlot("input")}
className={cn(commandInputVariants(), className)}
ref={ref}
/>
</div>
</InputGroup>
);
});
+27 -12
View File
@@ -2,8 +2,10 @@ import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const commandVariants = cva([
"flex h-full w-full flex-col overflow-hidden rounded-[var(--ui-panel-radius)] border border-[var(--ui-panel-border)]",
"relative flex h-full w-full flex-col overflow-hidden rounded-[var(--ui-panel-radius)] border border-[var(--ui-panel-border)]",
"bg-[var(--ui-panel-bg)] text-[var(--color-card-foreground)] shadow-[var(--ui-panel-shadow)] [border-width:var(--ui-panel-border-width)] backdrop-blur-[var(--ui-panel-backdrop-blur)]",
"[&_[data-slot=list]]:transition-[opacity,filter] [&_[data-slot=list]]:duration-[var(--dur-base)] [&_[data-slot=list]]:ease-[var(--ease-standard)]",
"data-[loading]:[&_[data-slot=list]]:opacity-70 data-[loading]:[&_[data-slot=list]]:saturate-[0.98]",
getMotionRecipeClassNames("transition", "ring")
]);
@@ -13,7 +15,10 @@ export const commandDialogContentVariants = cva([
]);
export const commandInputWrapperVariants = cva([
"flex items-center gap-3 border-b border-[var(--ui-panel-border)] px-4"
"rounded-none border-0 border-b border-[var(--ui-panel-border)] bg-transparent px-4 shadow-none backdrop-blur-none",
"data-[disabled]:bg-transparent data-[readonly]:bg-transparent",
"focus-within:-translate-y-0 focus-within:border-[var(--ui-panel-border)]",
"focus-within:shadow-none focus-within:ring-0 focus-within:ring-offset-0"
]);
export const commandInputVariants = cva([
@@ -22,34 +27,44 @@ export const commandInputVariants = cva([
]);
export const commandListVariants = cva([
"max-h-[22rem] overflow-y-auto overflow-x-hidden p-2"
"max-h-[22rem] overflow-y-auto overflow-x-hidden p-2.5 transition-[opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
]);
export const commandLoadingVariants = cva([
"px-4 py-8 text-sm text-[var(--color-muted-foreground)]"
"mx-3 mt-2 flex items-center gap-2.5 rounded-[calc(var(--ui-control-radius)+0.15rem)] border border-[color-mix(in_oklch,var(--color-primary)_16%,var(--ui-panel-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_34%,white_66%),color-mix(in_oklch,var(--ui-panel-bg)_88%,white_12%))] px-3.5 py-2.5 text-sm text-[var(--color-foreground)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_54%,transparent),0_10px_22px_color-mix(in_oklch,var(--color-primary)_8%,transparent)]"
]);
export const commandEmptyVariants = cva([
"py-10 text-center text-sm text-[var(--color-muted-foreground)]"
"mx-2 my-2 rounded-[calc(var(--ui-control-radius)+0.15rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_76%,white_24%)] px-4 py-7 text-center text-sm text-[var(--color-muted-foreground)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_44%,transparent)] motion-enter-fade motion-enter-rise"
]);
export const commandGroupVariants = cva([
"overflow-hidden p-1 text-[var(--color-foreground)]",
"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5",
"[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5",
"[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
"[&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[var(--tracking-caps)]",
"[&_[cmdk-group-heading]]:text-[var(--color-muted-foreground)]"
"[&_[cmdk-group-heading]]:text-[var(--color-muted-foreground)]",
"[&_[cmdk-group-items]]:grid [&_[cmdk-group-items]]:gap-1"
]);
export const commandItemVariants = cva([
"relative flex cursor-default select-none items-center gap-3 rounded-[var(--ui-control-radius)] px-3 py-2 text-sm outline-none",
"text-[var(--color-foreground)] transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[selected=true]:bg-[var(--ui-control-bg)] data-[selected=true]:text-[var(--color-foreground)]",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45"
"relative isolate flex min-w-0 cursor-default select-none items-center gap-3 overflow-hidden rounded-[calc(var(--ui-control-radius)+0.1rem)] border border-transparent px-3 py-2.5 text-sm outline-none",
"bg-transparent text-[var(--color-foreground)] shadow-[inset_0_1px_0_transparent]",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:bg-[radial-gradient(circle_at_left,color-mix(in_oklch,var(--color-primary-container)_42%,transparent),transparent_74%)] before:opacity-0 before:translate-x-[-12%] before:transition-[opacity,transform] before:duration-[var(--dur-base)] before:ease-[var(--ease-emphasized)] before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"transition-[color,background-color,border-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(--ui-panel-bg)]",
"hover:-translate-y-px hover:border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] hover:bg-[color-mix(in_oklch,var(--color-surface-container-high)_72%,white_28%)] hover:shadow-[inset_0_1px_0_color-mix(in_oklch,white_52%,transparent)] hover:before:opacity-72 hover:before:translate-x-0",
"data-[selected=true]:-translate-y-px data-[selected=true]:translate-x-[2px] data-[selected=true]:border-[color-mix(in_oklch,var(--color-primary)_20%,var(--color-border))] data-[selected=true]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_58%,white_42%),color-mix(in_oklch,var(--ui-control-bg)_76%,white_24%))] data-[selected=true]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_60%,transparent),0_12px_24px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] data-[selected=true]:before:opacity-100 data-[selected=true]:before:translate-x-0",
"[&_[data-slot=shortcut]]:transition-colors [&_[data-slot=shortcut]]:duration-[var(--dur-fast)] [&_[data-slot=shortcut]]:ease-[var(--ease-standard)]",
"[&[data-selected=true]_[data-slot=shortcut]]:text-[color-mix(in_oklch,var(--color-primary)_72%,var(--color-foreground))]",
"motion-reduce:hover:-translate-y-0 motion-reduce:data-[selected=true]:-translate-y-0 motion-reduce:data-[selected=true]:translate-x-0",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45",
getMotionRecipeClassNames("ring")
]);
export const commandSeparatorVariants = cva([
"-mx-1 my-1 h-px bg-[var(--color-border)]"
"-mx-0.5 my-1.5 h-px bg-[color-mix(in_oklch,var(--color-border)_82%,transparent)]"
]);
export const commandShortcutVariants = cva([
+22 -5
View File
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
import { DataTable, type DataTableColumn } from "./data-table";
import { DataTable, DataTableSearch, type DataTableColumn } from "./data-table";
type ReleaseRow = {
id: string;
@@ -103,11 +103,13 @@ describe("DataTable", () => {
/>
);
const search = screen.getByRole("searchbox", { name: "Search rows" });
const searchControl = search.closest('[data-slot="control"]');
expect(screen.getByRole("table").closest('[data-slot="root"]')).toBeInTheDocument();
expect(screen.getByRole("searchbox", { name: "Search rows" })).toHaveAttribute(
"data-slot",
"input"
);
expect(search).toHaveAttribute("data-slot", "input");
expect(searchControl).toBeInTheDocument();
expect(searchControl?.querySelector('[data-slot="prefix"]')).toBeTruthy();
expect(
screen.getByRole("button", { name: "Create lane" }).closest('[data-slot="actions"]')
).toBeInTheDocument();
@@ -207,6 +209,21 @@ describe("DataTable", () => {
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
});
it("keeps search input and wrapper className semantics distinct", () => {
render(
<DataTableSearch
aria-label="Search rows"
className="search-input-override"
wrapperClassName="search-wrapper-override"
/>
);
const search = screen.getByRole("searchbox", { name: "Search rows" });
expect(search).toHaveClass("search-input-override");
expect(search.closest('[data-slot="control"]')).toHaveClass("search-wrapper-override");
});
it("renders loading status without dropping the table chrome", () => {
render(<DataTable columns={columns} loading rows={rows} />);
+74 -23
View File
@@ -44,6 +44,7 @@ import {
EmptyStateTitle
} from "./empty-state";
import { Input, type InputProps } from "./input";
import { InputGroup, InputGroupPrefix } from "./input-group";
import {
Sheet,
SheetContent,
@@ -585,7 +586,7 @@ function DataTableInner<TData>(
<DataTableToolbar>
<div className="flex flex-1 flex-wrap items-center gap-3">
{shouldRenderSearch ? (
<div className={dataTableSearchContainerVariants()}>
<div className={cn(dataTableSearchContainerVariants())}>
<DataTableSearch
aria-label={searchLabel}
onChange={(event) => {
@@ -650,12 +651,20 @@ function DataTableInner<TData>(
{selectionEnabled && selectedRows.length > 0 ? (
<DataTableSelectionBar>
<div className="text-sm font-medium text-[var(--color-foreground)]">
{typeof selectionLabel === "function"
? selectionLabel(selectedRows)
: selectionLabel ?? `${selectedRows.length} selected`}
<div className="grid gap-1">
<p className="text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[color-mix(in_oklch,var(--color-primary)_68%,var(--color-foreground))]">
Selection ready
</p>
<div className="text-sm font-medium text-[var(--color-foreground)]">
{typeof selectionLabel === "function"
? selectionLabel(selectedRows)
: selectionLabel ?? `${selectedRows.length} selected`}
</div>
</div>
<div {...createSlot("actions")} className="flex flex-wrap items-center gap-2">
<div
{...createSlot("actions")}
className="flex flex-wrap items-center gap-2"
>
{selectionActions?.(selectedRows)}
<Button
size="sm"
@@ -701,19 +710,32 @@ function DataTableInner<TData>(
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<button
className={[
"inline-flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5",
"outline-none transition-colors duration-200 hover:bg-[var(--color-surface)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
align === "end" ? "justify-end" : align === "center" ? "justify-center" : "justify-start"
].join(" ")}
className={cn(
"group inline-flex w-full items-center gap-2 rounded-[calc(var(--ui-control-radius)-0.15rem)] px-2.5 py-2",
"outline-none transition-[background-color,box-shadow,color,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-[color-mix(in_oklch,var(--ui-card-default-bg)_82%,white_18%)]",
sortState
? "-translate-y-px bg-[color-mix(in_oklch,var(--color-surface-bright)_52%,var(--color-primary-container))] text-[var(--color-foreground)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_42%,transparent)]"
: "hover:-translate-y-px hover:bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_74%,white_26%)] hover:text-[var(--color-foreground)]",
align === "end"
? "justify-end"
: align === "center"
? "justify-center"
: "justify-start"
)}
onClick={header.column.getToggleSortingHandler()}
type="button"
>
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
<span
aria-hidden="true"
className="inline-flex items-center justify-center text-[var(--color-muted-foreground)]"
className={cn(
"inline-flex items-center justify-center transition-[color,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
sortState
? "translate-x-px text-[var(--color-primary)]"
: "text-[var(--color-muted-foreground)] group-hover:translate-x-px group-hover:text-[var(--color-foreground)]"
)}
>
{sortState === "asc" ? (
<SortAscendingIcon className="size-3.5" />
@@ -795,13 +817,18 @@ function DataTableInner<TData>(
</div>
<DataTablePagination>
<div className="text-sm text-[var(--color-muted-foreground)]">
{pageStart}-{pageEnd} of {filteredRowCount}
<div className="grid gap-0.5">
<span className="text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Visible rows
</span>
<span className="text-sm font-medium text-[var(--color-foreground)]">
{pageStart}-{pageEnd} of {filteredRowCount}
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
{resolvedPageSizeOptions.length > 1 ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_76%,white_24%)] px-2.5 py-1.5 shadow-[var(--ui-control-shadow)]">
<span className="text-sm text-[var(--color-muted-foreground)]">Rows</span>
<Select
value={String(currentPageSize)}
@@ -810,7 +837,7 @@ function DataTableInner<TData>(
setCurrentPageIndex(0);
}}
>
<SelectTrigger aria-label="Rows per page" className="w-[5.5rem]">
<SelectTrigger aria-label="Rows per page" className="h-9 w-[5.5rem] border-0 bg-transparent px-2 shadow-none">
<SelectValue placeholder={String(currentPageSize)} />
</SelectTrigger>
<SelectContent>
@@ -824,11 +851,11 @@ function DataTableInner<TData>(
</div>
) : null}
<div className="text-sm text-[var(--color-muted-foreground)]">
<div className="rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_72%,white_28%)] px-3 py-1.5 text-sm font-medium text-[var(--color-foreground)] shadow-[var(--ui-control-shadow)]">
Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_76%,white_24%)] p-1 shadow-[var(--ui-control-shadow)]">
<Button
disabled={!table.getCanPreviousPage()}
size="sm"
@@ -884,6 +911,21 @@ type DataTableComponent = <TData>(
export const DataTable = forwardRef(DataTableInner) as DataTableComponent;
function DataTableSearchIcon() {
return (
<svg aria-hidden="true" className="size-4" viewBox="0 0 16 16">
<path
d="M7.25 12.5a5.25 5.25 0 1 1 0-10.5a5.25 5.25 0 0 1 0 10.5Zm3.75-1.5 3 3"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
}
export type DataTableToolbarProps = ComponentPropsWithoutRef<"div">;
export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps>(
@@ -907,18 +949,27 @@ export const DataTableFilters = forwardRef<HTMLDivElement, DataTableFiltersProps
<div
{...props}
{...createSlot("actions")}
className={cn("flex flex-wrap items-center gap-2", className)}
className={cn("flex flex-wrap items-center justify-end gap-2", className)}
ref={ref}
/>
);
}
);
export type DataTableSearchProps = Omit<InputProps, "size">;
export type DataTableSearchProps = Omit<InputProps, "size"> & {
wrapperClassName?: string;
};
export const DataTableSearch = forwardRef<HTMLInputElement, DataTableSearchProps>(
function DataTableSearch({ className, type = "search", ...props }, ref) {
return <Input {...props} className={className} ref={ref} type={type} />;
function DataTableSearch({ className, type = "search", wrapperClassName, ...props }, ref) {
return (
<InputGroup className={cn(wrapperClassName)}>
<InputGroupPrefix>
<DataTableSearchIcon />
</InputGroupPrefix>
<Input {...props} className={className} ref={ref} type={type} />
</InputGroup>
);
}
);
@@ -4,19 +4,30 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const dataTableRootVariants = cva("grid gap-4 text-[var(--color-foreground)]");
export const dataTableToolbarVariants = cva(
"flex flex-wrap items-center justify-between gap-3"
[
"flex flex-wrap items-center justify-between gap-3 rounded-[calc(var(--ui-card-radius)-0.25rem)]",
"border border-[color-mix(in_oklch,var(--ui-card-subtle-border)_84%,transparent)] [border-width:var(--ui-card-border-width)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-subtle-bg)_82%,white_18%),color-mix(in_oklch,var(--ui-card-default-bg)_88%,white_12%))]",
"px-3 py-3 shadow-[var(--ui-card-subtle-shadow)] sm:px-4",
"transition-[border-color,background-color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"focus-within:border-[color-mix(in_oklch,var(--color-primary)_14%,var(--ui-card-subtle-border))]",
"focus-within:shadow-[0_16px_32px_color-mix(in_oklch,var(--color-primary)_8%,transparent),inset_0_1px_0_rgba(255,255,255,0.42)]"
]
);
export const dataTableContentVariants = cva(
[
"overflow-hidden rounded-[var(--ui-card-radius)] border border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] shadow-[var(--ui-card-default-shadow)] [border-width:var(--ui-card-border-width)]",
"relative overflow-hidden rounded-[var(--ui-card-radius)] border border-[var(--ui-card-default-border)] [border-width:var(--ui-card-border-width)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-default-bg)_74%,white_26%),color-mix(in_oklch,var(--ui-card-default-bg)_92%,var(--ui-card-subtle-bg)))]",
"shadow-[var(--ui-card-default-shadow)]",
"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-16 before:bg-[linear-gradient(180deg,color-mix(in_oklch,white_54%,transparent),transparent)] before:content-['']",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
loading: {
false: "",
true: "opacity-90"
true: "opacity-95 saturate-[0.96]"
}
},
defaultVariants: {
@@ -28,12 +39,15 @@ export const dataTableContentVariants = cva(
export const dataTableTableVariants = cva("min-w-full border-collapse align-middle");
export const dataTableHeaderVariants = cva(
"bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_74%,var(--ui-card-default-bg))]"
[
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-subtle-bg)_86%,white_14%),color-mix(in_oklch,var(--ui-card-default-bg)_82%,var(--ui-card-subtle-bg)))]",
"shadow-[inset_0_-1px_0_color-mix(in_oklch,var(--color-border)_74%,transparent)]"
]
);
export const dataTableHeaderCellVariants = cva(
[
"px-4 text-sm font-medium uppercase tracking-[var(--tracking-caps)]",
"relative px-4 first:pl-5 last:pr-5 align-middle text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)]",
"text-[var(--color-muted-foreground)]"
],
{
@@ -44,8 +58,8 @@ export const dataTableHeaderCellVariants = cva(
end: "text-right"
},
density: {
comfortable: "py-3",
compact: "py-2.5 text-xs"
comfortable: "py-4",
compact: "py-3 text-[0.68rem]"
},
sortable: {
false: "",
@@ -64,18 +78,33 @@ export const dataTableBodyVariants = cva("");
export const dataTableRowVariants = cva(
[
"group/row",
"border-t border-[color-mix(in_oklch,var(--ui-card-default-border)_88%,transparent)]",
"transition-colors duration-200"
"transition-[background-color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"[&>td]:transition-[background-color,box-shadow] [&>td]:duration-[var(--dur-base)] [&>td]:ease-[var(--ease-standard)]",
"motion-reduce:[&>td]:transition-none"
],
{
variants: {
interactive: {
false: "",
true: "hover:bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_72%,var(--ui-card-default-bg))]"
true:
[
"hover:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-subtle-bg)_78%,white_22%),color-mix(in_oklch,var(--ui-card-default-bg)_88%,var(--ui-card-subtle-bg)))]",
"hover:shadow-[inset_0_1px_0_color-mix(in_oklch,white_34%,transparent),0_10px_20px_color-mix(in_oklch,var(--color-primary)_5%,transparent)]",
"hover:[&>td]:bg-[color-mix(in_oklch,var(--color-surface-bright)_62%,transparent)]",
"hover:[&>td]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_36%,transparent)]"
]
},
selected: {
false: "",
true: "bg-[color-mix(in_oklch,var(--color-primary)_8%,var(--ui-card-default-bg))]"
true:
[
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_64%,white_36%),color-mix(in_oklch,var(--color-primary)_8%,var(--ui-card-default-bg)))]",
"shadow-[inset_0_1px_0_color-mix(in_oklch,white_36%,transparent),0_14px_24px_color-mix(in_oklch,var(--color-primary)_7%,transparent)]",
"[&>td]:bg-[color-mix(in_oklch,var(--color-primary-container)_26%,transparent)]",
"[&>td]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_40%,transparent)]"
]
}
},
defaultVariants: {
@@ -86,7 +115,7 @@ export const dataTableRowVariants = cva(
);
export const dataTableCellVariants = cva(
"px-4 text-[var(--color-card-foreground)]",
"px-4 first:pl-5 last:pr-5 align-top text-[var(--color-card-foreground)]",
{
variants: {
align: {
@@ -95,8 +124,8 @@ export const dataTableCellVariants = cva(
end: "text-right"
},
density: {
comfortable: "py-3 text-sm leading-6",
compact: "py-2.5 text-[0.8125rem] leading-5"
comfortable: "py-4 text-sm leading-6",
compact: "py-3 text-[0.8125rem] leading-5"
}
},
defaultVariants: {
@@ -107,19 +136,30 @@ export const dataTableCellVariants = cva(
);
export const dataTableSearchContainerVariants = cva(
"w-full max-w-[22rem] min-w-[14rem]"
"w-full max-w-[23.5rem] min-w-[15rem]"
);
export const dataTablePaginationVariants = cva(
"flex flex-wrap items-center justify-between gap-3 px-4 py-3"
[
"flex flex-wrap items-center justify-between gap-3 border-t px-5 py-3",
"border-[color-mix(in_oklch,var(--color-border)_72%,transparent)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-default-bg)_86%,white_14%),color-mix(in_oklch,var(--ui-card-subtle-bg)_78%,white_22%))]",
"transition-[border-color,background-color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"[&>*]:transition-[background-color,border-color,box-shadow,opacity] [&>*]:duration-[var(--dur-base)] [&>*]:ease-[var(--ease-standard)]",
"motion-reduce:[&>*]:transition-none"
]
);
export const dataTableSelectionBarVariants = cva(
[
"flex flex-wrap items-center justify-between gap-3 rounded-[var(--ui-card-radius)]",
"border border-[color-mix(in_oklch,var(--color-primary)_24%,var(--ui-card-default-border))] [border-width:var(--ui-card-border-width)]",
"bg-[color-mix(in_oklch,var(--color-primary)_7%,var(--ui-card-default-bg))] px-4 py-3",
"shadow-[var(--ui-card-subtle-shadow)]"
"flex flex-wrap items-center justify-between gap-3 rounded-[calc(var(--ui-card-radius)-0.2rem)]",
"border border-[color-mix(in_oklch,var(--color-primary)_26%,var(--ui-card-default-border))] [border-width:var(--ui-card-border-width)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_70%,white_30%),color-mix(in_oklch,var(--color-primary)_6%,var(--ui-card-default-bg)))] px-4 py-3.5",
"shadow-[var(--ui-card-subtle-shadow)]",
"motion-enter-fade motion-enter-rise",
"transition-[border-color,background-color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"[&>*]:transition-opacity [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)]",
"motion-reduce:[&>*]:transition-none"
]
);
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
@@ -44,6 +44,7 @@ describe("EmptyState", () => {
it("supports className overrides on sub-slots", () => {
render(
<EmptyState data-testid="empty-state" tone="subtle">
<EmptyStateMedia className="justify-items-start">A1</EmptyStateMedia>
<EmptyStateHeader className="items-start text-left">
<EmptyStateTitle className="text-left">No saved views</EmptyStateTitle>
</EmptyStateHeader>
@@ -51,6 +52,7 @@ describe("EmptyState", () => {
);
expect(screen.getByTestId("empty-state")).toHaveAttribute("data-tone", "subtle");
expect(screen.getByText("A1").closest('[data-slot="media"]')).toHaveClass("justify-items-start");
expect(screen.getByText("No saved views")).toHaveClass("text-left");
expect(screen.getByText("No saved views").closest('[data-slot="header"]')).toHaveClass(
"items-start"
@@ -104,4 +106,59 @@ describe("EmptyState", () => {
expect(screen.getByText("Workspace")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("42")).toHaveAttribute("data-size", "hero");
});
it("adds subtle motion hooks by default and disables them in static motion mode", async () => {
const originalMotionMode = document.documentElement.dataset.motion;
try {
const { rerender } = render(
<EmptyState>
<EmptyStateMedia data-testid="default-media">A</EmptyStateMedia>
<EmptyStateHeader>
<EmptyStateTitle>No matching releases</EmptyStateTitle>
</EmptyStateHeader>
<EmptyStateActions>
<Button size="sm">Create release</Button>
<Button size="sm" variant="ghost">
Reset filters
</Button>
</EmptyStateActions>
</EmptyState>
);
expect(screen.getByTestId("default-media")).toHaveClass("motion-breathe");
expect(screen.getByRole("button", { name: "Create release" })).toHaveClass(
"[animation:aiui-slide-up-sm_var(--dur-base)_var(--ease-emphasized)_both]"
);
document.documentElement.dataset.motion = "static";
rerender(
<EmptyState>
<EmptyStateMedia data-testid="default-media">A</EmptyStateMedia>
<EmptyStateHeader>
<EmptyStateTitle>No matching releases</EmptyStateTitle>
</EmptyStateHeader>
<EmptyStateActions>
<Button size="sm">Create release</Button>
<Button size="sm" variant="ghost">
Reset filters
</Button>
</EmptyStateActions>
</EmptyState>
);
await waitFor(() => {
expect(screen.getByTestId("default-media")).not.toHaveClass("motion-breathe");
expect(screen.getByRole("button", { name: "Create release" })).not.toHaveClass(
"[animation:aiui-slide-up-sm_var(--dur-base)_var(--ease-emphasized)_both]"
);
});
} finally {
if (originalMotionMode) {
document.documentElement.dataset.motion = originalMotionMode;
} else {
delete document.documentElement.dataset.motion;
}
}
});
});
+82 -4
View File
@@ -1,4 +1,14 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { useReducedMotion } from "motion/react";
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useEffect,
useState,
type ComponentPropsWithoutRef,
type CSSProperties
} from "react";
import {
emptyStateActionsVariants,
@@ -13,6 +23,40 @@ import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
function getIsStaticMotion() {
if (typeof document === "undefined") {
return false;
}
return document.documentElement.dataset.motion === "static";
}
function useMotionDisabled() {
const prefersReducedMotion = useReducedMotion();
const [isStaticMotion, setIsStaticMotion] = useState(getIsStaticMotion);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const syncMotionMode = () => {
setIsStaticMotion(getIsStaticMotion());
};
syncMotionMode();
const observer = new MutationObserver(syncMotionMode);
observer.observe(document.documentElement, {
attributeFilter: ["data-motion"]
});
return () => observer.disconnect();
}, []);
return prefersReducedMotion || isStaticMotion;
}
export type EmptyStateProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateVariants>;
@@ -36,12 +80,20 @@ export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div"> &
export const EmptyStateMedia = forwardRef<HTMLDivElement, EmptyStateMediaProps>(
function EmptyStateMedia({ className, size, ...props }, ref) {
const disableMotion = useMotionDisabled();
const ambientMotionClassName =
disableMotion || size === "compact"
? undefined
: size === "hero"
? "motion-float-delayed will-change-transform"
: "motion-breathe will-change-transform";
return (
<div
{...props}
{...createSlot("media")}
{...createDataAttributes({ size })}
className={cn(emptyStateMediaVariants({ size }), className)}
className={cn(emptyStateMediaVariants({ size }), ambientMotionClassName, className)}
ref={ref}
/>
);
@@ -115,7 +167,31 @@ export type EmptyStateActionsProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateActionsVariants>;
export const EmptyStateActions = forwardRef<HTMLDivElement, EmptyStateActionsProps>(
function EmptyStateActions({ className, layout, ...props }, ref) {
function EmptyStateActions({ children, className, layout, ...props }, ref) {
const disableMotion = useMotionDisabled();
const animatedChildren = disableMotion
? children
: Children.map(children, (child, index) => {
if (
!isValidElement<{ className?: string; style?: CSSProperties }>(child) ||
typeof child.type === "symbol"
) {
return child;
}
return cloneElement(child, {
className: cn(
"[animation:aiui-slide-up-sm_var(--dur-base)_var(--ease-emphasized)_both]",
"will-change-transform",
child.props.className
),
style: {
...(child.props.style ?? {}),
animationDelay: `${Math.min(index * 70, 140)}ms`
}
});
});
return (
<div
{...props}
@@ -123,7 +199,9 @@ export const EmptyStateActions = forwardRef<HTMLDivElement, EmptyStateActionsPro
{...createDataAttributes({ layout })}
className={cn(emptyStateActionsVariants({ layout }), className)}
ref={ref}
/>
>
{animatedChildren}
</div>
);
}
);
@@ -3,8 +3,9 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const emptyStateVariants = cva(
[
"grid gap-6 rounded-[var(--ui-card-radius)] border shadow-[var(--ui-card-default-shadow)] [border-width:var(--ui-card-border-width)]",
"relative isolate grid gap-6 overflow-hidden rounded-[var(--ui-card-radius)] border shadow-[var(--ui-card-default-shadow)] [border-width:var(--ui-card-border-width)]",
"text-[var(--color-card-foreground)]",
"[&>[data-slot=actions]]:relative [&>[data-slot=actions]]:z-[1] [&>[data-slot=header]]:relative [&>[data-slot=header]]:z-[1] [&>[data-slot=media]]:relative [&>[data-slot=media]]:z-[1]",
getMotionRecipeClassNames("transition", "ring")
],
{
@@ -19,9 +20,19 @@ export const emptyStateVariants = cva(
layout: {
default: "p-8 sm:p-10",
compact:
"gap-4 p-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:[&>[data-slot=media]]:row-span-2 sm:[&>[data-slot=header]]:justify-items-start sm:[&>[data-slot=actions]]:col-start-2 sm:[&>[data-slot=actions]]:justify-start",
"gap-4 p-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:[&>[data-slot=media]]:row-span-2 sm:[&>[data-slot=media]]:justify-self-start sm:[&>[data-slot=header]]:justify-items-start sm:[&>[data-slot=actions]]:col-start-2 sm:[&>[data-slot=actions]]:justify-start",
split:
"p-6 sm:p-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:[&>[data-slot=media]]:col-start-2 lg:[&>[data-slot=media]]:row-span-2 lg:[&>[data-slot=media]]:justify-self-end lg:[&>[data-slot=header]]:col-start-1 lg:[&>[data-slot=actions]]:col-start-1 lg:[&>[data-slot=actions]]:justify-start"
[
"gap-5 p-6 sm:gap-6 sm:p-8",
"lg:grid-cols-[minmax(0,1.06fr)_minmax(15rem,0.84fr)] lg:gap-x-8 lg:gap-y-5 lg:items-center",
"lg:[&>[data-slot=media]]:col-start-2 lg:[&>[data-slot=media]]:row-span-2 lg:[&>[data-slot=media]]:justify-self-stretch lg:[&>[data-slot=media]]:self-stretch",
"lg:[&>[data-slot=header]]:col-start-1 lg:[&>[data-slot=header]]:max-w-[36rem] lg:[&>[data-slot=header]]:gap-3",
"lg:[&>[data-slot=actions]]:col-start-1 lg:[&>[data-slot=actions]]:justify-start lg:[&>[data-slot=actions]]:pt-1",
"lg:[&>[data-slot=header]_[data-slot=label]]:text-[clamp(2rem,3vw,3rem)] lg:[&>[data-slot=header]_[data-slot=label]]:leading-[1.08]",
"lg:[&>[data-slot=header]_[data-slot=description]]:max-w-[32rem] lg:[&>[data-slot=header]_[data-slot=description]]:text-[0.96rem] lg:[&>[data-slot=header]_[data-slot=description]]:leading-7",
"before:pointer-events-none before:absolute before:right-[-4rem] before:top-[-5rem] before:size-[16rem] before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_58%,white_42%)_0%,transparent_72%)] before:opacity-75 before:content-['']",
"after:pointer-events-none after:absolute after:bottom-[-7rem] after:right-[18%] after:size-[14rem] after:rounded-full after:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-tertiary-container)_54%,white_46%)_0%,transparent_74%)] after:opacity-65 after:content-['']"
]
},
align: {
center: "justify-items-center text-center",
@@ -38,16 +49,19 @@ export const emptyStateVariants = cva(
export const emptyStateMediaVariants = cva(
[
"grid place-items-center rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]",
"relative isolate grid place-items-center overflow-hidden rounded-[calc(var(--ui-card-radius)-0.125rem)] border [border-width:var(--ui-card-border-width)]",
"border-[var(--ui-card-subtle-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-background)_72%,white_28%),var(--ui-card-subtle-bg))]",
"text-[var(--color-foreground)] shadow-[var(--ui-card-subtle-shadow)]"
"text-[var(--color-foreground)] shadow-[var(--ui-card-subtle-shadow)]",
"before:pointer-events-none before:absolute before:inset-x-[14%] before:top-0 before:h-[52%] before:rounded-full before:bg-[linear-gradient(180deg,color-mix(in_oklch,white_70%,transparent),transparent)] before:opacity-90 before:blur-2xl before:content-['']",
"after:pointer-events-none after:absolute after:inset-3 after:rounded-[calc(var(--ui-card-radius)-0.875rem)] after:border after:border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] after:opacity-55 after:content-['']",
"[&>*]:relative [&>*]:z-[1]"
],
{
variants: {
size: {
compact: "min-h-16 min-w-16 p-3",
default: "min-h-20 min-w-20 p-4",
hero: "min-h-28 min-w-28 p-6"
hero: "min-h-32 min-w-[10.5rem] p-5 sm:min-h-36 sm:min-w-[12rem] sm:p-6"
}
},
defaultVariants: {
@@ -56,7 +70,7 @@ export const emptyStateMediaVariants = cva(
}
);
export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2", {
export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2.5", {
variants: {
align: {
center: "justify-items-center text-center",
@@ -69,15 +83,15 @@ export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2", {
});
export const emptyStateEyebrowVariants = cva(
"text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
"inline-flex w-fit items-center rounded-full border border-[color-mix(in_oklch,var(--color-border)_68%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_70%,white_30%)] px-3 py-1 text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)] shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]"
);
export const emptyStateTitleVariants = cva(
"text-2xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
"text-[clamp(1.7rem,3vw,2.4rem)] font-semibold leading-[1.12] tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
);
export const emptyStateDescriptionVariants = cva(
"max-w-[30rem] text-sm leading-6 text-[var(--color-muted-foreground)]"
"max-w-[30rem] text-[0.95rem] leading-7 text-[var(--color-muted-foreground)]"
);
export const emptyStateActionsVariants = cva("flex flex-wrap items-center gap-3", {
+13 -6
View File
@@ -3,6 +3,8 @@ import {
type ComponentPropsWithoutRef
} from "react";
import { useInputGroupContext } from "./input-group";
import { inputGroupInputVariants } from "./input-group.variants";
import { inputVariants } from "./input.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
@@ -33,10 +35,12 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
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 group = useInputGroupContext();
const resolvedDisabled = disabled ?? group?.disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? group?.invalid ?? field?.invalid ?? false;
const resolvedReadOnly = readOnly ?? group?.readOnly ?? field?.readOnly ?? false;
const resolvedRequired = required ?? group?.required ?? field?.required ?? false;
const resolvedSize = size ?? group?.size ?? "md";
return (
<input
@@ -47,7 +51,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size
size: resolvedSize
})}
aria-describedby={mergeIds(
props["aria-describedby"],
@@ -55,7 +59,10 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
resolvedInvalid ? field?.errorId : undefined
)}
aria-invalid={resolvedInvalid || undefined}
className={cn(inputVariants({ size }), className)}
className={cn(
group ? inputGroupInputVariants({ size: resolvedSize }) : inputVariants({ size: resolvedSize }),
className
)}
disabled={resolvedDisabled}
id={id ?? field?.inputId}
readOnly={resolvedReadOnly}
+101 -4
View File
@@ -2,20 +2,35 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Progress } from "./progress";
import { setReducedMotionPreference } from "../test/a11y";
describe("Progress", () => {
it("renders root and indicator slots for a determinate value", () => {
render(<Progress aria-label="Upload progress" size="lg" value={64} variant="success" />);
render(
<Progress
aria-label="Upload progress"
pattern="linear"
size="lg"
tone="subtle"
value={64}
variant="success"
/>
);
const progressbar = screen.getByRole("progressbar", { name: "Upload progress" });
const indicator = progressbar.querySelector('[data-slot="indicator"]');
expect(progressbar).toHaveAttribute("data-slot", "root");
expect(progressbar).toHaveAttribute("data-pattern", "linear");
expect(progressbar).toHaveAttribute("data-size", "lg");
expect(progressbar).toHaveAttribute("data-state", "loading");
expect(progressbar).toHaveAttribute("data-tone", "subtle");
expect(progressbar).toHaveAttribute("data-variant", "success");
expect(progressbar).toHaveAttribute("aria-valuenow", "64");
expect(indicator).toHaveAttribute("data-pattern", "linear");
expect(indicator).toHaveAttribute("data-state", "loading");
expect(indicator).toHaveAttribute("data-variant", "success");
expect(indicator).toHaveStyle({ width: "64%" });
expect(indicator).toHaveStyle({ "--ui-progress-indicator-scale": "0.64" });
});
it("supports indeterminate and complete states", () => {
@@ -26,7 +41,8 @@ describe("Progress", () => {
expect(progressbar).toHaveAttribute("data-state", "indeterminate");
expect(progressbar).not.toHaveAttribute("aria-valuenow");
expect(indicator).toHaveStyle({ width: "38%" });
expect(indicator).toHaveAttribute("data-state", "indeterminate");
expect(indicator).toHaveStyle({ "--ui-progress-indicator-scale": "0.38" });
rerender(<Progress aria-label="Sync status" max={120} value={120} variant="default" />);
@@ -35,6 +51,87 @@ describe("Progress", () => {
expect(progressbar).toHaveAttribute("data-state", "complete");
expect(progressbar).toHaveAttribute("aria-valuenow", "120");
expect(indicator).toHaveStyle({ width: "100%" });
expect(indicator).toHaveAttribute("data-state", "complete");
expect(indicator).toHaveStyle({ "--ui-progress-indicator-scale": "1" });
});
it("supports segmented dashboard-style progress", () => {
render(
<Progress
aria-label="Operational cost reduction"
pattern="segmented"
segmentCount={20}
value={42}
variant="default"
/>
);
const progressbar = screen.getByRole("progressbar", { name: "Operational cost reduction" });
const segments = progressbar.querySelector('[data-slot="segments"]');
const segmentItems = progressbar.querySelectorAll('[data-slot="segment"]');
const activeSegments = progressbar.querySelectorAll('[data-slot="segment"][data-active]');
const indicator = progressbar.querySelector('[data-slot="indicator"]');
expect(progressbar).toHaveAttribute("data-pattern", "segmented");
expect(progressbar).toHaveAttribute("data-segments", "20");
expect(segments).toHaveAttribute("data-slot", "segments");
expect(segments).toHaveAttribute("data-pattern", "segmented");
expect(segmentItems).toHaveLength(20);
expect(activeSegments).toHaveLength(8);
expect(indicator).toBeNull();
});
it("animates segmented indeterminate progress through the segment slots", () => {
render(
<Progress
aria-label="Background sync"
pattern="segmented"
segmentCount={12}
value={null}
variant="success"
/>
);
const progressbar = screen.getByRole("progressbar", { name: "Background sync" });
const segmentItems = progressbar.querySelectorAll('[data-slot="segment"]');
expect(progressbar).toHaveAttribute("data-state", "indeterminate");
expect(segmentItems).toHaveLength(12);
expect(segmentItems[0]).toHaveAttribute("data-state", "indeterminate");
expect(segmentItems[0]).toHaveAttribute("data-variant", "success");
expect(segmentItems[0]).toHaveAttribute("data-active");
expect(segmentItems[0]).toHaveStyle({ animationDelay: "0ms" });
expect(segmentItems[11]).toHaveStyle({ animationDelay: "528ms" });
});
it("collapses continuous motion when reduced motion is preferred", () => {
setReducedMotionPreference(true);
const { rerender } = render(<Progress aria-label="Reduced sync status" value={null} />);
let progressbar = screen.getByRole("progressbar", { name: "Reduced sync status" });
let indicator = progressbar.querySelector('[data-slot="indicator"]');
expect(progressbar).toHaveAttribute("data-reduced-motion", "");
expect(indicator).toHaveAttribute("data-reduced-motion", "");
expect(indicator).toHaveStyle({ "--ui-progress-indicator-scale": "0.38" });
rerender(
<Progress
aria-label="Reduced segmented sync"
pattern="segmented"
segmentCount={12}
value={null}
/>
);
progressbar = screen.getByRole("progressbar", { name: "Reduced segmented sync" });
const activeSegments = progressbar.querySelectorAll('[data-slot="segment"][data-active]');
const segmentItems = progressbar.querySelectorAll('[data-slot="segment"]');
expect(activeSegments).toHaveLength(5);
expect(segmentItems[0]).toHaveAttribute("data-reduced-motion", "");
expect(segmentItems[0]).not.toHaveAttribute("style");
});
});
+180 -19
View File
@@ -1,14 +1,28 @@
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import {
forwardRef,
useEffect,
useState,
type ComponentPropsWithoutRef,
type CSSProperties,
type ElementRef
} from "react";
import {
progressIndicatorVariants,
progressSegmentVariants,
progressSegmentsVariants,
progressVariants
} from "./progress.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
const DEFAULT_SEGMENT_COUNT = 24;
const MIN_SEGMENT_COUNT = 6;
const MAX_SEGMENT_COUNT = 36;
const DEFAULT_INDETERMINATE_RATIO = 0.38;
function clampValue(value: number, max: number) {
return Math.min(Math.max(value, 0), max);
}
@@ -21,17 +35,57 @@ function getState(value: number | null | undefined, max: number) {
return clampValue(value, max) >= max ? "complete" : "loading";
}
function getIndicatorWidth(value: number | null | undefined, max: number) {
if (value == null) {
return "38%";
function getSegmentCount(segmentCount: number | undefined) {
if (!Number.isFinite(segmentCount)) {
return DEFAULT_SEGMENT_COUNT;
}
return `${(clampValue(value, max) / max) * 100}%`;
return Math.min(Math.max(Math.round(segmentCount as number), MIN_SEGMENT_COUNT), MAX_SEGMENT_COUNT);
}
function getFilledSegmentCount(
value: number | null | undefined,
max: number,
segmentCount: number,
disableMotion = false
) {
if (value == null) {
return disableMotion
? Math.max(1, Math.round(segmentCount * DEFAULT_INDETERMINATE_RATIO))
: segmentCount;
}
const ratio = clampValue(value, max) / max;
if (ratio <= 0) {
return 0;
}
return Math.max(1, Math.min(segmentCount, Math.round(ratio * segmentCount)));
}
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 ProgressProps = ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> &
VariantProps<typeof progressVariants> &
VariantProps<typeof progressIndicatorVariants>;
VariantProps<typeof progressIndicatorVariants> & {
segmentCount?: number;
};
export const Progress = forwardRef<
ElementRef<typeof ProgressPrimitive.Root>,
@@ -40,6 +94,8 @@ export const Progress = forwardRef<
{
className,
max = 100,
pattern = "linear",
segmentCount,
size,
tone,
value,
@@ -48,36 +104,141 @@ export const Progress = forwardRef<
},
ref
) {
const [disableMotion, setDisableMotion] = useState(motionIsDisabled);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const syncMotionState = () => {
setDisableMotion(motionIsDisabled());
};
syncMotionState();
const observer = new MutationObserver(syncMotionState);
observer.observe(document.documentElement, {
attributeFilter: ["data-motion"]
});
const mediaQuery =
typeof window !== "undefined" && typeof window.matchMedia === "function"
? (window.matchMedia("(prefers-reduced-motion: reduce)") as LegacyMediaQueryList)
: null;
const handleMotionPreferenceChange = () => {
syncMotionState();
};
mediaQuery?.addEventListener?.("change", handleMotionPreferenceChange);
mediaQuery?.addListener?.(handleMotionPreferenceChange);
return () => {
observer.disconnect();
mediaQuery?.removeEventListener?.("change", handleMotionPreferenceChange);
mediaQuery?.removeListener?.(handleMotionPreferenceChange);
};
}, []);
const resolvedMax = max > 0 ? max : 100;
const state = getState(value, resolvedMax);
const resolvedSegmentCount = getSegmentCount(segmentCount);
const filledSegmentCount = getFilledSegmentCount(
value,
resolvedMax,
resolvedSegmentCount,
disableMotion
);
const segments = pattern === "segmented" ? Array.from({ length: resolvedSegmentCount }) : null;
const linearIndicatorScale =
value == null
? DEFAULT_INDETERMINATE_RATIO
: clampValue(value, resolvedMax) / resolvedMax;
const indicatorStyle: CSSProperties = {
"--ui-progress-indicator-scale": String(linearIndicatorScale)
} as CSSProperties;
return (
<ProgressPrimitive.Root
{...props}
{...createSlot("root")}
{...createDataAttributes({
pattern,
segments: pattern === "segmented" ? resolvedSegmentCount : undefined,
size,
state,
"reduced-motion": disableMotion,
tone,
variant
})}
className={cn(progressVariants({ size, tone }), className)}
className={cn(progressVariants({ pattern, size, tone }), className)}
max={resolvedMax}
ref={ref}
value={value ?? undefined}
>
<ProgressPrimitive.Indicator
{...createSlot("indicator")}
{...createDataAttributes({
size,
state,
variant
})}
className={cn(progressIndicatorVariants({ variant }))}
style={{
width: getIndicatorWidth(value, resolvedMax)
}}
/>
{pattern === "segmented" ? (
<div
{...createSlot("segments")}
{...createDataAttributes({
pattern,
segments: resolvedSegmentCount,
size,
state,
"reduced-motion": disableMotion,
variant
})}
aria-hidden="true"
className={cn(progressSegmentsVariants({ size }))}
style={{
gridTemplateColumns: `repeat(${resolvedSegmentCount}, minmax(0, 1fr))`
}}
>
{segments?.map((_, index) => {
const active =
state === "indeterminate"
? disableMotion
? index < filledSegmentCount
: true
: index < filledSegmentCount;
return (
<span
key={index}
{...createSlot("segment")}
{...createDataAttributes({
active,
pattern,
size,
state,
"reduced-motion": disableMotion,
variant
})}
className={cn(progressSegmentVariants({ variant }))}
style={
state === "indeterminate" && !disableMotion
? {
animationDelay: `${index * 48}ms`
}
: undefined
}
/>
);
})}
</div>
) : (
<ProgressPrimitive.Indicator
{...createSlot("indicator")}
{...createDataAttributes({
pattern,
size,
state,
"reduced-motion": disableMotion,
variant
})}
className={cn(progressIndicatorVariants({ variant }))}
style={indicatorStyle}
/>
)}
</ProgressPrimitive.Root>
);
});
+72 -12
View File
@@ -3,22 +3,31 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const progressVariants = cva(
[
"relative w-full overflow-hidden rounded-full border",
"border-[color-mix(in_oklch,var(--color-border)_92%,transparent)] bg-[var(--color-surface)]"
"relative isolate w-full overflow-hidden rounded-[var(--ui-progress-radius)] border outline-none",
"[border-width:var(--ui-progress-track-border-width)] border-[var(--ui-progress-track-border)] [background:var(--ui-progress-track-bg)] shadow-[var(--ui-progress-track-shadow)]",
"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-[58%] before:[background:var(--ui-progress-track-highlight)] before:opacity-90 before:content-['']",
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-[72%] after:[background:var(--ui-progress-track-depth)] after:opacity-80 after:content-['']"
],
{
variants: {
size: {
sm: "h-2",
md: "h-3",
lg: "h-4"
sm: "h-2.5 [--ui-progress-segment-gap:0.1875rem] [--ui-progress-segment-inset:1px]",
md: "h-3.5 [--ui-progress-segment-gap:0.25rem] [--ui-progress-segment-inset:1.5px]",
lg: "h-5 [--ui-progress-segment-gap:0.3125rem] [--ui-progress-segment-inset:2px]"
},
pattern: {
linear: "",
segmented:
"border-[var(--ui-progress-segment-surface-border)] [background:var(--ui-progress-segment-surface-bg)] p-[var(--ui-progress-segment-inset)] shadow-[var(--ui-progress-segment-surface-shadow)] before:hidden after:hidden"
},
tone: {
default: "bg-[var(--color-surface)]",
subtle: "bg-[var(--color-muted)]"
default: "",
subtle:
"[--ui-progress-track-bg:var(--ui-progress-track-subtle-bg)] [--ui-progress-track-shadow:var(--ui-progress-track-subtle-shadow)] [--ui-progress-track-highlight:var(--ui-progress-track-subtle-highlight)] [--ui-progress-segment-surface-bg:var(--ui-progress-segment-subtle-surface-bg)] [--ui-progress-segment-inactive-bg:var(--ui-progress-segment-subtle-inactive-bg)]"
}
},
defaultVariants: {
pattern: "linear",
size: "md",
tone: "default"
}
@@ -27,16 +36,67 @@ export const progressVariants = cva(
export const progressIndicatorVariants = cva(
[
"h-full rounded-full transition-[width,background-color] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]",
"relative z-[1] h-full w-full origin-left overflow-hidden rounded-[var(--ui-progress-radius)] [background:var(--ui-progress-indicator-bg)] shadow-[var(--ui-progress-indicator-shadow)] [transform:scaleX(var(--ui-progress-indicator-scale,0))]",
"transition-[transform,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]",
"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-[58%] before:[background:var(--ui-progress-indicator-gloss)] before:opacity-95 before:content-['']",
"after:pointer-events-none after:absolute after:inset-y-[-18%] after:right-[-16%] after:w-[42%] after:[background:var(--ui-progress-indicator-glimmer)] after:opacity-80 after:content-['']",
"[&[data-state=indeterminate]::after]:animate-[aiui-skeleton-shimmer_1.6s_var(--ease-emphasized)_infinite]",
"[&[data-reduced-motion]::after]:animate-none",
"data-[state=complete]:shadow-[var(--ui-progress-indicator-complete-shadow)]",
"data-[reduced-motion]:transition-none",
getMotionRecipeClassNames("transition")
],
{
variants: {
variant: {
default: "bg-[var(--color-primary)]",
success: "bg-[var(--color-success)]",
warning: "bg-[var(--color-warning)]",
destructive: "bg-[var(--color-destructive)]"
default:
"[--ui-progress-indicator-bg:var(--ui-progress-indicator-default-bg)] [--ui-progress-indicator-shadow:var(--ui-progress-indicator-default-shadow)]",
success:
"[--ui-progress-indicator-bg:var(--ui-progress-indicator-success-bg)] [--ui-progress-indicator-shadow:var(--ui-progress-indicator-success-shadow)]",
warning:
"[--ui-progress-indicator-bg:var(--ui-progress-indicator-warning-bg)] [--ui-progress-indicator-shadow:var(--ui-progress-indicator-warning-shadow)]",
destructive:
"[--ui-progress-indicator-bg:var(--ui-progress-indicator-destructive-bg)] [--ui-progress-indicator-shadow:var(--ui-progress-indicator-destructive-shadow)]"
}
},
defaultVariants: {
variant: "default"
}
}
);
export const progressSegmentsVariants = cva("relative z-[1] grid h-full w-full items-stretch gap-[var(--ui-progress-segment-gap)]", {
variants: {
size: {
sm: "",
md: "",
lg: ""
}
},
defaultVariants: {
size: "md"
}
});
export const progressSegmentVariants = cva(
[
"min-w-0 rounded-[var(--ui-progress-segment-radius)] [background:var(--ui-progress-segment-inactive-bg)] shadow-[var(--ui-progress-segment-inactive-shadow)]",
"transition-[background-color,box-shadow,opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]",
"data-[active]:[background:var(--ui-progress-segment-active-bg,var(--ui-progress-indicator-bg))] data-[active]:shadow-[var(--ui-progress-segment-active-shadow,var(--ui-progress-indicator-shadow))]",
"data-[state=indeterminate]:[background:var(--ui-progress-segment-active-bg,var(--ui-progress-indicator-bg))] data-[state=indeterminate]:shadow-[var(--ui-progress-segment-active-shadow,var(--ui-progress-indicator-shadow))] data-[state=indeterminate]:animate-[aiui-breathe_1.1s_var(--ease-standard)_infinite]",
"data-[reduced-motion]:animate-none data-[reduced-motion]:transition-none"
],
{
variants: {
variant: {
default:
"[--ui-progress-indicator-bg:var(--ui-progress-indicator-default-bg)] [--ui-progress-indicator-shadow:var(--ui-progress-indicator-default-shadow)]",
success:
"[--ui-progress-indicator-bg:var(--ui-progress-indicator-success-bg)] [--ui-progress-indicator-shadow:var(--ui-progress-indicator-success-shadow)]",
warning:
"[--ui-progress-indicator-bg:var(--ui-progress-indicator-warning-bg)] [--ui-progress-indicator-shadow:var(--ui-progress-indicator-warning-shadow)]",
destructive:
"[--ui-progress-indicator-bg:var(--ui-progress-indicator-destructive-bg)] [--ui-progress-indicator-shadow:var(--ui-progress-indicator-destructive-shadow)]"
}
},
defaultVariants: {
@@ -15,12 +15,14 @@ describe("RadioGroup", () => {
const group = screen.getByRole("radiogroup", { name: "Review lane" });
const design = screen.getByRole("radio", { name: "Design" });
const designIcon = design.querySelector('[data-slot="icon"]');
expect(group).toHaveAttribute("data-slot", "root");
expect(group).toHaveAttribute("data-orientation", "horizontal");
expect(design).toBeChecked();
expect(design).toHaveAttribute("data-slot", "control");
expect(design).toHaveAttribute("data-state", "checked");
expect(designIcon).toHaveAttribute("data-state", "checked");
});
it("supports value change callbacks when a new option is selected", async () => {
@@ -36,10 +38,12 @@ describe("RadioGroup", () => {
);
const medium = screen.getByRole("radio", { name: "Medium" });
const mediumIcon = medium.querySelector('[data-slot="icon"]');
await user.click(medium);
expect(medium).toBeChecked();
expect(mediumIcon).toHaveAttribute("data-state", "checked");
expect(onValueChange).toHaveBeenLastCalledWith("medium");
});
@@ -53,6 +53,7 @@ export const RadioGroupItem = forwardRef<
>
<RadioGroupPrimitive.Indicator
{...createSlot("icon")}
forceMount
className={radioGroupIndicatorVariants()}
/>
</RadioGroupPrimitive.Item>
@@ -27,5 +27,10 @@ export const radioGroupItemVariants = cva(
);
export const radioGroupIndicatorVariants = cva([
"flex size-full items-center justify-center after:block after:size-2 after:rounded-full after:bg-current after:content-['']"
"flex size-full items-center justify-center",
"will-change-transform transition-[opacity,transform] duration-[var(--dur-fast)] ease-[var(--ease-emphasized)]",
"data-[state=unchecked]:scale-[0.72] data-[state=unchecked]:opacity-0",
"data-[state=checked]:scale-100 data-[state=checked]:opacity-100",
"motion-reduce:transition-none motion-reduce:data-[state=unchecked]:scale-100",
"after:block after:size-2 after:rounded-full after:bg-current after:content-['']"
]);
@@ -53,6 +53,7 @@ describe("Select", () => {
expect(listbox).toHaveAttribute("data-slot", "content");
expect(designOption).toHaveAttribute("data-slot", "item");
expect(designOption).toHaveAttribute("data-state", "checked");
});
it("updates controlled value after selecting an option", async () => {
+5 -1
View File
@@ -124,7 +124,11 @@ export const SelectItem = forwardRef<
<CheckIcon className="size-3" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemText>
<span {...createSlot("label")} className="block truncate font-medium">
{children}
</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
+26 -6
View File
@@ -10,6 +10,10 @@ export const selectTriggerVariants = cva(
"focus-visible:-translate-y-[var(--ui-input-focus-lift)] focus-visible:border-[var(--ui-input-focus-border)] focus-visible:shadow-[var(--ui-input-focus-shadow)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[placeholder]:text-[var(--color-muted-foreground)] data-[disabled]:cursor-not-allowed data-[disabled]:bg-[var(--ui-input-disabled-bg)] data-[disabled]:text-[var(--color-muted-foreground)] data-[disabled]:opacity-100",
"data-[state=open]:border-[color-mix(in_oklch,var(--color-primary)_16%,var(--ui-input-border))] data-[state=open]:shadow-[0_14px_28px_color-mix(in_oklch,var(--color-primary)_10%,transparent),var(--ui-input-shadow)]",
"[&_[data-slot=icon]]:transition-[transform,color,opacity] [&_[data-slot=icon]]:duration-[var(--dur-fast)] [&_[data-slot=icon]]:ease-[var(--ease-standard)]",
"[&[data-state=open]_[data-slot=icon]]:translate-y-px [&[data-state=open]_[data-slot=icon]]:rotate-180 [&[data-state=open]_[data-slot=icon]]:text-[var(--color-primary)]",
"motion-reduce:[&[data-state=open]_[data-slot=icon]]:translate-y-0",
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
"aria-[invalid=true]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]",
getMotionRecipeClassNames("ring")
@@ -19,18 +23,34 @@ export const selectTriggerVariants = cva(
export const selectContentVariants = cva([
"relative z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-[var(--ui-panel-radius)] border border-[var(--ui-panel-border)] bg-[var(--ui-panel-bg)] p-1.5 text-[var(--color-card-foreground)] shadow-[var(--ui-panel-shadow)] outline-none",
"[border-width:var(--ui-panel-border-width)] backdrop-blur-[var(--ui-panel-backdrop-blur)]",
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop"
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-fade data-[state=closed]:motion-exit-drop",
"motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none"
]);
export const selectViewportVariants = cva([
"max-h-[16rem] overflow-y-auto p-0.5"
"max-h-[16rem] overflow-y-auto p-1 motion-enter-fade",
"transition-[opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"motion-reduce:animate-none"
]);
export const selectItemVariants = cva([
"relative flex w-full cursor-default select-none items-center gap-2 rounded-[var(--ui-control-radius)] px-8 py-2 text-sm text-[var(--color-foreground)] outline-none",
"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus:bg-[var(--ui-control-bg)] focus:text-[var(--color-foreground)] data-[highlighted]:bg-[var(--ui-control-bg)] data-[highlighted]:text-[var(--color-foreground)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45"
"relative isolate flex w-full cursor-default select-none items-center gap-2 overflow-hidden rounded-[calc(var(--ui-control-radius)+0.1rem)] border border-transparent px-8 py-2.5 text-sm text-[var(--color-foreground)] outline-none",
"bg-transparent shadow-[inset_0_1px_0_transparent]",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:bg-[radial-gradient(circle_at_left,color-mix(in_oklch,var(--color-primary-container)_42%,transparent),transparent_74%)] before:opacity-0 before:translate-x-[-12%] before:transition-[opacity,transform] before:duration-[var(--dur-base)] before:ease-[var(--ease-emphasized)] before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus:bg-transparent focus:text-[var(--color-foreground)]",
"data-[highlighted]:-translate-y-px data-[highlighted]:translate-x-[1px] data-[highlighted]:border-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-border))] data-[highlighted]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-high)_84%,white_16%),color-mix(in_oklch,var(--ui-control-bg)_82%,white_18%))] data-[highlighted]:text-[var(--color-foreground)] data-[highlighted]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_54%,transparent),0_10px_22px_color-mix(in_oklch,var(--color-primary)_8%,transparent)] data-[highlighted]:before:opacity-72 data-[highlighted]:before:translate-x-0",
"data-[state=checked]:translate-x-[2px] data-[state=checked]:border-[color-mix(in_oklch,var(--color-primary)_22%,var(--color-border))] data-[state=checked]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_56%,white_44%),color-mix(in_oklch,var(--ui-control-bg)_74%,white_26%))] data-[state=checked]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_60%,transparent),0_12px_24px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] data-[state=checked]:before:opacity-100 data-[state=checked]:before:translate-x-0",
"[&_[data-slot=icon]]:transition-[opacity,transform,color] [&_[data-slot=icon]]:duration-[var(--dur-fast)] [&_[data-slot=icon]]:ease-[var(--ease-standard)]",
"[&[data-highlighted]_[data-slot=icon]]:translate-x-px",
"[&[data-state=checked]_[data-slot=icon]]:scale-100 [&[data-state=checked]_[data-slot=icon]]:opacity-100 [&[data-state=unchecked]_[data-slot=icon]]:scale-75 [&[data-state=unchecked]_[data-slot=icon]]:opacity-0",
"[&_[data-slot=label]]:transition-[color,transform] [&_[data-slot=label]]:duration-[var(--dur-fast)] [&_[data-slot=label]]:ease-[var(--ease-standard)]",
"[&[data-highlighted]_[data-slot=label]]:translate-x-[1px] [&[data-state=checked]_[data-slot=label]]:translate-x-[1.5px]",
"motion-reduce:data-[highlighted]:-translate-y-0 motion-reduce:data-[highlighted]:translate-x-0 motion-reduce:data-[state=checked]:translate-x-0",
"motion-reduce:[&[data-highlighted]_[data-slot=icon]]:translate-x-0 motion-reduce:[&[data-highlighted]_[data-slot=label]]:translate-x-0 motion-reduce:[&[data-state=checked]_[data-slot=label]]:translate-x-0",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45",
getMotionRecipeClassNames("ring")
]);
export const selectLabelVariants = cva([
@@ -13,6 +13,10 @@ describe("Skeleton", () => {
expect(skeleton).toHaveAttribute("data-shape", "line");
expect(skeleton).toHaveAttribute("data-tone", "default");
expect(skeleton).toHaveAttribute("aria-hidden", "true");
expect(skeleton.className).toContain(
"before:animate-[aiui-skeleton-shimmer_1.8s_var(--ease-standard)_infinite]"
);
expect(skeleton.className).toContain("motion-reduce:before:animate-none");
});
it("supports alternate shape and tone hooks", () => {
+2 -1
View File
@@ -7,7 +7,8 @@ import { createDataAttributes, createSlot } from "../lib/contracts";
const skeletonVariants = cva(
[
"relative overflow-hidden rounded-[var(--ui-skeleton-radius)] bg-[var(--ui-skeleton-bg)]",
"before:absolute before:inset-0 before:bg-[var(--ui-skeleton-gradient)] before:opacity-70 before:content-[''] before:animate-[aiui-skeleton-shimmer_1.8s_var(--ease-standard)_infinite]"
"before:absolute before:inset-0 before:bg-[var(--ui-skeleton-gradient)] before:opacity-70 before:content-[''] before:animate-[aiui-skeleton-shimmer_1.8s_var(--ease-standard)_infinite]",
"motion-reduce:before:animate-none"
],
{
variants: {
@@ -13,6 +13,8 @@ describe("Spinner", () => {
expect(spinner).toHaveAttribute("data-size", "lg");
expect(spinner).toHaveAttribute("data-tone", "primary");
expect(spinner).toHaveAttribute("aria-hidden", "true");
expect(spinner).toHaveClass("animate-spin");
expect(spinner).toHaveClass("motion-reduce:animate-none");
});
it("keeps an accessible label when one is provided", () => {
+1 -1
View File
@@ -7,7 +7,7 @@ import { createDataAttributes, createSlot } from "../lib/contracts";
const spinnerVariants = cva(
[
"inline-block rounded-full border-current border-r-transparent align-middle",
"animate-spin"
"animate-spin motion-reduce:animate-none"
],
{
variants: {
+4
View File
@@ -21,11 +21,15 @@ describe("Tabs", () => {
expect(screen.getByText("Overview panel")).toHaveAttribute("data-slot", "content");
expect(screen.queryByText("Activity panel")).not.toBeInTheDocument();
expect(screen.getByText("Overview").closest('[data-slot="label"]')).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Overview" }).querySelector('[data-slot="indicator"]')).toBeTruthy();
await user.click(screen.getByRole("tab", { name: "Activity" }));
expect(screen.getByText("Activity panel")).toBeInTheDocument();
expect(screen.queryByText("Overview panel")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Activity" }).querySelector('[data-slot="indicator"]')).toBeTruthy();
expect(screen.getByRole("tab", { name: "Overview" }).querySelector('[data-slot="indicator"]')).toBeNull();
});
it("preserves disabled triggers and root/list slots", async () => {
+133 -18
View File
@@ -1,44 +1,145 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import {
createContext,
forwardRef,
useContext,
useEffect,
useId,
useState,
type ComponentPropsWithoutRef,
type ElementRef
} from "react";
import { tabsContentVariants, tabsListVariants, tabsTriggerVariants } from "./tabs.variants";
import {
tabsContentVariants,
tabsIndicatorVariants,
tabsLabelVariants,
tabsListVariants,
tabsTriggerVariants
} from "./tabs.variants";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
type TabsMotionContextValue = {
activeValue?: string;
disableMotion: boolean;
};
const TabsMotionContext = createContext<TabsMotionContextValue | 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 function Tabs({
children,
className,
defaultValue,
onValueChange,
orientation = "horizontal",
value,
...props
}: ComponentPropsWithoutRef<typeof TabsPrimitive.Root>) {
const disableMotion = useStaticMotion();
const [currentValue, setCurrentValue] = useControllableStringState({
controlledValue: value,
defaultValue,
onChange: onValueChange
});
return (
<TabsPrimitive.Root
{...props}
{...createSlot("root")}
{...createDataAttributes({ orientation })}
className={cn("flex flex-col", className)}
orientation={orientation}
/>
<TabsMotionContext.Provider value={{ activeValue: currentValue, disableMotion }}>
<TabsPrimitive.Root
{...props}
{...createSlot("root")}
{...createDataAttributes({ orientation })}
className={cn("flex flex-col", className)}
onValueChange={setCurrentValue}
orientation={orientation}
value={currentValue ?? undefined}
>
{children}
</TabsPrimitive.Root>
</TabsMotionContext.Provider>
);
}
export const TabsList = forwardRef<
ElementRef<typeof TabsPrimitive.List>,
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(function TabsList({ className, ...props }, ref) {
>(function TabsList({ children, className, ...props }, ref) {
const layoutGroupId = useId();
return (
<TabsPrimitive.List
{...props}
{...createSlot("list")}
className={cn(tabsListVariants(), className)}
ref={ref}
/>
<LayoutGroup id={layoutGroupId}>
<TabsPrimitive.List
{...props}
{...createSlot("list")}
className={cn(tabsListVariants(), className)}
ref={ref}
>
{children}
</TabsPrimitive.List>
</LayoutGroup>
);
});
export const TabsTrigger = forwardRef<
ElementRef<typeof TabsPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(function TabsTrigger({ className, disabled, ...props }, ref) {
>(function TabsTrigger({ children, className, disabled, value, ...props }, ref) {
const motionContext = useContext(TabsMotionContext);
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 (
<TabsPrimitive.Trigger
{...props}
@@ -47,7 +148,21 @@ export const TabsTrigger = forwardRef<
className={cn(tabsTriggerVariants(), className)}
disabled={disabled}
ref={ref}
/>
value={value}
>
{isActive && motionContext ? (
<motion.span
{...createSlot("indicator")}
aria-hidden="true"
className={tabsIndicatorVariants()}
layoutId="active-pill"
transition={transition}
/>
) : null}
<span {...createSlot("label")} className={tabsLabelVariants()}>
{children}
</span>
</TabsPrimitive.Trigger>
);
});
+15 -4
View File
@@ -6,15 +6,26 @@ export const tabsListVariants = cva([
]);
export const tabsTriggerVariants = cva([
"inline-flex min-w-[7rem] items-center justify-center rounded-[var(--ui-control-radius)] px-4 py-2.5 text-sm font-medium outline-none",
"text-[var(--color-muted-foreground)] transition-[color,background-color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"relative isolate inline-flex min-w-[7rem] items-center justify-center overflow-hidden rounded-[var(--ui-control-radius)] px-4 py-2.5 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]:opacity-45",
"hover:-translate-y-px hover:bg-[color-mix(in_oklch,var(--ui-control-bg)_76%,white_24%)] hover:text-[var(--color-foreground)]",
"data-[state=active]:-translate-y-px data-[state=active]:bg-[var(--ui-panel-bg)] data-[state=active]:text-[var(--color-foreground)] data-[state=active]:shadow-[var(--ui-control-shadow)]",
"hover:-translate-y-px hover:text-[var(--color-foreground)]",
"data-[state=active]:text-[var(--color-foreground)]",
getMotionRecipeClassNames("ring")
]);
export const tabsIndicatorVariants = 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(180deg,color-mix(in_oklch,var(--color-primary-container)_68%,white_32%),color-mix(in_oklch,var(--ui-panel-bg)_82%,white_18%))]",
"shadow-[inset_0_1px_0_color-mix(in_oklch,white_56%,transparent),0_10px_22px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]"
]);
export const tabsLabelVariants = cva(
"relative z-[1] inline-flex items-center justify-center gap-2"
);
export const tabsContentVariants = cva([
"mt-4 rounded-[var(--ui-card-radius)] border border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] p-6 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] outline-none [border-width:var(--ui-card-border-width)]",
"data-[state=active]:motion-enter-fade data-[state=active]:motion-enter-rise"
@@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { tooltipContentVariants } from "./tooltip.variants";
import {
Tooltip,
TooltipArrow,
@@ -11,6 +12,18 @@ import {
} from "./tooltip";
describe("Tooltip", () => {
it("uses a light rise/fade motion and disables animation for reduced motion", () => {
const className = tooltipContentVariants();
expect(className).toContain(
"data-[state=delayed-open]:[animation:aiui-fade-in_calc(var(--dur-fast)*1.2)_var(--ease-standard)_both,aiui-slide-up-sm_calc(var(--dur-fast)*1.2)_var(--ease-standard)_both]"
);
expect(className).toContain(
"data-[state=closed]:[animation:aiui-fade-out_var(--dur-fast)_var(--ease-exit)_both,aiui-slide-down-sm_var(--dur-fast)_var(--ease-exit)_reverse_both]"
);
expect(className).toContain("motion-reduce:data-[state=delayed-open]:animate-none");
});
it("shows and hides tooltip content around hover", async () => {
const user = userEvent.setup();
@@ -3,7 +3,10 @@ import { cva } from "../lib/cva";
export const tooltipContentVariants = cva(
[
"z-50 max-w-xs rounded-[var(--radius-sm)] bg-[var(--color-surface-contrast)] px-3 py-2 text-sm text-[var(--color-background)] shadow-[var(--shadow-sm)] outline-none",
"data-[state=delayed-open]:motion-enter-fade data-[state=instant-open]:motion-enter-fade data-[state=closed]:motion-exit-fade",
"data-[state=delayed-open]:[animation:aiui-fade-in_calc(var(--dur-fast)*1.2)_var(--ease-standard)_both,aiui-slide-up-sm_calc(var(--dur-fast)*1.2)_var(--ease-standard)_both]",
"data-[state=instant-open]:[animation:aiui-fade-in_calc(var(--dur-fast)*1.2)_var(--ease-standard)_both,aiui-slide-up-sm_calc(var(--dur-fast)*1.2)_var(--ease-standard)_both]",
"data-[state=closed]:[animation:aiui-fade-out_var(--dur-fast)_var(--ease-exit)_both,aiui-slide-down-sm_var(--dur-fast)_var(--ease-exit)_reverse_both]",
"motion-reduce:data-[state=delayed-open]:animate-none motion-reduce:data-[state=instant-open]:animate-none motion-reduce:data-[state=closed]:animate-none",
"data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left data-[side=top]:origin-bottom"
],
{
+219
View File
@@ -168,6 +168,12 @@
--ui-card-hover-scale: 1.016;
--ui-card-hover-shadow: 0 24px 44px color-mix(in oklch, var(--color-primary) 16%, transparent);
--ui-grid-gap-xs: 0.75rem;
--ui-grid-gap-sm: 1rem;
--ui-grid-gap-md: 1.25rem;
--ui-grid-gap-lg: 1.5rem;
--ui-grid-gap-xl: 2rem;
--ui-input-radius: var(--radius-sm);
--ui-input-border-width: 1px;
--ui-input-bg: linear-gradient(
@@ -232,6 +238,219 @@
var(--shadow-xs);
--ui-switch-transition-duration: var(--dur-base);
--ui-progress-radius: var(--radius-full);
--ui-progress-track-border-width: 1px;
--ui-progress-track-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-highest) 74%, var(--color-surface-bright) 26%),
color-mix(in oklch, var(--color-surface-container) 20%, var(--color-surface-container-highest) 80%)
);
--ui-progress-track-border: transparent;
--ui-progress-track-shadow:
inset 0 1px 0 color-mix(in oklch, white 38%, transparent),
0 8px 18px color-mix(in oklch, var(--color-primary) 7%, transparent);
--ui-progress-track-highlight: linear-gradient(
180deg,
color-mix(in oklch, white 72%, transparent),
transparent 78%
);
--ui-progress-track-depth: linear-gradient(
180deg,
transparent 0%,
color-mix(in oklch, var(--color-surface-contrast) 8%, transparent) 100%
);
--ui-progress-track-subtle-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container) 86%, var(--color-surface-bright) 14%),
color-mix(in oklch, var(--color-surface) 18%, var(--color-surface-container) 82%)
);
--ui-progress-track-subtle-shadow:
inset 0 1px 0 color-mix(in oklch, white 30%, transparent),
0 6px 14px color-mix(in oklch, var(--color-primary) 5%, transparent);
--ui-progress-track-subtle-highlight: linear-gradient(
180deg,
color-mix(in oklch, white 58%, transparent),
transparent 82%
);
--ui-progress-indicator-gloss: linear-gradient(
180deg,
color-mix(in oklch, white 62%, transparent),
transparent 78%
);
--ui-progress-indicator-glimmer: linear-gradient(
90deg,
transparent 0%,
color-mix(in oklch, white 54%, transparent) 45%,
transparent 100%
);
--ui-progress-indicator-default-bg: linear-gradient(
90deg,
color-mix(in oklch, var(--color-primary-container) 84%, white 16%),
color-mix(in oklch, var(--color-primary) 24%, var(--color-primary-container) 76%)
);
--ui-progress-indicator-default-shadow:
inset 0 1px 0 color-mix(in oklch, white 42%, transparent),
0 10px 22px color-mix(in oklch, var(--color-primary) 18%, transparent);
--ui-progress-indicator-success-bg: linear-gradient(
90deg,
color-mix(in oklch, var(--color-tertiary-container) 82%, white 18%),
color-mix(in oklch, var(--color-success) 30%, var(--color-tertiary-container) 70%)
);
--ui-progress-indicator-success-shadow:
inset 0 1px 0 color-mix(in oklch, white 36%, transparent),
0 10px 22px color-mix(in oklch, var(--color-success) 18%, transparent);
--ui-progress-indicator-warning-bg: linear-gradient(
90deg,
color-mix(in oklch, var(--color-warning) 22%, var(--color-surface-bright) 78%),
color-mix(in oklch, var(--color-warning) 44%, var(--color-primary-container) 56%)
);
--ui-progress-indicator-warning-shadow:
inset 0 1px 0 color-mix(in oklch, white 30%, transparent),
0 10px 22px color-mix(in oklch, var(--color-warning) 16%, transparent);
--ui-progress-indicator-destructive-bg: linear-gradient(
90deg,
color-mix(in oklch, var(--color-error-container) 86%, white 14%),
color-mix(in oklch, var(--color-error) 18%, var(--color-error-container) 82%)
);
--ui-progress-indicator-destructive-shadow:
inset 0 1px 0 color-mix(in oklch, white 34%, transparent),
0 10px 22px color-mix(in oklch, var(--color-error) 16%, transparent);
--ui-progress-indicator-complete-shadow:
inset 0 1px 0 color-mix(in oklch, white 42%, transparent),
0 14px 28px color-mix(in oklch, var(--color-primary) 16%, transparent);
--ui-progress-segment-radius: var(--radius-full);
--ui-progress-segment-surface-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-low) 88%, white 12%),
color-mix(in oklch, var(--color-surface-container) 18%, var(--color-surface-container-low) 82%)
);
--ui-progress-segment-surface-border: transparent;
--ui-progress-segment-surface-shadow:
inset 0 1px 0 color-mix(in oklch, white 44%, transparent),
0 10px 20px color-mix(in oklch, var(--color-primary) 7%, transparent);
--ui-progress-segment-subtle-surface-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface) 72%, white 28%),
color-mix(in oklch, var(--color-surface-container) 18%, var(--color-surface) 82%)
);
--ui-progress-segment-inactive-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-highest) 86%, white 14%),
color-mix(in oklch, var(--color-surface-container) 16%, var(--color-surface-container-highest) 84%)
);
--ui-progress-segment-subtle-inactive-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container) 78%, white 22%),
color-mix(in oklch, var(--color-surface) 14%, var(--color-surface-container) 86%)
);
--ui-progress-segment-inactive-shadow: inset 0 1px 0 color-mix(in oklch, white 32%, transparent);
--ui-gauge-track-stroke: color-mix(in oklch, var(--color-border) 88%, white 12%);
--ui-gauge-tick-stroke: color-mix(in oklch, var(--color-border-strong) 34%, transparent);
--ui-gauge-center-default-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-low) 82%, white 18%),
color-mix(in oklch, var(--color-surface-container) 16%, var(--color-surface-container-low) 84%)
);
--ui-gauge-center-default-shadow:
inset 0 1px 0 color-mix(in oklch, white 48%, transparent),
0 12px 24px color-mix(in oklch, var(--color-primary) 10%, transparent);
--ui-gauge-center-subtle-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface) 78%, white 22%),
color-mix(in oklch, var(--color-surface-container) 14%, var(--color-surface) 86%)
);
--ui-gauge-center-subtle-shadow:
inset 0 1px 0 color-mix(in oklch, white 40%, transparent),
0 10px 22px color-mix(in oklch, var(--color-primary) 8%, transparent);
--ui-gauge-center-accent-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-primary-container) 68%, white 32%),
color-mix(in oklch, var(--color-secondary-container) 16%, var(--color-primary-container) 84%)
);
--ui-gauge-center-accent-shadow:
inset 0 1px 0 color-mix(in oklch, white 44%, transparent),
0 14px 28px color-mix(in oklch, var(--color-primary) 14%, transparent);
--ui-gauge-indicator-default-start: color-mix(
in oklch,
var(--color-primary-container) 82%,
white 18%
);
--ui-gauge-indicator-default-end: var(--color-primary);
--ui-gauge-indicator-success-start: color-mix(
in oklch,
var(--color-tertiary-container) 80%,
white 20%
);
--ui-gauge-indicator-success-end: var(--color-success);
--ui-gauge-indicator-warning-start: color-mix(
in oklch,
var(--color-warning) 22%,
var(--color-surface-bright) 78%
);
--ui-gauge-indicator-warning-end: var(--color-warning);
--ui-gauge-indicator-destructive-start: color-mix(
in oklch,
var(--color-error-container) 82%,
white 18%
);
--ui-gauge-indicator-destructive-end: var(--color-error);
--ui-sparkbar-height-sm: 3rem;
--ui-sparkbar-height-md: 4.25rem;
--ui-sparkbar-height-lg: 5rem;
--ui-sparkbar-gap-sm: 0.25rem;
--ui-sparkbar-gap-md: 0.375rem;
--ui-sparkbar-gap-lg: 0.4375rem;
--ui-sparkbar-bar-radius: var(--radius-full);
--ui-sparkbar-inactive-default-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container-highest) 84%, white 16%),
color-mix(in oklch, var(--color-surface-container) 16%, var(--color-surface-container-highest) 84%)
);
--ui-sparkbar-inactive-default-shadow:
inset 0 1px 0 color-mix(in oklch, white 30%, transparent);
--ui-sparkbar-inactive-subtle-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-surface-container) 80%, white 20%),
color-mix(in oklch, var(--color-surface) 12%, var(--color-surface-container) 88%)
);
--ui-sparkbar-inactive-subtle-shadow:
inset 0 1px 0 color-mix(in oklch, white 24%, transparent);
--ui-sparkbar-inactive-contrast-bg: color-mix(in oklch, white 40%, transparent);
--ui-sparkbar-inactive-contrast-shadow:
inset 0 1px 0 color-mix(in oklch, white 20%, transparent);
--ui-sparkbar-active-default-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-primary-container) 84%, white 16%),
color-mix(in oklch, var(--color-primary) 26%, var(--color-primary-container) 74%)
);
--ui-sparkbar-active-default-shadow:
inset 0 1px 0 color-mix(in oklch, white 38%, transparent),
0 10px 22px color-mix(in oklch, var(--color-primary) 14%, transparent);
--ui-sparkbar-active-success-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-tertiary-container) 80%, white 20%),
color-mix(in oklch, var(--color-success) 26%, var(--color-tertiary-container) 74%)
);
--ui-sparkbar-active-success-shadow:
inset 0 1px 0 color-mix(in oklch, white 32%, transparent),
0 10px 22px color-mix(in oklch, var(--color-success) 14%, transparent);
--ui-sparkbar-active-warning-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-warning) 26%, var(--color-surface-bright) 74%),
color-mix(in oklch, var(--color-warning) 46%, var(--color-primary-container) 54%)
);
--ui-sparkbar-active-warning-shadow:
inset 0 1px 0 color-mix(in oklch, white 28%, transparent),
0 10px 22px color-mix(in oklch, var(--color-warning) 14%, transparent);
--ui-sparkbar-active-destructive-bg: linear-gradient(
180deg,
color-mix(in oklch, var(--color-error-container) 84%, white 16%),
color-mix(in oklch, var(--color-error) 22%, var(--color-error-container) 78%)
);
--ui-sparkbar-active-destructive-shadow:
inset 0 1px 0 color-mix(in oklch, white 30%, transparent),
0 10px 22px color-mix(in oklch, var(--color-error) 14%, transparent);
--ui-skeleton-radius: var(--radius-sm);
--ui-skeleton-block-radius: var(--radius-md);
--ui-skeleton-pill-radius: var(--radius-full);