feat: add sheet component and docs qa baseline
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]);
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user