feat: add sheet component and docs qa baseline

This commit is contained in:
2026-03-19 18:46:20 +08:00
parent 71ebb010b9
commit f318f94c9a
28 changed files with 1799 additions and 91 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
import { Avatar, AvatarFallback } from "./avatar";
describe("Avatar", () => {
it("renders root slot metadata and fallback content", async () => {
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
import { setReducedMotionPreference } from "../test/a11y";
describe("Button", () => {
it("renders a native button with root and label slots", () => {
@@ -49,4 +50,16 @@ describe("Button", () => {
expect(link).toHaveAttribute("data-variant", "ghost");
expect(link).not.toHaveAttribute("type");
});
it("preserves the loading contract when reduced motion is preferred", () => {
setReducedMotionPreference(true);
render(<Button loading>Saving</Button>);
const button = screen.getByRole("button", { name: "Saving" });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("data-loading", "");
expect(button.querySelector('[data-slot="icon"]')).toBeInTheDocument();
});
});
+13 -4
View File
@@ -104,6 +104,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
ref
) {
const field = useFieldContext();
const reactId = useId();
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(defaultSearchValue);
@@ -115,7 +116,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
const resolvedSearchValue = searchValue ?? uncontrolledSearchValue;
const resolvedDisabled = disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? field?.invalid ?? false;
const controlId = id ?? props.name ?? field?.inputId ?? `combobox-${useId().replace(/:/g, "")}`;
const controlId = id ?? props.name ?? field?.inputId ?? `combobox-${reactId.replace(/:/g, "")}`;
const describedBy = mergeIds(
typeof props["aria-describedby"] === "string" ? props["aria-describedby"] : undefined,
field?.descriptionId,
@@ -212,7 +213,13 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
const firstEnabledIndex = filteredItems.findIndex((item) => !item.disabled);
const nextIndex = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex;
setActiveIndex(nextIndex);
const frame = requestAnimationFrame(() => {
setActiveIndex(nextIndex);
});
return () => {
cancelAnimationFrame(frame);
};
}, [filteredItems, resolvedOpen, resolvedValue]);
useEffect(() => {
@@ -223,6 +230,10 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
itemRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
useEffect(() => {
itemRefs.current = [];
}, [filteredItems]);
const handleSelect = (item: ComboboxItem) => {
if (item.disabled) {
return;
@@ -273,8 +284,6 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
}
};
itemRefs.current = [];
return (
<div
{...createSlot("root")}
+45
View File
@@ -193,4 +193,49 @@ describe("Form", () => {
expect(await screen.findByText("Saved successfully.")).toBeInTheDocument();
});
it("merges caller-provided aria-describedby ids with generated field messaging", async () => {
const user = userEvent.setup();
function DescribedByPreview() {
const form = useForm<{ email: string }>({
defaultValues: {
email: ""
}
});
return (
<Form {...form}>
<form noValidate onSubmit={form.handleSubmit(() => undefined)}>
<FormItem name="email">
<FormLabel>Email address</FormLabel>
<FormControl aria-describedby="supporting-note">
<Input
{...form.register("email", {
required: "Email is required."
})}
/>
</FormControl>
<FormDescription>We send launch notes here.</FormDescription>
<FormMessage />
</FormItem>
<p id="supporting-note">Primary contact for release notifications.</p>
<Button type="submit">Save</Button>
</form>
</Form>
);
}
render(<DescribedByPreview />);
await user.click(screen.getByRole("button", { name: "Save" }));
const input = screen.getByRole("textbox", { name: "Email address" });
const description = screen.getByText("We send launch notes here.");
const message = await screen.findByText("Email is required.");
expect(input).toHaveAttribute("aria-describedby", expect.stringContaining("supporting-note"));
expect(input).toHaveAttribute("aria-describedby", expect.stringContaining(description.id));
expect(input).toHaveAttribute("aria-describedby", expect.stringContaining(message.id));
});
});
@@ -128,4 +128,20 @@ describe("Select", () => {
expect(trigger).toBeDisabled();
expect(trigger).toHaveAttribute("data-disabled", "");
});
it("opens from the keyboard so combobox interaction stays accessible", async () => {
const user = userEvent.setup();
render(<ReviewLaneSelect defaultValue="editorial" />);
const trigger = screen.getByRole("combobox", { name: "Review lane" });
trigger.focus();
await user.keyboard("{ArrowDown}");
const listbox = await screen.findByRole("listbox");
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(within(listbox).getByRole("option", { name: "Editorial review" })).toBeInTheDocument();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger
} from "./sheet";
describe("Sheet", () => {
it("opens from the trigger and closes from the close control", async () => {
const user = userEvent.setup();
render(
<Sheet>
<SheetTrigger>Open sheet</SheetTrigger>
<SheetContent side="right" size="lg">
<SheetHeader>
<SheetTitle>Review launch</SheetTitle>
<SheetDescription>Confirm the rollout details before publishing.</SheetDescription>
</SheetHeader>
<SheetFooter>
<SheetClose>Done</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open sheet" }));
const sheet = await screen.findByRole("dialog");
expect(sheet).toHaveAttribute("data-slot", "content");
expect(sheet).toHaveAttribute("data-side", "right");
expect(sheet).toHaveAttribute("data-size", "lg");
expect(screen.getByText("Review launch")).toHaveAttribute("data-slot", "label");
expect(screen.getByText("Confirm the rollout details before publishing.")).toHaveAttribute(
"data-slot",
"description"
);
expect(document.querySelector('[data-slot="overlay"]')).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close sheet" }));
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(
<Sheet open={false} onOpenChange={onOpenChange}>
<SheetTrigger>Open controlled sheet</SheetTrigger>
<SheetContent side="left">
<SheetTitle>Controlled</SheetTitle>
</SheetContent>
</Sheet>
);
await user.click(screen.getByRole("button", { name: "Open controlled sheet" }));
expect(onOpenChange).toHaveBeenCalledWith(true);
render(
<Sheet open onOpenChange={onOpenChange}>
<SheetTrigger>Open controlled sheet</SheetTrigger>
<SheetContent side="left">
<SheetTitle>Controlled</SheetTitle>
</SheetContent>
</Sheet>
);
await user.keyboard("{Escape}");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("supports bottom sheets while remaining accessible as a dialog", async () => {
const user = userEvent.setup();
render(
<Sheet>
<SheetTrigger>Open mobile actions</SheetTrigger>
<SheetContent side="bottom">
<SheetHeader>
<SheetTitle>Mobile actions</SheetTitle>
<SheetDescription>Choose how to continue this rollout.</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
);
await user.click(screen.getByRole("button", { name: "Open mobile actions" }));
const sheet = await screen.findByRole("dialog", { name: "Mobile actions" });
expect(sheet).toHaveAttribute("data-side", "bottom");
expect(sheet).toHaveAttribute("data-size", "md");
});
it("renders header and footer slots when provided", async () => {
const user = userEvent.setup();
render(
<Sheet>
<SheetTrigger>Open summary sheet</SheetTrigger>
<SheetContent side="left" size="sm">
<SheetHeader>
<SheetTitle>Summary</SheetTitle>
</SheetHeader>
<SheetFooter>
<SheetClose>Close</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
await user.click(screen.getByRole("button", { name: "Open summary sheet" }));
const sheet = await screen.findByRole("dialog");
expect(within(sheet).getByText("Summary").closest('[data-slot="header"]')).toBeInTheDocument();
expect(
within(sheet).getByRole("button", { name: "Close" }).closest('[data-slot="footer"]')
).toBeInTheDocument();
});
});
+128
View File
@@ -0,0 +1,128 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import {
sheetContentVariants,
sheetFooterVariants,
sheetHeaderVariants,
sheetOverlayVariants
} from "./sheet.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export const Sheet = DialogPrimitive.Root;
export const SheetTrigger = DialogPrimitive.Trigger;
export const SheetPortal = DialogPrimitive.Portal;
export const SheetClose = DialogPrimitive.Close;
export const SheetOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(function SheetOverlay({ className, ...props }, ref) {
return (
<DialogPrimitive.Overlay
{...props}
{...createSlot("overlay")}
className={cn(sheetOverlayVariants(), className)}
ref={ref}
/>
);
});
export type SheetContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
VariantProps<typeof sheetContentVariants>;
export const SheetContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(function SheetContent({ children, className, side, size, ...props }, ref) {
const resolvedSide = side ?? "right";
const resolvedSize = size ?? "md";
return (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
{...props}
{...createSlot("content")}
{...createDataAttributes({
side: resolvedSide,
size: resolvedSize
})}
className={cn(
sheetContentVariants({
side: resolvedSide,
size: resolvedSize
}),
className
)}
ref={ref}
>
{children}
<DialogPrimitive.Close
aria-label="Close sheet"
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>
</SheetPortal>
);
});
export function SheetHeader({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(sheetHeaderVariants(), className)}
/>
);
}
export function SheetFooter({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(sheetFooterVariants(), className)}
/>
);
}
export const SheetTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(function SheetTitle({ className, ...props }, ref) {
return (
<DialogPrimitive.Title
{...props}
{...createSlot("label")}
className={cn("pr-10 text-xl font-semibold tracking-tight", className)}
ref={ref}
/>
);
});
export const SheetDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(function SheetDescription({ 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,79 @@
import { cva } from "../lib/cva";
import { dialogOverlayVariants } from "./dialog.variants";
export const sheetOverlayVariants = dialogOverlayVariants;
export const sheetContentVariants = cva(
[
"fixed z-50 grid gap-5 overflow-y-auto",
"border bg-[var(--color-card)] text-[var(--color-card-foreground)] shadow-[var(--shadow-md)] outline-none",
"transition-[transform,opacity,box-shadow] duration-[var(--dur-slow)] ease-[var(--ease-emphasized)]",
"data-[state=open]:opacity-100 data-[state=closed]:opacity-0"
],
{
variants: {
side: {
right: [
"inset-y-0 right-0 h-full rounded-l-[var(--radius-lg)] border-l border-y-0 border-r-0",
"data-[state=open]:translate-x-0 data-[state=closed]:translate-x-full"
],
left: [
"inset-y-0 left-0 h-full rounded-r-[var(--radius-lg)] border-r border-y-0 border-l-0",
"data-[state=open]:translate-x-0 data-[state=closed]:-translate-x-full"
],
bottom: [
"bottom-0 left-1/2 max-h-[min(85vh,42rem)] w-[min(calc(100vw-1rem),52rem)] -translate-x-1/2",
"rounded-t-[var(--radius-lg)] border-b-0",
"data-[state=open]:translate-y-0 data-[state=closed]:translate-y-full"
]
},
size: {
sm: "",
md: "",
lg: ""
}
},
compoundVariants: [
{
side: ["left", "right"],
size: "sm",
class: "w-[min(calc(100vw-1rem),22rem)]"
},
{
side: ["left", "right"],
size: "md",
class: "w-[min(calc(100vw-1rem),28rem)]"
},
{
side: ["left", "right"],
size: "lg",
class: "w-[min(calc(100vw-1rem),36rem)]"
},
{
side: "bottom",
size: "sm",
class: "pb-5 px-5 pt-6 sm:px-6"
},
{
side: "bottom",
size: "md",
class: "pb-5 px-5 pt-6 sm:px-6"
},
{
side: "bottom",
size: "lg",
class: "pb-6 px-5 pt-6 sm:px-6"
}
],
defaultVariants: {
side: "right",
size: "md"
}
}
);
export const sheetHeaderVariants = cva(["flex flex-col gap-2 text-left"]);
export const sheetFooterVariants = cva([
"flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
]);
+19
View File
@@ -226,6 +226,25 @@ export {
} from "./components/select.variants";
export { Separator, type SeparatorProps } from "./components/separator";
export { separatorVariants } from "./components/separator.variants";
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
type SheetContentProps
} from "./components/sheet";
export {
sheetContentVariants,
sheetFooterVariants,
sheetHeaderVariants,
sheetOverlayVariants
} from "./components/sheet.variants";
export { Skeleton, type SkeletonProps } from "./components/skeleton";
export { Spinner, type SpinnerProps } from "./components/spinner";
export { Switch, type SwitchProps } from "./components/switch";
+15
View File
@@ -0,0 +1,15 @@
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
let prefersReducedMotion = false;
export function matchesMediaQuery(query: string) {
return query === REDUCED_MOTION_QUERY ? prefersReducedMotion : false;
}
export function resetAccessibilityPreferences() {
prefersReducedMotion = false;
}
export function setReducedMotionPreference(nextValue: boolean) {
prefersReducedMotion = nextValue;
}
+4 -1
View File
@@ -3,7 +3,10 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { matchesMediaQuery, resetAccessibilityPreferences } from "./a11y";
afterEach(() => {
resetAccessibilityPreferences();
cleanup();
});
@@ -22,7 +25,7 @@ class PointerEventMock extends MouseEvent {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
matches: matchesMediaQuery(query),
media: query,
onchange: null,
addEventListener: vi.fn(),