feat: add empty state and expand overlay qa
This commit is contained in:
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user