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
+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);