feat: add empty state and expand overlay qa

This commit is contained in:
2026-03-19 19:00:36 +08:00
parent f318f94c9a
commit 132bb6961d
20 changed files with 1094 additions and 6 deletions
@@ -105,4 +105,29 @@ describe("Dialog", () => {
expect(within(dialog).getByText("Summary").closest('[data-slot="header"]')).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "Close" }).closest('[data-slot="footer"]')).toBeInTheDocument();
});
it("returns focus to the trigger after Escape closes the dialog", async () => {
const user = userEvent.setup();
render(
<Dialog>
<DialogTrigger>Open accessible dialog</DialogTrigger>
<DialogContent>
<DialogTitle>Accessibility</DialogTitle>
</DialogContent>
</Dialog>
);
const trigger = screen.getByRole("button", { name: "Open accessible dialog" });
await user.click(trigger);
expect(await screen.findByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
});
@@ -88,4 +88,31 @@ describe("DropdownMenu", () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
it("opens from the keyboard and returns focus to the trigger on Escape", async () => {
const user = userEvent.setup();
render(
<DropdownMenu>
<DropdownMenuTrigger>Keyboard menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Review</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const trigger = screen.getByRole("button", { name: "Keyboard menu" });
trigger.focus();
await user.keyboard("{Enter}");
expect(await screen.findByRole("menu")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
});
@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
import {
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateEyebrow,
EmptyStateHeader,
EmptyStateMedia,
EmptyStateTitle
} from "./empty-state";
describe("EmptyState", () => {
it("renders semantic slots and tone metadata", () => {
render(
<EmptyState tone="accent">
<EmptyStateMedia>0</EmptyStateMedia>
<EmptyStateHeader>
<EmptyStateEyebrow>Search</EmptyStateEyebrow>
<EmptyStateTitle>No matching releases</EmptyStateTitle>
<EmptyStateDescription>Try another filter or create a new release.</EmptyStateDescription>
</EmptyStateHeader>
<EmptyStateActions>
<Button size="sm">Create release</Button>
</EmptyStateActions>
</EmptyState>
);
const root = screen.getByText("No matching releases").closest('[data-slot="root"]');
expect(root).toHaveAttribute("data-tone", "accent");
expect(screen.getByText("0")).toHaveAttribute("data-slot", "media");
expect(screen.getByText("Search")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("No matching releases")).toHaveAttribute("data-slot", "label");
expect(screen.getByText("Try another filter or create a new release.")).toHaveAttribute(
"data-slot",
"description"
);
expect(screen.getByRole("button", { name: "Create release" }).closest('[data-slot="actions"]')).toBeInTheDocument();
});
it("supports className overrides on sub-slots", () => {
render(
<EmptyState data-testid="empty-state" tone="subtle">
<EmptyStateHeader className="items-start text-left">
<EmptyStateTitle className="text-left">No saved views</EmptyStateTitle>
</EmptyStateHeader>
</EmptyState>
);
expect(screen.getByTestId("empty-state")).toHaveAttribute("data-tone", "subtle");
expect(screen.getByText("No saved views")).toHaveClass("text-left");
expect(screen.getByText("No saved views").closest('[data-slot="header"]')).toHaveClass(
"items-start"
);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import {
emptyStateActionsVariants,
emptyStateDescriptionVariants,
emptyStateEyebrowVariants,
emptyStateHeaderVariants,
emptyStateMediaVariants,
emptyStateTitleVariants,
emptyStateVariants
} from "./empty-state.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type EmptyStateProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateVariants>;
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(
{ className, tone, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({ tone })}
className={cn(emptyStateVariants({ tone }), className)}
ref={ref}
/>
);
});
export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div">;
export const EmptyStateMedia = forwardRef<HTMLDivElement, EmptyStateMediaProps>(
function EmptyStateMedia({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("media")}
className={cn(emptyStateMediaVariants(), className)}
ref={ref}
/>
);
}
);
export type EmptyStateHeaderProps = ComponentPropsWithoutRef<"div">;
export const EmptyStateHeader = forwardRef<HTMLDivElement, EmptyStateHeaderProps>(
function EmptyStateHeader({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(emptyStateHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type EmptyStateEyebrowProps = ComponentPropsWithoutRef<"p">;
export const EmptyStateEyebrow = forwardRef<HTMLParagraphElement, EmptyStateEyebrowProps>(
function EmptyStateEyebrow({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("eyebrow")}
className={cn(emptyStateEyebrowVariants(), className)}
ref={ref}
/>
);
}
);
export type EmptyStateTitleProps = ComponentPropsWithoutRef<"h3">;
export const EmptyStateTitle = forwardRef<HTMLHeadingElement, EmptyStateTitleProps>(
function EmptyStateTitle({ className, ...props }, ref) {
return (
<h3
{...props}
{...createSlot("label")}
className={cn(emptyStateTitleVariants(), className)}
ref={ref}
/>
);
}
);
export type EmptyStateDescriptionProps = ComponentPropsWithoutRef<"p">;
export const EmptyStateDescription = forwardRef<
HTMLParagraphElement,
EmptyStateDescriptionProps
>(function EmptyStateDescription({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("description")}
className={cn(emptyStateDescriptionVariants(), className)}
ref={ref}
/>
);
});
export type EmptyStateActionsProps = ComponentPropsWithoutRef<"div">;
export const EmptyStateActions = forwardRef<HTMLDivElement, EmptyStateActionsProps>(
function EmptyStateActions({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
className={cn(emptyStateActionsVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,50 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const emptyStateVariants = cva(
[
"grid gap-6 rounded-[var(--radius-lg)] border p-8 shadow-[var(--shadow-sm)] sm:p-10",
"justify-items-center text-center text-[var(--color-card-foreground)]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default: "border-[var(--color-border)] bg-[var(--color-card)]",
subtle:
"border-[color-mix(in_oklch,var(--color-border)_82%,transparent)] bg-[var(--color-surface)] shadow-[var(--shadow-xs)]",
accent:
"border-[color-mix(in_oklch,var(--color-primary)_24%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_8%,var(--color-card))]"
}
},
defaultVariants: {
tone: "default"
}
}
);
export const emptyStateMediaVariants = cva(
[
"grid min-h-20 min-w-20 place-items-center rounded-[var(--radius-lg)] border p-4",
"border-[color-mix(in_oklch,var(--color-border)_88%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-background)_72%,white_28%),var(--color-surface))]",
"text-[var(--color-foreground)] shadow-[var(--shadow-xs)]"
]
);
export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2 justify-items-center");
export const emptyStateEyebrowVariants = cva(
"text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
);
export const emptyStateTitleVariants = cva(
"text-2xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
);
export const emptyStateDescriptionVariants = cva(
"max-w-[30rem] text-sm leading-6 text-[var(--color-muted-foreground)]"
);
export const emptyStateActionsVariants = cva(
"flex flex-wrap items-center justify-center gap-3"
);
@@ -54,4 +54,27 @@ describe("Popover", () => {
await user.click(screen.getByRole("button", { name: "Open details" }));
expect(onOpenChange).toHaveBeenCalledWith(true);
});
it("closes on Escape and returns focus to the trigger", async () => {
const user = userEvent.setup();
render(
<Popover>
<PopoverTrigger>Open details</PopoverTrigger>
<PopoverContent>Context</PopoverContent>
</Popover>
);
const trigger = screen.getByRole("button", { name: "Open details" });
await user.click(trigger);
expect(await screen.findByText("Context")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByText("Context")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
});
+25
View File
@@ -130,4 +130,29 @@ describe("Sheet", () => {
within(sheet).getByRole("button", { name: "Close" }).closest('[data-slot="footer"]')
).toBeInTheDocument();
});
it("returns focus to the trigger after Escape closes the sheet", async () => {
const user = userEvent.setup();
render(
<Sheet>
<SheetTrigger>Open accessible sheet</SheetTrigger>
<SheetContent side="right">
<SheetTitle>Accessibility</SheetTitle>
</SheetContent>
</Sheet>
);
const trigger = screen.getByRole("button", { name: "Open accessible sheet" });
await user.click(trigger);
expect(await screen.findByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
});
@@ -66,4 +66,12 @@ describe("Toast", () => {
expect(screen.queryByText("Saved")).not.toBeInTheDocument();
});
});
it("exposes the default close label for screen readers", () => {
render(<StatefulToast />);
const closeButton = screen.getByRole("button", { name: "Close notification" });
expect(closeButton).toHaveAttribute("data-slot", "close");
});
});
@@ -59,4 +59,30 @@ describe("Tooltip", () => {
expect(onOpenChange).toHaveBeenCalledWith(true);
});
});
it("shows tooltip content on focus and hides it on blur", async () => {
render(
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>Focus help</TooltipTrigger>
<TooltipContent>Focus context</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const trigger = screen.getByRole("button", { name: "Focus help" });
trigger.focus();
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent("Focus context");
expect(trigger).toHaveFocus();
trigger.blur();
await waitFor(() => {
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});
});
+25
View File
@@ -145,6 +145,31 @@ export {
dropdownMenuLabelVariants,
dropdownMenuSeparatorVariants
} from "./components/dropdown-menu.variants";
export {
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateEyebrow,
EmptyStateHeader,
EmptyStateMedia,
EmptyStateTitle,
type EmptyStateActionsProps,
type EmptyStateDescriptionProps,
type EmptyStateEyebrowProps,
type EmptyStateHeaderProps,
type EmptyStateMediaProps,
type EmptyStateProps,
type EmptyStateTitleProps
} from "./components/empty-state";
export {
emptyStateActionsVariants,
emptyStateDescriptionVariants,
emptyStateEyebrowVariants,
emptyStateHeaderVariants,
emptyStateMediaVariants,
emptyStateTitleVariants,
emptyStateVariants
} from "./components/empty-state.variants";
export {
Field,
FieldControl,