feat: add core UI components and baseline tests
This commit is contained in:
@@ -108,6 +108,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiui-skeleton-shimmer {
|
||||
from {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
}
|
||||
|
||||
.motion-transition {
|
||||
transition-duration: var(--dur-base);
|
||||
transition-property: color, background-color, border-color, box-shadow, opacity,
|
||||
|
||||
@@ -12,19 +12,37 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --clean --dts --format esm,cjs",
|
||||
"test": "vitest run --config ../../vitest.config.ts",
|
||||
"test:watch": "vitest --config ../../vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-ui/tokens": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"motion": "^12.38.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"jsdom": "^29.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1 || ^19.0.0",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Button } from "./button";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders a native button with root and label slots", () => {
|
||||
render(<Button variant="secondary">Save changes</Button>);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Save changes" });
|
||||
|
||||
expect(button).toHaveAttribute("data-slot", "root");
|
||||
expect(button).toHaveAttribute("data-variant", "secondary");
|
||||
expect(button).toHaveAttribute("type", "button");
|
||||
expect(screen.getByText("Save changes")).toHaveAttribute("data-slot", "label");
|
||||
});
|
||||
|
||||
it("disables interaction and exposes loading hooks when loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
|
||||
render(
|
||||
<Button loading onClick={onClick}>
|
||||
Saving
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Saving" });
|
||||
|
||||
await user.click(button);
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "");
|
||||
expect(button.querySelector('[data-slot="icon"]')).toBeInTheDocument();
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports asChild rendering without forcing button semantics", () => {
|
||||
render(
|
||||
<Button asChild variant="ghost">
|
||||
<a href="/release-notes">Release notes</a>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Release notes" });
|
||||
|
||||
expect(link).toHaveAttribute("data-slot", "root");
|
||||
expect(link).toHaveAttribute("data-variant", "ghost");
|
||||
expect(link).not.toHaveAttribute("type");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Checkbox } from "./checkbox";
|
||||
|
||||
describe("Checkbox", () => {
|
||||
it("renders with root and icon slots and toggles by click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCheckedChange = vi.fn();
|
||||
|
||||
render(<Checkbox aria-label="Accept terms" onCheckedChange={onCheckedChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
|
||||
|
||||
expect(checkbox).toHaveAttribute("data-slot", "root");
|
||||
expect(checkbox).toHaveAttribute("data-state", "unchecked");
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(checkbox).toHaveAttribute("data-state", "checked");
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
||||
expect(screen.getByText("✓")).toHaveAttribute("data-slot", "icon");
|
||||
});
|
||||
|
||||
it("supports keyboard interaction", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<Checkbox aria-label="Keyboard checkbox" />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: "Keyboard checkbox" });
|
||||
|
||||
await user.tab();
|
||||
expect(checkbox).toHaveFocus();
|
||||
|
||||
await user.keyboard(" ");
|
||||
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(checkbox).toHaveAttribute("data-state", "checked");
|
||||
});
|
||||
|
||||
it("exposes disabled and invalid state hooks", () => {
|
||||
render(<Checkbox aria-label="Disabled checkbox" disabled invalid />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: "Disabled checkbox" });
|
||||
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(checkbox).toHaveAttribute("aria-invalid", "true");
|
||||
expect(checkbox).toHaveAttribute("data-disabled", "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import { checkboxIndicatorVariants, checkboxVariants } from "./checkbox.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
export type CheckboxProps = ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
|
||||
invalid?: boolean;
|
||||
};
|
||||
|
||||
export const Checkbox = forwardRef<
|
||||
ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
CheckboxProps
|
||||
>(function Checkbox({ className, disabled, invalid, ...props }, ref) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
{...props}
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({
|
||||
disabled,
|
||||
invalid
|
||||
})}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(checkboxVariants(), className)}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
{...createSlot("icon")}
|
||||
className={checkboxIndicatorVariants()}
|
||||
>
|
||||
✓
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const checkboxVariants = cva(
|
||||
[
|
||||
"inline-flex size-5 shrink-0 items-center justify-center rounded-[calc(var(--radius-sm)-4px)] border border-[var(--color-border-strong)] bg-[var(--color-card)] shadow-[var(--shadow-xs)] outline-none",
|
||||
"text-[var(--color-primary-foreground)] transition-[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(--color-background)]",
|
||||
"data-[state=checked]:border-[var(--color-primary)] data-[state=checked]:bg-[var(--color-primary)]",
|
||||
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45",
|
||||
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
|
||||
getMotionRecipeClassNames("ring")
|
||||
]
|
||||
);
|
||||
|
||||
export const checkboxIndicatorVariants = cva([
|
||||
"inline-flex items-center justify-center text-[0.8rem] leading-none"
|
||||
]);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from "./dialog";
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("opens from the trigger and closes from the close control", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Dialog>
|
||||
<DialogTrigger>Open dialog</DialogTrigger>
|
||||
<DialogContent size="lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review launch</DialogTitle>
|
||||
<DialogDescription>Check the launch checklist before shipping.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose>Done</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open dialog" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveAttribute("data-slot", "content");
|
||||
expect(dialog).toHaveAttribute("data-size", "lg");
|
||||
expect(screen.getByText("Review launch")).toHaveAttribute("data-slot", "label");
|
||||
expect(screen.getByText("Check the launch checklist before shipping.")).toHaveAttribute(
|
||||
"data-slot",
|
||||
"description"
|
||||
);
|
||||
expect(document.querySelector('[data-slot="overlay"]')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Close dialog" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies controlled open state changes from trigger and Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Dialog open={false} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger>Launch dialog</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>Controlled</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Launch dialog" }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
|
||||
render(
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<DialogTrigger>Launch dialog</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>Controlled</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("renders header and footer slots when provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Dialog>
|
||||
<DialogTrigger>Open summary</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Summary</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose>Close</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open summary" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText("Summary").closest('[data-slot="header"]')).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("button", { name: "Close" }).closest('[data-slot="footer"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import { dialogContentVariants, dialogFooterVariants, dialogHeaderVariants, dialogOverlayVariants } from "./dialog.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
export const Dialog = DialogPrimitive.Root;
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
export const DialogPortal = DialogPrimitive.Portal;
|
||||
export const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
export const DialogOverlay = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(function DialogOverlay({ className, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
{...props}
|
||||
{...createSlot("overlay")}
|
||||
className={cn(dialogOverlayVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export type DialogContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
|
||||
VariantProps<typeof dialogContentVariants>;
|
||||
|
||||
export const DialogContent = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(function DialogContent({ children, className, size, ...props }, ref) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
{...props}
|
||||
{...createSlot("content")}
|
||||
{...createDataAttributes({ size })}
|
||||
className={cn(dialogContentVariants({ size }), className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
aria-label="Close dialog"
|
||||
className="absolute right-4 top-4 inline-flex size-9 items-center justify-center rounded-[var(--radius-full)] text-[var(--color-muted-foreground)] outline-none transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:bg-[var(--color-surface)] hover:text-[var(--color-foreground)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-card)]"
|
||||
>
|
||||
<span aria-hidden="true" className="text-lg leading-none">
|
||||
×
|
||||
</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
export function DialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
{...createSlot("header")}
|
||||
className={cn(dialogHeaderVariants(), className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
{...createSlot("footer")}
|
||||
className={cn(dialogFooterVariants(), className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DialogTitle = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Title>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(function DialogTitle({ className, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
{...props}
|
||||
{...createSlot("label")}
|
||||
className={cn("pr-10 text-xl font-semibold tracking-tight", className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const DialogDescription = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Description>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(function DialogDescription({ className, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
{...props}
|
||||
{...createSlot("description")}
|
||||
className={cn("text-sm leading-6 text-[var(--color-muted-foreground)]", className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cva } from "../lib/cva";
|
||||
|
||||
export const dialogOverlayVariants = cva([
|
||||
"fixed inset-0 z-50 bg-[var(--color-overlay)] backdrop-blur-[2px]",
|
||||
"data-[state=open]:motion-overlay-enter data-[state=closed]:motion-overlay-exit"
|
||||
]);
|
||||
|
||||
export const dialogContentVariants = cva(
|
||||
[
|
||||
"fixed left-1/2 top-1/2 z-50 grid -translate-x-1/2 -translate-y-1/2 gap-5",
|
||||
"w-[min(calc(100vw-2rem),40rem)] max-h-[calc(100vh-2rem)] overflow-y-auto",
|
||||
"rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-card-foreground)] shadow-[var(--shadow-md)] outline-none",
|
||||
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "w-[min(calc(100vw-2rem),30rem)]",
|
||||
md: "w-[min(calc(100vw-2rem),40rem)]",
|
||||
lg: "w-[min(calc(100vw-2rem),52rem)]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const dialogHeaderVariants = cva(["flex flex-col gap-2 text-left"]);
|
||||
|
||||
export const dialogFooterVariants = cva([
|
||||
"flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
|
||||
]);
|
||||
@@ -0,0 +1,91 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from "./dropdown-menu";
|
||||
|
||||
describe("DropdownMenu", () => {
|
||||
it("opens from the trigger and renders label, items, and shortcuts", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>Open menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent size="lg">
|
||||
<DropdownMenuLabel inset>Launch actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem inset onSelect={onSelect}>
|
||||
Publish
|
||||
<DropdownMenuShortcut>P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked>Require review</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuRadioGroup value="design">
|
||||
<DropdownMenuRadioItem value="design">Design</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
const menu = await screen.findByRole("menu");
|
||||
expect(menu).toHaveAttribute("data-slot", "content");
|
||||
expect(menu).toHaveAttribute("data-size", "lg");
|
||||
expect(screen.getByText("Launch actions")).toHaveAttribute("data-slot", "label");
|
||||
expect(screen.getByText("Publish").closest('[data-slot="item"]')).toHaveAttribute(
|
||||
"data-inset",
|
||||
""
|
||||
);
|
||||
expect(screen.getByText("P")).toHaveAttribute("data-slot", "shortcut");
|
||||
expect(screen.getByText("Require review").closest('[data-slot="item"]')).toHaveAttribute(
|
||||
"data-checked",
|
||||
""
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Publish"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes on Escape and supports controlled opening", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DropdownMenu open={false} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger>Open menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Open</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
|
||||
render(
|
||||
<DropdownMenu open onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger>Open menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Open</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
type HTMLAttributes
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
dropdownMenuContentVariants,
|
||||
dropdownMenuItemVariants,
|
||||
dropdownMenuLabelVariants,
|
||||
dropdownMenuSeparatorVariants
|
||||
} from "./dropdown-menu.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
export type DropdownMenuContentProps =
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> &
|
||||
VariantProps<typeof dropdownMenuContentVariants>;
|
||||
|
||||
export const DropdownMenuContent = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
DropdownMenuContentProps
|
||||
>(function DropdownMenuContent(
|
||||
{ className, sideOffset = 8, size, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
{...props}
|
||||
{...createSlot("content")}
|
||||
{...createDataAttributes({ size })}
|
||||
className={cn(dropdownMenuContentVariants({ size }), className)}
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
/>
|
||||
</DropdownMenuPortal>
|
||||
);
|
||||
});
|
||||
|
||||
export type DropdownMenuSubContentProps =
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> &
|
||||
VariantProps<typeof dropdownMenuContentVariants>;
|
||||
|
||||
export const DropdownMenuSubContent = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
DropdownMenuSubContentProps
|
||||
>(function DropdownMenuSubContent(
|
||||
{ className, sideOffset = 10, size, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
{...props}
|
||||
{...createSlot("content")}
|
||||
{...createDataAttributes({ size })}
|
||||
className={cn(dropdownMenuContentVariants({ size }), className)}
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
/>
|
||||
</DropdownMenuPortal>
|
||||
);
|
||||
});
|
||||
|
||||
export type DropdownMenuItemProps =
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> &
|
||||
VariantProps<typeof dropdownMenuItemVariants>;
|
||||
|
||||
export const DropdownMenuItem = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
DropdownMenuItemProps
|
||||
>(function DropdownMenuItem(
|
||||
{ className, inset, variant, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
{...props}
|
||||
{...createSlot("item")}
|
||||
{...createDataAttributes({ inset, variant })}
|
||||
className={cn(dropdownMenuItemVariants({ inset, variant }), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export type DropdownMenuCheckboxItemProps =
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> &
|
||||
VariantProps<typeof dropdownMenuItemVariants>;
|
||||
|
||||
export const DropdownMenuCheckboxItem = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
DropdownMenuCheckboxItemProps
|
||||
>(function DropdownMenuCheckboxItem(
|
||||
{ checked, children, className, inset = true, variant, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
{...props}
|
||||
checked={checked}
|
||||
{...createSlot("item")}
|
||||
{...createDataAttributes({ checked: checked === true, inset, variant })}
|
||||
className={cn(dropdownMenuItemVariants({ inset, variant }), className)}
|
||||
ref={ref}
|
||||
>
|
||||
<span
|
||||
{...createSlot("icon")}
|
||||
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-xs"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>✓</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
});
|
||||
|
||||
export type DropdownMenuRadioItemProps =
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> &
|
||||
VariantProps<typeof dropdownMenuItemVariants>;
|
||||
|
||||
export const DropdownMenuRadioItem = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
DropdownMenuRadioItemProps
|
||||
>(function DropdownMenuRadioItem(
|
||||
{ children, className, inset = true, variant, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
{...props}
|
||||
{...createSlot("item")}
|
||||
{...createDataAttributes({ inset, variant })}
|
||||
className={cn(dropdownMenuItemVariants({ inset, variant }), className)}
|
||||
ref={ref}
|
||||
>
|
||||
<span
|
||||
{...createSlot("icon")}
|
||||
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-xs"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>•</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
});
|
||||
|
||||
export type DropdownMenuLabelProps =
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> &
|
||||
VariantProps<typeof dropdownMenuLabelVariants>;
|
||||
|
||||
export const DropdownMenuLabel = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
DropdownMenuLabelProps
|
||||
>(function DropdownMenuLabel({ className, inset, ...props }, ref) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
{...props}
|
||||
{...createSlot("label")}
|
||||
{...createDataAttributes({ inset })}
|
||||
className={cn(dropdownMenuLabelVariants({ inset }), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const DropdownMenuSeparator = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(function DropdownMenuSeparator({ className, ...props }, ref) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
{...props}
|
||||
{...createSlot("separator")}
|
||||
className={cn(dropdownMenuSeparatorVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export type DropdownMenuSubTriggerProps =
|
||||
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> &
|
||||
VariantProps<typeof dropdownMenuItemVariants>;
|
||||
|
||||
export const DropdownMenuSubTrigger = forwardRef<
|
||||
ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
DropdownMenuSubTriggerProps
|
||||
>(function DropdownMenuSubTrigger(
|
||||
{ children, className, inset, variant, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
{...props}
|
||||
{...createSlot("trigger")}
|
||||
{...createDataAttributes({ inset, variant })}
|
||||
className={cn(dropdownMenuItemVariants({ inset, variant }), className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
<span className="ml-auto text-xs text-[var(--color-muted-foreground)]">›</span>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
export function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
{...createSlot("shortcut")}
|
||||
className={cn(
|
||||
"ml-auto text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cva } from "../lib/cva";
|
||||
|
||||
export const dropdownMenuContentVariants = cva(
|
||||
[
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] p-1.5 text-[var(--color-card-foreground)] shadow-[var(--shadow-md)] outline-none",
|
||||
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop",
|
||||
"data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left data-[side=top]:origin-bottom"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "min-w-[11rem]",
|
||||
md: "min-w-[13rem]",
|
||||
lg: "min-w-[15rem]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const dropdownMenuItemVariants = cva(
|
||||
[
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-[calc(var(--radius-sm)-4px)] px-2.5 py-2 text-sm outline-none",
|
||||
"text-[var(--color-foreground)] transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
|
||||
"focus:bg-[var(--color-surface)] focus:text-[var(--color-foreground)] data-[highlighted]:bg-[var(--color-surface)] data-[highlighted]:text-[var(--color-foreground)]",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
inset: {
|
||||
false: "",
|
||||
true: "pl-8"
|
||||
},
|
||||
variant: {
|
||||
default: "",
|
||||
destructive:
|
||||
"text-[var(--color-destructive)] data-[highlighted]:bg-[color-mix(in_oklch,var(--color-destructive)_12%,var(--color-card))] data-[highlighted]:text-[var(--color-destructive)]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
inset: false,
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const dropdownMenuLabelVariants = cva(
|
||||
[
|
||||
"px-2.5 py-2 text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
inset: {
|
||||
false: "",
|
||||
true: "pl-8"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
inset: false
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const dropdownMenuSeparatorVariants = cva([
|
||||
"-mx-1 my-1 h-px bg-[var(--color-border)]"
|
||||
]);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Field, FieldControl, FieldDescription, FieldError, FieldLabel } from "./field";
|
||||
import { Input } from "./input";
|
||||
|
||||
describe("Field", () => {
|
||||
it("links the label, control, description and error content through context", () => {
|
||||
render(
|
||||
<Field invalid required>
|
||||
<FieldLabel requiredIndicator>Email address</FieldLabel>
|
||||
<FieldControl>
|
||||
<Input />
|
||||
<FieldDescription>Use your company email.</FieldDescription>
|
||||
<FieldError>Please enter a valid email address.</FieldError>
|
||||
</FieldControl>
|
||||
</Field>
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "Email address" });
|
||||
const label = screen.getByText("Email address").closest("label");
|
||||
const description = screen.getByText("Use your company email.");
|
||||
const error = screen.getByText("Please enter a valid email address.");
|
||||
const control = input.closest("[data-slot='control']");
|
||||
|
||||
expect(label).toHaveAttribute("for", input.getAttribute("id"));
|
||||
expect(input).toHaveAttribute("aria-describedby", `${description.id} ${error.id}`);
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(control).toHaveAttribute("data-invalid", "");
|
||||
expect(control).toHaveAttribute("data-required", "");
|
||||
});
|
||||
|
||||
it("exposes root state attributes including horizontal orientation", () => {
|
||||
render(
|
||||
<Field disabled orientation="horizontal" readOnly required>
|
||||
<FieldLabel>Internal note</FieldLabel>
|
||||
<FieldControl>
|
||||
<Input />
|
||||
</FieldControl>
|
||||
</Field>
|
||||
);
|
||||
|
||||
const field = screen.getByText("Internal note").closest("[data-slot='root']");
|
||||
|
||||
expect(field).toHaveAttribute("data-orientation", "horizontal");
|
||||
expect(field).toHaveAttribute("data-disabled", "");
|
||||
expect(field).toHaveAttribute("data-readonly", "");
|
||||
expect(field).toHaveAttribute("data-required", "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useContext,
|
||||
useId,
|
||||
type ComponentPropsWithoutRef
|
||||
} from "react";
|
||||
|
||||
import { Label } from "./label";
|
||||
import { cn } from "../lib/cn";
|
||||
import { createDataAttributes, createSlot, type FieldStateProps } from "../lib/contracts";
|
||||
|
||||
type FieldContextValue = {
|
||||
descriptionId: string;
|
||||
disabled: boolean;
|
||||
errorId: string;
|
||||
inputId: string;
|
||||
invalid: boolean;
|
||||
readOnly: boolean;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export type FieldRenderProps = FieldContextValue;
|
||||
|
||||
const FieldContext = createContext<FieldContextValue | null>(null);
|
||||
|
||||
export function useFieldContext() {
|
||||
return useContext(FieldContext);
|
||||
}
|
||||
|
||||
export type FieldProps = ComponentPropsWithoutRef<"div"> &
|
||||
FieldStateProps & {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
};
|
||||
|
||||
export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
disabled = false,
|
||||
id,
|
||||
invalid = false,
|
||||
orientation = "vertical",
|
||||
readOnly = false,
|
||||
required = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const reactId = useId();
|
||||
const baseId = id ?? `field-${reactId.replace(/:/g, "")}`;
|
||||
const value: FieldContextValue = {
|
||||
descriptionId: `${baseId}-description`,
|
||||
disabled,
|
||||
errorId: `${baseId}-error`,
|
||||
inputId: `${baseId}-control`,
|
||||
invalid,
|
||||
readOnly,
|
||||
required
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldContext.Provider value={value}>
|
||||
<div
|
||||
{...props}
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({
|
||||
disabled,
|
||||
invalid,
|
||||
orientation,
|
||||
readonly: readOnly,
|
||||
required
|
||||
})}
|
||||
className={cn(
|
||||
"grid gap-2.5",
|
||||
orientation === "horizontal" && "gap-3 sm:grid-cols-[minmax(10rem,12rem)_minmax(0,1fr)] sm:items-start",
|
||||
className
|
||||
)}
|
||||
id={id}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FieldContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
export const FormItem = Field;
|
||||
export const FieldLabel = Label;
|
||||
|
||||
export type FieldDescriptionProps = ComponentPropsWithoutRef<"p">;
|
||||
|
||||
export const FieldDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
FieldDescriptionProps
|
||||
>(function FieldDescription({ className, id, ...props }, ref) {
|
||||
const field = useFieldContext();
|
||||
|
||||
return (
|
||||
<p
|
||||
{...props}
|
||||
{...createSlot("description")}
|
||||
className={cn(
|
||||
"text-sm leading-6 text-[var(--color-muted-foreground)]",
|
||||
className
|
||||
)}
|
||||
id={id ?? field?.descriptionId}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export type FieldErrorProps = ComponentPropsWithoutRef<"p">;
|
||||
|
||||
export const FieldError = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
FieldErrorProps
|
||||
>(function FieldError({ children, className, id, ...props }, ref) {
|
||||
const field = useFieldContext();
|
||||
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
{...props}
|
||||
{...createSlot("description")}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-6 text-[var(--color-destructive)]",
|
||||
className
|
||||
)}
|
||||
id={id ?? field?.errorId}
|
||||
ref={ref}
|
||||
role="alert"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
|
||||
export type FieldControlProps = ComponentPropsWithoutRef<"div">;
|
||||
|
||||
export const FieldControl = forwardRef<
|
||||
HTMLDivElement,
|
||||
FieldControlProps
|
||||
>(function FieldControl({ className, ...props }, ref) {
|
||||
const field = useFieldContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
{...createSlot("control")}
|
||||
{...createDataAttributes({
|
||||
disabled: field?.disabled,
|
||||
invalid: field?.invalid,
|
||||
readonly: field?.readOnly,
|
||||
required: field?.required
|
||||
})}
|
||||
className={cn("grid gap-2", className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export function useFieldIds() {
|
||||
const field = useFieldContext();
|
||||
|
||||
if (!field) {
|
||||
throw new Error("useFieldIds must be used within <Field>.");
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Field, FieldControl, FieldDescription, FieldError } from "./field";
|
||||
import { Input } from "./input";
|
||||
import { Label } from "./label";
|
||||
|
||||
describe("Input", () => {
|
||||
it("renders the input slot and reflects explicit state props", () => {
|
||||
render(
|
||||
<Input
|
||||
aria-label="Project slug"
|
||||
disabled
|
||||
invalid
|
||||
readOnly
|
||||
required
|
||||
size="lg"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "Project slug" });
|
||||
|
||||
expect(input).toHaveAttribute("data-slot", "input");
|
||||
expect(input).toHaveAttribute("data-disabled", "");
|
||||
expect(input).toHaveAttribute("data-invalid", "");
|
||||
expect(input).toHaveAttribute("data-readonly", "");
|
||||
expect(input).toHaveAttribute("data-required", "");
|
||||
expect(input).toHaveAttribute("data-size", "lg");
|
||||
expect(input).toBeDisabled();
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input).toHaveAttribute("readonly");
|
||||
expect(input).toBeRequired();
|
||||
});
|
||||
|
||||
it("inherits ids and described-by wiring from the surrounding field", () => {
|
||||
render(
|
||||
<Field invalid required>
|
||||
<Label requiredIndicator>Slug</Label>
|
||||
<FieldControl>
|
||||
<Input />
|
||||
<FieldDescription>Use lowercase letters and hyphens.</FieldDescription>
|
||||
<FieldError>Slug is required.</FieldError>
|
||||
</FieldControl>
|
||||
</Field>
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "Slug" });
|
||||
const description = screen.getByText("Use lowercase letters and hyphens.");
|
||||
const error = screen.getByText("Slug is required.");
|
||||
|
||||
expect(input).toHaveAttribute("aria-describedby", `${description.id} ${error.id}`);
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input).toBeRequired();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef
|
||||
} from "react";
|
||||
|
||||
import { inputVariants } from "./input.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import type { FieldStateProps } from "../lib/contracts";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
import { useFieldContext } from "./field";
|
||||
|
||||
function mergeIds(...ids: Array<string | undefined>) {
|
||||
const value = ids.filter(Boolean).join(" ").trim();
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export type InputProps = Omit<ComponentPropsWithoutRef<"input">, "size"> &
|
||||
FieldStateProps &
|
||||
VariantProps<typeof inputVariants>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{
|
||||
className,
|
||||
disabled,
|
||||
id,
|
||||
invalid,
|
||||
readOnly,
|
||||
required,
|
||||
size,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const field = useFieldContext();
|
||||
const resolvedDisabled = disabled ?? field?.disabled ?? false;
|
||||
const resolvedInvalid = invalid ?? field?.invalid ?? false;
|
||||
const resolvedReadOnly = readOnly ?? field?.readOnly ?? false;
|
||||
const resolvedRequired = required ?? field?.required ?? false;
|
||||
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
{...createSlot("input")}
|
||||
{...createDataAttributes({
|
||||
disabled: resolvedDisabled,
|
||||
invalid: resolvedInvalid,
|
||||
readonly: resolvedReadOnly,
|
||||
required: resolvedRequired,
|
||||
size
|
||||
})}
|
||||
aria-describedby={mergeIds(
|
||||
props["aria-describedby"],
|
||||
field?.descriptionId,
|
||||
resolvedInvalid ? field?.errorId : undefined
|
||||
)}
|
||||
aria-invalid={resolvedInvalid || undefined}
|
||||
className={cn(inputVariants({ size }), className)}
|
||||
disabled={resolvedDisabled}
|
||||
id={id ?? field?.inputId}
|
||||
readOnly={resolvedReadOnly}
|
||||
ref={ref}
|
||||
required={resolvedRequired}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const inputVariants = cva(
|
||||
[
|
||||
"flex w-full min-w-0 rounded-[var(--radius-md)] border border-[var(--color-input)] bg-[var(--color-card)]",
|
||||
"text-[var(--color-foreground)] shadow-[var(--shadow-xs)] outline-none",
|
||||
"placeholder:text-[var(--color-muted-foreground)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
|
||||
"disabled:cursor-not-allowed disabled:bg-[var(--color-surface)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100",
|
||||
"read-only:bg-[var(--color-surface)] read-only:text-[var(--color-muted-foreground)]",
|
||||
"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")
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-10 px-3 text-sm",
|
||||
md: "h-11 px-4 text-sm",
|
||||
lg: "h-12 px-4 text-base"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Field } from "./field";
|
||||
import { Label } from "./label";
|
||||
|
||||
describe("Label", () => {
|
||||
it("renders a standalone label with its slot hook", () => {
|
||||
render(<Label htmlFor="email">Email address</Label>);
|
||||
|
||||
const label = screen.getByText("Email address").closest("label");
|
||||
|
||||
expect(label).toHaveAttribute("data-slot", "label");
|
||||
expect(label).toHaveAttribute("for", "email");
|
||||
});
|
||||
|
||||
it("shows the required indicator when requested inside a required field", () => {
|
||||
render(
|
||||
<Field required>
|
||||
<Label requiredIndicator>Project name</Label>
|
||||
</Field>
|
||||
);
|
||||
|
||||
const label = screen.getByText("Project name").closest("label");
|
||||
|
||||
expect(label).toHaveAttribute("data-required", "");
|
||||
expect(screen.getByText("*")).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("inherits disabled and invalid state from the field context", () => {
|
||||
render(
|
||||
<Field disabled invalid>
|
||||
<Label>Email</Label>
|
||||
</Field>
|
||||
);
|
||||
|
||||
const label = screen.getByText("Email").closest("label");
|
||||
|
||||
expect(label).toHaveAttribute("data-disabled", "");
|
||||
expect(label).toHaveAttribute("data-invalid", "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { forwardRef, type ComponentPropsWithoutRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
import { useFieldContext } from "./field";
|
||||
|
||||
export type LabelProps = ComponentPropsWithoutRef<"label"> & {
|
||||
requiredIndicator?: boolean;
|
||||
};
|
||||
|
||||
export const Label = forwardRef<HTMLLabelElement, LabelProps>(function Label(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
htmlFor,
|
||||
requiredIndicator = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const field = useFieldContext();
|
||||
const disabled = props["aria-disabled"] === true || field?.disabled === true;
|
||||
const invalid = props["aria-invalid"] === true || field?.invalid === true;
|
||||
const required = props["aria-required"] === true || field?.required === true;
|
||||
|
||||
return (
|
||||
<label
|
||||
{...props}
|
||||
{...createSlot("label")}
|
||||
{...createDataAttributes({
|
||||
disabled,
|
||||
invalid,
|
||||
required
|
||||
})}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 text-sm font-medium leading-none text-[var(--color-foreground)]",
|
||||
"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
|
||||
"data-[disabled]:cursor-not-allowed data-[disabled]:text-[var(--color-muted-foreground)]",
|
||||
"data-[invalid]:text-[color-mix(in_oklch,var(--color-destructive)_82%,var(--color-foreground))]",
|
||||
className
|
||||
)}
|
||||
htmlFor={htmlFor ?? field?.inputId}
|
||||
ref={ref}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{requiredIndicator && required ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-[var(--color-destructive)]"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "./popover";
|
||||
|
||||
describe("Popover", () => {
|
||||
it("opens from the trigger and closes from the close control", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Popover>
|
||||
<PopoverTrigger>Open details</PopoverTrigger>
|
||||
<PopoverContent size="sm">
|
||||
<p>Editorial details</p>
|
||||
<PopoverArrow />
|
||||
<PopoverClose>Dismiss</PopoverClose>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Editorial details")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open details" }));
|
||||
|
||||
const content = await screen.findByText("Editorial details");
|
||||
expect(content.closest('[data-slot="content"]')).toHaveAttribute("data-size", "sm");
|
||||
expect(document.querySelector('[data-slot="arrow"]')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Editorial details")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports controlled open changes from the trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Popover open={false} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger>Open details</PopoverTrigger>
|
||||
<PopoverContent>Content</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Open details" }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import { popoverContentVariants } from "./popover.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
export const Popover = PopoverPrimitive.Root;
|
||||
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
export const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
export const PopoverPortal = PopoverPrimitive.Portal;
|
||||
export const PopoverClose = PopoverPrimitive.Close;
|
||||
|
||||
export type PopoverContentProps = ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> &
|
||||
VariantProps<typeof popoverContentVariants>;
|
||||
|
||||
export const PopoverContent = forwardRef<
|
||||
ElementRef<typeof PopoverPrimitive.Content>,
|
||||
PopoverContentProps
|
||||
>(function PopoverContent(
|
||||
{ className, sideOffset = 10, size, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<PopoverPortal>
|
||||
<PopoverPrimitive.Content
|
||||
{...props}
|
||||
{...createSlot("content")}
|
||||
{...createDataAttributes({ size })}
|
||||
className={cn(popoverContentVariants({ size }), className)}
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
/>
|
||||
</PopoverPortal>
|
||||
);
|
||||
});
|
||||
|
||||
export const PopoverArrow = forwardRef<
|
||||
ElementRef<typeof PopoverPrimitive.Arrow>,
|
||||
ComponentPropsWithoutRef<typeof PopoverPrimitive.Arrow>
|
||||
>(function PopoverArrow({ className, ...props }, ref) {
|
||||
return (
|
||||
<PopoverPrimitive.Arrow
|
||||
{...props}
|
||||
{...createSlot("arrow")}
|
||||
className={cn("fill-[var(--color-card)]", className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cva } from "../lib/cva";
|
||||
|
||||
export const popoverContentVariants = cva(
|
||||
[
|
||||
"z-50 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 text-[var(--color-card-foreground)] shadow-[var(--shadow-md)] outline-none",
|
||||
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop",
|
||||
"data-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left data-[side=top]:origin-bottom"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "w-64",
|
||||
md: "w-80",
|
||||
lg: "w-[24rem]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { RadioGroup, RadioGroupItem } from "./radio-group";
|
||||
|
||||
describe("RadioGroup", () => {
|
||||
it("renders orientation and default value state", () => {
|
||||
render(
|
||||
<RadioGroup aria-label="Review lane" defaultValue="design" orientation="horizontal">
|
||||
<RadioGroupItem aria-label="Editorial" value="editorial" />
|
||||
<RadioGroupItem aria-label="Design" value="design" />
|
||||
</RadioGroup>
|
||||
);
|
||||
|
||||
const group = screen.getByRole("radiogroup", { name: "Review lane" });
|
||||
const design = screen.getByRole("radio", { name: "Design" });
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("supports value change callbacks when a new option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioGroup aria-label="Priority" defaultValue="low" onValueChange={onValueChange}>
|
||||
<RadioGroupItem aria-label="Low" value="low" />
|
||||
<RadioGroupItem aria-label="Medium" value="medium" />
|
||||
<RadioGroupItem aria-label="High" value="high" />
|
||||
</RadioGroup>
|
||||
);
|
||||
|
||||
const medium = screen.getByRole("radio", { name: "Medium" });
|
||||
|
||||
await user.click(medium);
|
||||
|
||||
expect(medium).toBeChecked();
|
||||
expect(onValueChange).toHaveBeenLastCalledWith("medium");
|
||||
});
|
||||
|
||||
it("exposes disabled and invalid item state", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<RadioGroup aria-label="Status">
|
||||
<RadioGroupItem aria-label="Ready" value="ready" />
|
||||
<RadioGroupItem aria-label="Blocked" disabled invalid value="blocked" />
|
||||
</RadioGroup>
|
||||
);
|
||||
|
||||
const blocked = screen.getByRole("radio", { name: "Blocked" });
|
||||
|
||||
expect(blocked).toBeDisabled();
|
||||
expect(blocked).toHaveAttribute("aria-invalid", "true");
|
||||
expect(blocked).toHaveAttribute("data-disabled", "");
|
||||
|
||||
await user.click(blocked);
|
||||
expect(blocked).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import {
|
||||
radioGroupIndicatorVariants,
|
||||
radioGroupItemVariants,
|
||||
radioGroupVariants
|
||||
} from "./radio-group.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
export type RadioGroupProps = ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> &
|
||||
VariantProps<typeof radioGroupVariants>;
|
||||
|
||||
export function RadioGroup({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: RadioGroupProps) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
{...props}
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({ orientation })}
|
||||
className={cn(radioGroupVariants({ orientation }), className)}
|
||||
orientation={orientation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type RadioGroupItemProps =
|
||||
ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> & {
|
||||
invalid?: boolean;
|
||||
};
|
||||
|
||||
export const RadioGroupItem = forwardRef<
|
||||
ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
RadioGroupItemProps
|
||||
>(function RadioGroupItem({ className, disabled, invalid, ...props }, ref) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
{...props}
|
||||
{...createSlot("control")}
|
||||
{...createDataAttributes({
|
||||
disabled,
|
||||
invalid
|
||||
})}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(radioGroupItemVariants(), className)}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
{...createSlot("icon")}
|
||||
className={radioGroupIndicatorVariants()}
|
||||
/>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const radioGroupVariants = cva(["grid gap-3"], {
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal: "sm:grid-flow-col sm:auto-cols-fr",
|
||||
vertical: ""
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical"
|
||||
}
|
||||
});
|
||||
|
||||
export const radioGroupItemVariants = cva(
|
||||
[
|
||||
"inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-[var(--color-border-strong)] bg-[var(--color-card)] shadow-[var(--shadow-xs)] outline-none",
|
||||
"text-[var(--color-primary)] transition-[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(--color-background)]",
|
||||
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45",
|
||||
"data-[state=checked]:border-[var(--color-primary)]",
|
||||
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
|
||||
getMotionRecipeClassNames("ring")
|
||||
]
|
||||
);
|
||||
|
||||
export const radioGroupIndicatorVariants = cva([
|
||||
"flex size-full items-center justify-center after:block after:size-2 after:rounded-full after:bg-current after:content-['']"
|
||||
]);
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Field, FieldDescription, FieldError } from "./field";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "./select";
|
||||
|
||||
function ReviewLaneSelect(props?: React.ComponentProps<typeof Select>) {
|
||||
return (
|
||||
<Select {...props}>
|
||||
<SelectTrigger aria-label="Review lane">
|
||||
<SelectValue placeholder="Choose a review lane" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Review lane</SelectLabel>
|
||||
<SelectItem value="editorial">Editorial review</SelectItem>
|
||||
<SelectItem value="design">Design review</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="legal">Legal review</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Select", () => {
|
||||
it("renders default value and opens selectable content", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ReviewLaneSelect defaultValue="design" />);
|
||||
|
||||
const trigger = screen.getByRole("combobox", { name: "Review lane" });
|
||||
|
||||
expect(trigger).toHaveTextContent("Design review");
|
||||
expect(trigger).toHaveAttribute("data-slot", "trigger");
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const designOption = within(listbox).getByRole("option", { name: "Design review" });
|
||||
|
||||
expect(listbox).toHaveAttribute("data-slot", "content");
|
||||
expect(designOption).toHaveAttribute("data-slot", "item");
|
||||
});
|
||||
|
||||
it("updates controlled value after selecting an option", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function ControlledSelect() {
|
||||
const [value, setValue] = useState("editorial");
|
||||
|
||||
return <ReviewLaneSelect value={value} onValueChange={setValue} />;
|
||||
}
|
||||
|
||||
render(<ControlledSelect />);
|
||||
|
||||
const trigger = screen.getByRole("combobox", { name: "Review lane" });
|
||||
expect(trigger).toHaveTextContent("Editorial review");
|
||||
|
||||
await user.click(trigger);
|
||||
await user.click(await screen.findByRole("option", { name: "Legal review" }));
|
||||
|
||||
expect(trigger).toHaveTextContent("Legal review");
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports field invalid state and described-by wiring", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Field id="routing" invalid>
|
||||
<Select>
|
||||
<SelectTrigger aria-label="Routing team">
|
||||
<SelectValue placeholder="Choose a team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="product">Product</SelectItem>
|
||||
<SelectItem value="design">Design</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>Choose the primary owner.</FieldDescription>
|
||||
<FieldError>Select a team before publishing.</FieldError>
|
||||
</Field>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("combobox", { name: "Routing team" });
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-invalid", "true");
|
||||
expect(trigger).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
expect.stringContaining("routing-description")
|
||||
);
|
||||
expect(trigger).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
expect.stringContaining("routing-error")
|
||||
);
|
||||
|
||||
await user.click(trigger);
|
||||
expect(await screen.findByRole("option", { name: "Product" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes disabled state on the trigger", () => {
|
||||
render(
|
||||
<Select disabled>
|
||||
<SelectTrigger aria-label="Disabled select">
|
||||
<SelectValue placeholder="Disabled" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one">One</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("combobox", { name: "Disabled select" });
|
||||
|
||||
expect(trigger).toBeDisabled();
|
||||
expect(trigger).toHaveAttribute("data-disabled", "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import {
|
||||
selectContentVariants,
|
||||
selectItemVariants,
|
||||
selectLabelVariants,
|
||||
selectSeparatorVariants,
|
||||
selectTriggerVariants,
|
||||
selectViewportVariants
|
||||
} from "./select.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
import { useFieldContext } from "./field";
|
||||
|
||||
function mergeIds(...ids: Array<string | undefined>) {
|
||||
const value = ids.filter(Boolean).join(" ").trim();
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export const Select = SelectPrimitive.Root;
|
||||
export const SelectGroup = SelectPrimitive.Group;
|
||||
export const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
export type SelectTriggerProps =
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
invalid?: boolean;
|
||||
};
|
||||
|
||||
export const SelectTrigger = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
SelectTriggerProps
|
||||
>(function SelectTrigger({ children, className, disabled, invalid, ...props }, ref) {
|
||||
const field = useFieldContext();
|
||||
const resolvedInvalid = invalid ?? field?.invalid ?? false;
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
{...props}
|
||||
{...createSlot("trigger")}
|
||||
{...createDataAttributes({
|
||||
disabled,
|
||||
invalid: resolvedInvalid
|
||||
})}
|
||||
aria-describedby={mergeIds(
|
||||
props["aria-describedby"],
|
||||
field?.descriptionId,
|
||||
resolvedInvalid ? field?.errorId : undefined
|
||||
)}
|
||||
aria-invalid={resolvedInvalid || undefined}
|
||||
className={cn(selectTriggerVariants(), className)}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
{...createSlot("icon")}
|
||||
className="text-[var(--color-muted-foreground)]"
|
||||
>
|
||||
▾
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
export const SelectContent = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(function SelectContent(
|
||||
{ children, className, position = "popper", ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
{...props}
|
||||
{...createSlot("content")}
|
||||
className={cn(selectContentVariants(), className)}
|
||||
position={position}
|
||||
ref={ref}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
{...createSlot("content")}
|
||||
className={selectViewportVariants()}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const SelectLabel = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Label>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(function SelectLabel({ className, ...props }, ref) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
{...props}
|
||||
{...createSlot("label")}
|
||||
className={cn(selectLabelVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const SelectItem = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Item>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(function SelectItem({ children, className, ...props }, ref) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
{...props}
|
||||
{...createSlot("item")}
|
||||
className={cn(selectItemVariants(), className)}
|
||||
ref={ref}
|
||||
>
|
||||
<span
|
||||
{...createSlot("icon")}
|
||||
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-xs"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>✓</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export const SelectSeparator = forwardRef<
|
||||
ElementRef<typeof SelectPrimitive.Separator>,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(function SelectSeparator({ className, ...props }, ref) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
{...props}
|
||||
{...createSlot("separator")}
|
||||
className={cn(selectSeparatorVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const selectTriggerVariants = cva(
|
||||
[
|
||||
"inline-flex h-11 w-full items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-input)] bg-[var(--color-card)] px-4 text-left text-sm text-[var(--color-foreground)] shadow-[var(--shadow-xs)] outline-none",
|
||||
"placeholder:text-[var(--color-muted-foreground)]",
|
||||
"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(--color-surface)] data-[disabled]:text-[var(--color-muted-foreground)] data-[disabled]:opacity-100",
|
||||
"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")
|
||||
]
|
||||
);
|
||||
|
||||
export const selectContentVariants = cva([
|
||||
"relative z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] p-1.5 text-[var(--color-card-foreground)] shadow-[var(--shadow-md)] outline-none",
|
||||
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop"
|
||||
]);
|
||||
|
||||
export const selectViewportVariants = cva([
|
||||
"max-h-[16rem] overflow-y-auto p-0.5"
|
||||
]);
|
||||
|
||||
export const selectItemVariants = cva([
|
||||
"relative flex w-full cursor-default select-none items-center gap-2 rounded-[calc(var(--radius-sm)-4px)] 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(--color-surface)] focus:text-[var(--color-foreground)] data-[highlighted]:bg-[var(--color-surface)] data-[highlighted]:text-[var(--color-foreground)]",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45"
|
||||
]);
|
||||
|
||||
export const selectLabelVariants = cva([
|
||||
"px-2.5 py-2 text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
|
||||
]);
|
||||
|
||||
export const selectSeparatorVariants = cva([
|
||||
"-mx-1 my-1 h-px bg-[var(--color-border)]"
|
||||
]);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Separator } from "./separator";
|
||||
|
||||
describe("Separator", () => {
|
||||
it("renders a decorative separator by default", () => {
|
||||
render(<Separator data-testid="separator" />);
|
||||
|
||||
const separator = screen.getByTestId("separator");
|
||||
|
||||
expect(separator).toHaveAttribute("data-slot", "root");
|
||||
expect(separator).toHaveAttribute("data-orientation", "horizontal");
|
||||
expect(separator).toHaveAttribute("role", "presentation");
|
||||
expect(separator).not.toHaveAttribute("aria-orientation");
|
||||
});
|
||||
|
||||
it("renders semantic separator attributes when decorative is false", () => {
|
||||
render(<Separator data-testid="separator" decorative={false} orientation="vertical" tone="strong" />);
|
||||
|
||||
const separator = screen.getByTestId("separator");
|
||||
|
||||
expect(separator).toHaveAttribute("role", "separator");
|
||||
expect(separator).toHaveAttribute("aria-orientation", "vertical");
|
||||
expect(separator).toHaveAttribute("data-orientation", "vertical");
|
||||
expect(separator).toHaveAttribute("data-tone", "strong");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
import { separatorVariants } from "./separator.variants";
|
||||
|
||||
export type SeparatorProps = React.ComponentPropsWithoutRef<"div"> &
|
||||
VariantProps<typeof separatorVariants> & {
|
||||
decorative?: boolean;
|
||||
};
|
||||
|
||||
export const Separator = forwardRef<HTMLDivElement, SeparatorProps>(function Separator(
|
||||
{
|
||||
className,
|
||||
decorative = true,
|
||||
orientation = "horizontal",
|
||||
role,
|
||||
tone,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({
|
||||
orientation,
|
||||
tone
|
||||
})}
|
||||
aria-orientation={decorative ? undefined : orientation ?? undefined}
|
||||
className={cn(separatorVariants({ orientation, tone }), className)}
|
||||
ref={ref}
|
||||
role={decorative ? "presentation" : role ?? "separator"}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cva } from "../lib/cva";
|
||||
|
||||
export const separatorVariants = cva("shrink-0 bg-[var(--color-border)]", {
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal: "h-px w-full",
|
||||
vertical: "h-full min-h-4 w-px"
|
||||
},
|
||||
tone: {
|
||||
subtle: "bg-[color-mix(in_oklch,var(--color-border)_72%,transparent)]",
|
||||
strong: "bg-[var(--color-border-strong)]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
tone: "subtle"
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Skeleton } from "./skeleton";
|
||||
|
||||
describe("Skeleton", () => {
|
||||
it("renders as an aria-hidden placeholder with root slot", () => {
|
||||
render(<Skeleton data-testid="skeleton" />);
|
||||
|
||||
const skeleton = screen.getByTestId("skeleton");
|
||||
|
||||
expect(skeleton).toHaveAttribute("data-slot", "root");
|
||||
expect(skeleton).toHaveAttribute("data-shape", "line");
|
||||
expect(skeleton).toHaveAttribute("data-tone", "default");
|
||||
expect(skeleton).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("supports alternate shape and tone hooks", () => {
|
||||
render(<Skeleton data-testid="skeleton" shape="avatar" tone="muted" />);
|
||||
|
||||
const skeleton = screen.getByTestId("skeleton");
|
||||
|
||||
expect(skeleton).toHaveAttribute("data-shape", "avatar");
|
||||
expect(skeleton).toHaveAttribute("data-tone", "muted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import { cva, type VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
const skeletonVariants = cva(
|
||||
[
|
||||
"relative overflow-hidden rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-surface)_74%,var(--color-border))]",
|
||||
"before:absolute before:inset-0 before:bg-[linear-gradient(110deg,transparent_0%,rgba(255,255,255,0.48)_42%,transparent_72%)] before:opacity-70 before:content-[''] before:animate-[aiui-skeleton-shimmer_1.8s_var(--ease-standard)_infinite]"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
shape: {
|
||||
line: "h-4 w-full",
|
||||
block: "h-24 w-full rounded-[var(--radius-md)]",
|
||||
pill: "h-10 w-32 rounded-[var(--radius-full)]",
|
||||
avatar: "size-12 rounded-[var(--radius-full)]"
|
||||
},
|
||||
tone: {
|
||||
default:
|
||||
"bg-[color-mix(in_oklch,var(--color-surface)_74%,var(--color-border))]",
|
||||
muted: "bg-[var(--color-muted)]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
shape: "line",
|
||||
tone: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export type SkeletonProps = React.ComponentPropsWithoutRef<"div"> &
|
||||
VariantProps<typeof skeletonVariants>;
|
||||
|
||||
export const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(function Skeleton(
|
||||
{
|
||||
className,
|
||||
shape = "line",
|
||||
tone = "default",
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({
|
||||
shape,
|
||||
tone
|
||||
})}
|
||||
aria-hidden="true"
|
||||
className={cn(skeletonVariants({ shape, tone }), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Spinner } from "./spinner";
|
||||
|
||||
describe("Spinner", () => {
|
||||
it("renders with icon slot and default aria-hidden semantics", () => {
|
||||
render(<Spinner data-testid="spinner" size="lg" tone="primary" />);
|
||||
|
||||
const spinner = screen.getByTestId("spinner");
|
||||
|
||||
expect(spinner).toHaveAttribute("data-slot", "icon");
|
||||
expect(spinner).toHaveAttribute("data-size", "lg");
|
||||
expect(spinner).toHaveAttribute("data-tone", "primary");
|
||||
expect(spinner).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("keeps an accessible label when one is provided", () => {
|
||||
render(<Spinner aria-label="Loading releases" />);
|
||||
|
||||
expect(screen.getByLabelText("Loading releases")).not.toHaveAttribute("aria-hidden");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import { cva, type VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
const spinnerVariants = cva(
|
||||
[
|
||||
"inline-block rounded-full border-current border-r-transparent align-middle",
|
||||
"animate-spin"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "size-3 border-[1.5px]",
|
||||
md: "size-4 border-2",
|
||||
lg: "size-5 border-2"
|
||||
},
|
||||
tone: {
|
||||
default: "text-[var(--color-muted-foreground)]",
|
||||
current: "text-current",
|
||||
primary: "text-[var(--color-primary)]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
tone: "current"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export type SpinnerProps = React.ComponentPropsWithoutRef<"span"> &
|
||||
VariantProps<typeof spinnerVariants>;
|
||||
|
||||
export const Spinner = forwardRef<HTMLSpanElement, SpinnerProps>(function Spinner(
|
||||
{
|
||||
className,
|
||||
size = "md",
|
||||
tone = "current",
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
{...createSlot("icon")}
|
||||
{...createDataAttributes({
|
||||
size,
|
||||
tone
|
||||
})}
|
||||
aria-hidden={props["aria-label"] ? undefined : true}
|
||||
className={cn(spinnerVariants({ size, tone }), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Switch } from "./switch";
|
||||
|
||||
describe("Switch", () => {
|
||||
it("toggles by click and exposes slot/state hooks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCheckedChange = vi.fn();
|
||||
|
||||
render(<Switch aria-label="Email alerts" onCheckedChange={onCheckedChange} />);
|
||||
|
||||
const control = screen.getByRole("switch", { name: "Email alerts" });
|
||||
|
||||
expect(control).toHaveAttribute("data-slot", "root");
|
||||
expect(control).toHaveAttribute("data-state", "unchecked");
|
||||
expect(control.querySelector('[data-slot="control"]')).not.toBeNull();
|
||||
|
||||
await user.click(control);
|
||||
|
||||
expect(control).toBeChecked();
|
||||
expect(control).toHaveAttribute("data-state", "checked");
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("supports controlled state updates", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function ControlledSwitch() {
|
||||
const [checked, setChecked] = useState(false);
|
||||
return <Switch aria-label="Controlled switch" checked={checked} onCheckedChange={setChecked} />;
|
||||
}
|
||||
|
||||
render(<ControlledSwitch />);
|
||||
|
||||
const control = screen.getByRole("switch", { name: "Controlled switch" });
|
||||
expect(control).not.toBeChecked();
|
||||
|
||||
await user.click(control);
|
||||
|
||||
expect(control).toBeChecked();
|
||||
});
|
||||
|
||||
it("supports keyboard interaction and disabled/invalid states", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<>
|
||||
<Switch aria-label="Keyboard switch" />
|
||||
<Switch aria-label="Disabled switch" disabled invalid />
|
||||
</>
|
||||
);
|
||||
|
||||
const keyboardSwitch = screen.getByRole("switch", { name: "Keyboard switch" });
|
||||
const disabledSwitch = screen.getByRole("switch", { name: "Disabled switch" });
|
||||
|
||||
await user.tab();
|
||||
expect(keyboardSwitch).toHaveFocus();
|
||||
|
||||
await user.keyboard(" ");
|
||||
expect(keyboardSwitch).toBeChecked();
|
||||
|
||||
expect(disabledSwitch).toBeDisabled();
|
||||
expect(disabledSwitch).toHaveAttribute("aria-invalid", "true");
|
||||
expect(disabledSwitch).toHaveAttribute("data-disabled", "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import { switchThumbVariants, switchVariants } from "./switch.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
export type SwitchProps = ComponentPropsWithoutRef<typeof SwitchPrimitive.Root> & {
|
||||
invalid?: boolean;
|
||||
};
|
||||
|
||||
export const Switch = forwardRef<
|
||||
ElementRef<typeof SwitchPrimitive.Root>,
|
||||
SwitchProps
|
||||
>(function Switch({ className, disabled, invalid, ...props }, ref) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
{...props}
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({
|
||||
disabled,
|
||||
invalid
|
||||
})}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(switchVariants(), className)}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
{...createSlot("control")}
|
||||
className={switchThumbVariants()}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const switchVariants = cva(
|
||||
[
|
||||
"inline-flex h-7 w-12 shrink-0 items-center rounded-full border border-transparent bg-[var(--color-border)] shadow-[var(--shadow-xs)] outline-none",
|
||||
"transition-[background-color,box-shadow] 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-[state=checked]:bg-[var(--color-primary)] data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45",
|
||||
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
|
||||
getMotionRecipeClassNames("ring")
|
||||
]
|
||||
);
|
||||
|
||||
export const switchThumbVariants = cva([
|
||||
"pointer-events-none block size-5 rounded-full bg-white shadow-[var(--shadow-xs)]",
|
||||
"translate-x-0.5 transition-transform duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
|
||||
"data-[state=checked]:translate-x-[1.55rem]"
|
||||
]);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs";
|
||||
|
||||
describe("Tabs", () => {
|
||||
it("switches content when a trigger is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList aria-label="Sections">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">Overview panel</TabsContent>
|
||||
<TabsContent value="activity">Activity panel</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Overview panel")).toHaveAttribute("data-slot", "content");
|
||||
expect(screen.queryByText("Activity panel")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Activity" }));
|
||||
|
||||
expect(screen.getByText("Activity panel")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Overview panel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves disabled triggers and root/list slots", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Tabs defaultValue="overview" orientation="vertical">
|
||||
<TabsList aria-label="Sections">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger disabled value="activity">
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">Overview panel</TabsContent>
|
||||
<TabsContent value="activity">Activity panel</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("tablist")).toHaveAttribute("data-slot", "list");
|
||||
expect(screen.getByRole("tablist").parentElement).toHaveAttribute("data-orientation", "vertical");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Activity" }));
|
||||
expect(screen.getByText("Overview panel")).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Activity" })).toHaveAttribute("data-disabled", "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import { tabsContentVariants, tabsListVariants, tabsTriggerVariants } from "./tabs.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
export function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
{...props}
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({ orientation })}
|
||||
className={cn("flex flex-col", className)}
|
||||
orientation={orientation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const TabsList = forwardRef<
|
||||
ElementRef<typeof TabsPrimitive.List>,
|
||||
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(function TabsList({ className, ...props }, ref) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
{...props}
|
||||
{...createSlot("list")}
|
||||
className={cn(tabsListVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const TabsTrigger = forwardRef<
|
||||
ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(function TabsTrigger({ className, disabled, ...props }, ref) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
{...props}
|
||||
{...createSlot("trigger")}
|
||||
{...createDataAttributes({ disabled })}
|
||||
className={cn(tabsTriggerVariants(), className)}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const TabsContent = forwardRef<
|
||||
ElementRef<typeof TabsPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(function TabsContent({ className, ...props }, ref) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
{...props}
|
||||
{...createSlot("content")}
|
||||
className={cn(tabsContentVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const tabsListVariants = cva([
|
||||
"inline-flex h-12 items-center gap-1 rounded-[var(--radius-full)] border border-[var(--color-border)] bg-[var(--color-surface)] p-1 shadow-[var(--shadow-xs)]"
|
||||
]);
|
||||
|
||||
export const tabsTriggerVariants = cva([
|
||||
"inline-flex min-w-[7rem] items-center justify-center rounded-[var(--radius-full)] 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)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45",
|
||||
"data-[state=active]:bg-[var(--color-card)] data-[state=active]:text-[var(--color-foreground)] data-[state=active]:shadow-[var(--shadow-xs)]",
|
||||
getMotionRecipeClassNames("ring")
|
||||
]);
|
||||
|
||||
export const tabsContentVariants = cva([
|
||||
"mt-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-card-foreground)] shadow-[var(--shadow-sm)] outline-none",
|
||||
"data-[state=active]:motion-enter-rise"
|
||||
]);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Field, FieldControl, FieldDescription, FieldError } from "./field";
|
||||
import { Label } from "./label";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
describe("Textarea", () => {
|
||||
it("renders the input slot and reflects explicit field state props", () => {
|
||||
render(
|
||||
<Textarea
|
||||
aria-label="Release summary"
|
||||
disabled
|
||||
invalid
|
||||
readOnly
|
||||
required
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox", { name: "Release summary" });
|
||||
|
||||
expect(textarea).toHaveAttribute("data-slot", "input");
|
||||
expect(textarea).toHaveAttribute("data-disabled", "");
|
||||
expect(textarea).toHaveAttribute("data-invalid", "");
|
||||
expect(textarea).toHaveAttribute("data-readonly", "");
|
||||
expect(textarea).toHaveAttribute("data-required", "");
|
||||
expect(textarea).toHaveAttribute("data-size", "sm");
|
||||
expect(textarea).toBeDisabled();
|
||||
expect(textarea).toHaveAttribute("aria-invalid", "true");
|
||||
expect(textarea).toHaveAttribute("readonly");
|
||||
});
|
||||
|
||||
it("inherits context wiring inside a field", () => {
|
||||
render(
|
||||
<Field invalid readOnly>
|
||||
<Label>Launch summary</Label>
|
||||
<FieldControl>
|
||||
<Textarea />
|
||||
<FieldDescription>Keep it concise for the changelog card.</FieldDescription>
|
||||
<FieldError>Summary needs more detail.</FieldError>
|
||||
</FieldControl>
|
||||
</Field>
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox", { name: "Launch summary" });
|
||||
const description = screen.getByText("Keep it concise for the changelog card.");
|
||||
const error = screen.getByText("Summary needs more detail.");
|
||||
|
||||
expect(textarea).toHaveAttribute("aria-describedby", `${description.id} ${error.id}`);
|
||||
expect(textarea).toHaveAttribute("aria-invalid", "true");
|
||||
expect(textarea).toHaveAttribute("readonly");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef
|
||||
} from "react";
|
||||
|
||||
import { textareaVariants } from "./textarea.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import type { FieldStateProps } from "../lib/contracts";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
import { useFieldContext } from "./field";
|
||||
|
||||
function mergeIds(...ids: Array<string | undefined>) {
|
||||
const value = ids.filter(Boolean).join(" ").trim();
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export type TextareaProps = ComponentPropsWithoutRef<"textarea"> &
|
||||
FieldStateProps &
|
||||
VariantProps<typeof textareaVariants>;
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
||||
{
|
||||
className,
|
||||
disabled,
|
||||
id,
|
||||
invalid,
|
||||
readOnly,
|
||||
required,
|
||||
size,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const field = useFieldContext();
|
||||
const resolvedDisabled = disabled ?? field?.disabled ?? false;
|
||||
const resolvedInvalid = invalid ?? field?.invalid ?? false;
|
||||
const resolvedReadOnly = readOnly ?? field?.readOnly ?? false;
|
||||
const resolvedRequired = required ?? field?.required ?? false;
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
{...createSlot("input")}
|
||||
{...createDataAttributes({
|
||||
disabled: resolvedDisabled,
|
||||
invalid: resolvedInvalid,
|
||||
readonly: resolvedReadOnly,
|
||||
required: resolvedRequired,
|
||||
size
|
||||
})}
|
||||
aria-describedby={mergeIds(
|
||||
props["aria-describedby"],
|
||||
field?.descriptionId,
|
||||
resolvedInvalid ? field?.errorId : undefined
|
||||
)}
|
||||
aria-invalid={resolvedInvalid || undefined}
|
||||
className={cn(textareaVariants({ size }), className)}
|
||||
disabled={resolvedDisabled}
|
||||
id={id ?? field?.inputId}
|
||||
readOnly={resolvedReadOnly}
|
||||
ref={ref}
|
||||
required={resolvedRequired}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const textareaVariants = cva(
|
||||
[
|
||||
"flex min-h-[8.75rem] w-full min-w-0 resize-y rounded-[var(--radius-md)] border border-[var(--color-input)] bg-[var(--color-card)] px-4 py-3",
|
||||
"text-[var(--color-foreground)] shadow-[var(--shadow-xs)] outline-none",
|
||||
"placeholder:text-[var(--color-muted-foreground)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
|
||||
"disabled:cursor-not-allowed disabled:bg-[var(--color-surface)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100",
|
||||
"read-only:bg-[var(--color-surface)] read-only:text-[var(--color-muted-foreground)]",
|
||||
"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")
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "min-h-[7.5rem] px-3 py-2.5 text-sm",
|
||||
md: "min-h-[8.75rem] px-4 py-3 text-sm",
|
||||
lg: "min-h-[10.5rem] px-4 py-3.5 text-base"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport
|
||||
} from "./toast";
|
||||
|
||||
function StatefulToast() {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Toast open={open} onOpenChange={setOpen}>
|
||||
<ToastTitle>Saved</ToastTitle>
|
||||
<ToastDescription>Launch details were updated.</ToastDescription>
|
||||
<ToastAction altText="Undo changes">Undo</ToastAction>
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Toast", () => {
|
||||
it("renders viewport, title, description, action, and variant hooks", () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Toast open variant="destructive">
|
||||
<ToastTitle>Delete failed</ToastTitle>
|
||||
<ToastDescription>Try again in a moment.</ToastDescription>
|
||||
<ToastAction altText="Retry delete">Retry</ToastAction>
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Delete failed").closest('[data-slot="label"]')).toBeInTheDocument();
|
||||
expect(screen.getByText("Try again in a moment.").closest('[data-slot="description"]')).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toHaveAttribute("data-slot", "action");
|
||||
expect(document.querySelector('[data-slot="root"]')).toHaveAttribute("data-variant", "destructive");
|
||||
expect(document.querySelector('[data-slot="viewport"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes through the close control", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StatefulToast />);
|
||||
|
||||
expect(screen.getByText("Saved")).toBeInTheDocument();
|
||||
|
||||
const closeButton = document.querySelector('[data-slot="close"]');
|
||||
|
||||
expect(closeButton).toBeInstanceOf(HTMLButtonElement);
|
||||
await user.click(closeButton as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Saved")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as ToastPrimitive from "@radix-ui/react-toast";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import { toastActionVariants, toastCloseVariants, toastVariants, toastViewportVariants } from "./toast.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
export const ToastProvider = ToastPrimitive.Provider;
|
||||
|
||||
export const ToastViewport = forwardRef<
|
||||
ElementRef<typeof ToastPrimitive.Viewport>,
|
||||
ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
|
||||
>(function ToastViewport({ className, ...props }, ref) {
|
||||
return (
|
||||
<ToastPrimitive.Viewport
|
||||
{...props}
|
||||
{...createSlot("viewport")}
|
||||
className={cn(toastViewportVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export type ToastProps = ComponentPropsWithoutRef<typeof ToastPrimitive.Root> &
|
||||
VariantProps<typeof toastVariants>;
|
||||
|
||||
export const Toast = forwardRef<
|
||||
ElementRef<typeof ToastPrimitive.Root>,
|
||||
ToastProps
|
||||
>(function Toast({ className, variant, ...props }, ref) {
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
{...props}
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({ variant })}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToastTitle = forwardRef<
|
||||
ElementRef<typeof ToastPrimitive.Title>,
|
||||
ComponentPropsWithoutRef<typeof ToastPrimitive.Title>
|
||||
>(function ToastTitle({ className, ...props }, ref) {
|
||||
return (
|
||||
<ToastPrimitive.Title
|
||||
{...props}
|
||||
{...createSlot("label")}
|
||||
className={cn("col-start-1 row-start-1 text-sm font-semibold", className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToastDescription = forwardRef<
|
||||
ElementRef<typeof ToastPrimitive.Description>,
|
||||
ComponentPropsWithoutRef<typeof ToastPrimitive.Description>
|
||||
>(function ToastDescription({ className, ...props }, ref) {
|
||||
return (
|
||||
<ToastPrimitive.Description
|
||||
{...props}
|
||||
{...createSlot("description")}
|
||||
className={cn("col-start-1 row-start-2 text-sm leading-6 text-[var(--color-muted-foreground)]", className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToastAction = forwardRef<
|
||||
ElementRef<typeof ToastPrimitive.Action>,
|
||||
ComponentPropsWithoutRef<typeof ToastPrimitive.Action>
|
||||
>(function ToastAction({ className, ...props }, ref) {
|
||||
return (
|
||||
<ToastPrimitive.Action
|
||||
{...props}
|
||||
{...createSlot("action")}
|
||||
className={cn(toastActionVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ToastClose = forwardRef<
|
||||
ElementRef<typeof ToastPrimitive.Close>,
|
||||
ComponentPropsWithoutRef<typeof ToastPrimitive.Close>
|
||||
>(function ToastClose({ children, className, ...props }, ref) {
|
||||
return (
|
||||
<ToastPrimitive.Close
|
||||
{...props}
|
||||
aria-label={props["aria-label"] ?? "Close notification"}
|
||||
{...createSlot("close")}
|
||||
className={cn(toastCloseVariants(), className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children ?? (
|
||||
<span aria-hidden="true" className="text-lg leading-none">
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</ToastPrimitive.Close>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const toastViewportVariants = cva([
|
||||
"fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-3 p-4 sm:bottom-4 sm:right-4 sm:max-w-[28rem]"
|
||||
]);
|
||||
|
||||
export const toastVariants = cva(
|
||||
[
|
||||
"group pointer-events-auto relative grid grid-cols-[1fr_auto] items-start gap-x-4 gap-y-2 overflow-hidden rounded-[var(--radius-lg)] border p-4 pr-12 shadow-[var(--shadow-md)]",
|
||||
"bg-[var(--color-card)] text-[var(--color-card-foreground)]",
|
||||
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop",
|
||||
"data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none",
|
||||
"data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-[transform] data-[swipe=cancel]:duration-[var(--dur-fast)]",
|
||||
"data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=end]:opacity-0",
|
||||
getMotionRecipeClassNames("ring")
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-[var(--color-border)]",
|
||||
success:
|
||||
"border-[color-mix(in_oklch,var(--color-success)_32%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-success)_10%,var(--color-card))]",
|
||||
destructive:
|
||||
"border-[color-mix(in_oklch,var(--color-destructive)_38%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-destructive)_10%,var(--color-card))]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const toastActionVariants = cva([
|
||||
"col-start-2 row-span-2 row-start-1 inline-flex h-9 shrink-0 items-center justify-center self-start rounded-[var(--radius-full)] border border-[var(--color-border-strong)] px-3 text-sm font-medium text-[var(--color-foreground)]",
|
||||
"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:bg-[var(--color-surface)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-card)]"
|
||||
]);
|
||||
|
||||
export const toastCloseVariants = cva([
|
||||
"absolute top-3 right-3 inline-flex size-8 items-center justify-center rounded-[var(--radius-full)] text-[var(--color-muted-foreground)] outline-none",
|
||||
"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:bg-[var(--color-surface)] hover:text-[var(--color-foreground)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-card)]"
|
||||
]);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./tooltip";
|
||||
|
||||
describe("Tooltip", () => {
|
||||
it("shows and hides tooltip content around hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>Help</TooltipTrigger>
|
||||
<TooltipContent size="lg">
|
||||
Helpful context
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
await user.hover(screen.getByRole("button", { name: "Help" }));
|
||||
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
const content = tooltip.closest('[data-slot="content"]');
|
||||
|
||||
expect(content).toHaveAttribute("data-size", "lg");
|
||||
expect(document.querySelector('[data-slot="arrow"]')).toBeInTheDocument();
|
||||
|
||||
await user.unhover(screen.getByRole("button", { name: "Help" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports controlled open changes from the trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip open={false} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger>Help</TooltipTrigger>
|
||||
<TooltipContent>Helpful context</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
await user.hover(screen.getByRole("button", { name: "Help" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
||||
|
||||
import { tooltipContentVariants } from "./tooltip.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
export const TooltipPortal = TooltipPrimitive.Portal;
|
||||
|
||||
export type TooltipContentProps = ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> &
|
||||
VariantProps<typeof tooltipContentVariants>;
|
||||
|
||||
export const TooltipContent = forwardRef<
|
||||
ElementRef<typeof TooltipPrimitive.Content>,
|
||||
TooltipContentProps
|
||||
>(function TooltipContent(
|
||||
{ className, sideOffset = 8, size, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<TooltipPortal>
|
||||
<TooltipPrimitive.Content
|
||||
{...props}
|
||||
{...createSlot("content")}
|
||||
{...createDataAttributes({ size })}
|
||||
className={cn(tooltipContentVariants({ size }), className)}
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
/>
|
||||
</TooltipPortal>
|
||||
);
|
||||
});
|
||||
|
||||
export const TooltipArrow = forwardRef<
|
||||
ElementRef<typeof TooltipPrimitive.Arrow>,
|
||||
ComponentPropsWithoutRef<typeof TooltipPrimitive.Arrow>
|
||||
>(function TooltipArrow({ className, ...props }, ref) {
|
||||
return (
|
||||
<TooltipPrimitive.Arrow
|
||||
{...props}
|
||||
{...createSlot("arrow")}
|
||||
className={cn("fill-[var(--color-surface-contrast)]", className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
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-[side=bottom]:origin-top data-[side=left]:origin-right data-[side=right]:origin-left data-[side=top]:origin-bottom"
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "max-w-[12rem]",
|
||||
md: "max-w-[16rem]",
|
||||
lg: "max-w-[20rem]"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,5 +1,161 @@
|
||||
export { Button, type ButtonProps } from "./components/button";
|
||||
export { buttonVariants } from "./components/button.variants";
|
||||
export { Checkbox, type CheckboxProps } from "./components/checkbox";
|
||||
export { checkboxVariants } from "./components/checkbox.variants";
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
type DialogContentProps
|
||||
} from "./components/dialog";
|
||||
export {
|
||||
dialogContentVariants,
|
||||
dialogFooterVariants,
|
||||
dialogHeaderVariants,
|
||||
dialogOverlayVariants
|
||||
} from "./components/dialog.variants";
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
type DropdownMenuCheckboxItemProps,
|
||||
type DropdownMenuContentProps,
|
||||
type DropdownMenuItemProps,
|
||||
type DropdownMenuLabelProps,
|
||||
type DropdownMenuRadioItemProps,
|
||||
type DropdownMenuSubContentProps,
|
||||
type DropdownMenuSubTriggerProps
|
||||
} from "./components/dropdown-menu";
|
||||
export {
|
||||
dropdownMenuContentVariants,
|
||||
dropdownMenuItemVariants,
|
||||
dropdownMenuLabelVariants,
|
||||
dropdownMenuSeparatorVariants
|
||||
} from "./components/dropdown-menu.variants";
|
||||
export {
|
||||
Field,
|
||||
FieldControl,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FormItem,
|
||||
useFieldIds,
|
||||
type FieldControlProps,
|
||||
type FieldDescriptionProps,
|
||||
type FieldErrorProps,
|
||||
type FieldProps,
|
||||
type FieldRenderProps
|
||||
} from "./components/field";
|
||||
export { Input, type InputProps } from "./components/input";
|
||||
export { inputVariants } from "./components/input.variants";
|
||||
export { Label, type LabelProps } from "./components/label";
|
||||
export {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverArrow,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverTrigger,
|
||||
type PopoverContentProps
|
||||
} from "./components/popover";
|
||||
export { popoverContentVariants } from "./components/popover.variants";
|
||||
export {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
type RadioGroupItemProps,
|
||||
type RadioGroupProps
|
||||
} from "./components/radio-group";
|
||||
export {
|
||||
radioGroupItemVariants,
|
||||
radioGroupVariants
|
||||
} from "./components/radio-group.variants";
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
type SelectTriggerProps
|
||||
} from "./components/select";
|
||||
export {
|
||||
selectContentVariants,
|
||||
selectItemVariants,
|
||||
selectLabelVariants,
|
||||
selectSeparatorVariants,
|
||||
selectTriggerVariants,
|
||||
selectViewportVariants
|
||||
} from "./components/select.variants";
|
||||
export { Separator, type SeparatorProps } from "./components/separator";
|
||||
export { separatorVariants } from "./components/separator.variants";
|
||||
export { Skeleton, type SkeletonProps } from "./components/skeleton";
|
||||
export { Spinner, type SpinnerProps } from "./components/spinner";
|
||||
export { Switch, type SwitchProps } from "./components/switch";
|
||||
export {
|
||||
switchThumbVariants,
|
||||
switchVariants
|
||||
} from "./components/switch.variants";
|
||||
export {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from "./components/tabs";
|
||||
export {
|
||||
tabsContentVariants,
|
||||
tabsListVariants,
|
||||
tabsTriggerVariants
|
||||
} from "./components/tabs.variants";
|
||||
export { Textarea, type TextareaProps } from "./components/textarea";
|
||||
export { textareaVariants } from "./components/textarea.variants";
|
||||
export {
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
type ToastProps
|
||||
} from "./components/toast";
|
||||
export {
|
||||
toastActionVariants,
|
||||
toastCloseVariants,
|
||||
toastVariants,
|
||||
toastViewportVariants
|
||||
} from "./components/toast.variants";
|
||||
export {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
type TooltipContentProps
|
||||
} from "./components/tooltip";
|
||||
export { tooltipContentVariants } from "./components/tooltip.variants";
|
||||
export { cn } from "./lib/cn";
|
||||
export { cva, cx, type VariantProps } from "./lib/cva";
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
class PointerEventMock extends MouseEvent {
|
||||
constructor(type: string, props: PointerEventInit = {}) {
|
||||
super(type, props);
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "ResizeObserver", {
|
||||
writable: true,
|
||||
value: ResizeObserverMock
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "PointerEvent", {
|
||||
writable: true,
|
||||
value: PointerEventMock
|
||||
});
|
||||
|
||||
Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn()
|
||||
});
|
||||
|
||||
Object.defineProperty(window.HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false)
|
||||
});
|
||||
|
||||
Object.defineProperty(window.HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn()
|
||||
});
|
||||
|
||||
Object.defineProperty(window.HTMLElement.prototype, "setPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn()
|
||||
});
|
||||
Reference in New Issue
Block a user