feat(ui): polish core component surfaces
This commit is contained in:
@@ -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"
|
||||
]);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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%]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
]);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user