feat: add core UI components and baseline tests

This commit is contained in:
2026-03-19 16:56:27 +08:00
parent 12642e0a92
commit 063179933c
73 changed files with 5756 additions and 2 deletions
+19 -1
View File
@@ -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", "");
});
});
+37
View File
@@ -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"
]);
+108
View File
@@ -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();
});
});
+111
View File
@@ -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)]"
]);
+50
View File
@@ -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", "");
});
});
+174
View File
@@ -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;
}
+55
View File
@@ -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();
});
});
+66
View File
@@ -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"
}
}
);
+42
View File
@@ -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", "");
});
});
+56
View File
@@ -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);
});
});
+51
View File
@@ -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-['']"
]);
+131
View File
@@ -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", "");
});
});
+141
View File
@@ -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");
});
});
+38
View File
@@ -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");
});
});
+58
View File
@@ -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");
});
});
+57
View File
@@ -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", "");
});
});
+35
View File
@@ -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]"
]);
+54
View File
@@ -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", "");
});
});
+66
View File
@@ -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");
});
});
+66
View File
@@ -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"
}
}
);
+69
View File
@@ -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();
});
});
});
+104
View File
@@ -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);
});
});
});
+50
View File
@@ -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"
}
}
);
+156
View File
@@ -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 {
+64
View File
@@ -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()
});