mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 09:24:54 +10:00
test: add unit and component tests across the monorepo
Adds ~1000 tests to bring the previously-untested packages and apps under coverage: - packages/utils — string, color, date, file, level, locale, sanitize, field, html, network-icons, rate-limit, url, url-security, monorepo, resume/patch, resume/docx/link-utils, style helpers (~97% on the testable utility files) - packages/ui — 28 component test files plus the use-controlled-state, use-mobile, use-confirm, use-prompt hooks (95% statements) - packages/pdf — shared template helpers (filtering, columns, picture, metrics, section-links, page-size, rich-text-html, section-title) - packages/schema — resumeDataSchema, page, templates, default - packages/fonts — expanded coverage on font helpers - packages/ai — resume sanitize and extraction template - packages/api — resume-access-policy - apps/web — error-message, locale, theme, pwa, dialogs/store, and the resume/move-item / section-actions / make-section-item helpers Adds jsdom polyfills (ResizeObserver, IntersectionObserver, scrollIntoView, matchMedia) and an explicit React Testing Library cleanup hook to vitest.setup.ts so portal- and overlay-based components work without per-test setup.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion";
|
||||
|
||||
describe("Accordion", () => {
|
||||
it("renders with data-slot='accordion'", () => {
|
||||
render(
|
||||
<Accordion data-testid="acc">
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Q1</AccordionTrigger>
|
||||
<AccordionContent>A1</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByTestId("acc")).toHaveAttribute("data-slot", "accordion");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Accordion data-testid="acc" className="my-class">
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Q</AccordionTrigger>
|
||||
<AccordionContent>A</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByTestId("acc")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("expands and collapses on trigger click", async () => {
|
||||
render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Question 1</AccordionTrigger>
|
||||
<AccordionContent>Answer 1</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByText("Question 1");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
await userEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
await userEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders multiple items", () => {
|
||||
render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Q1</AccordionTrigger>
|
||||
<AccordionContent>A1</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="2">
|
||||
<AccordionTrigger>Q2</AccordionTrigger>
|
||||
<AccordionContent>A2</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Q2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccordionItem", () => {
|
||||
it("uses data-slot='accordion-item'", () => {
|
||||
render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1" data-testid="item">
|
||||
<AccordionTrigger>Q</AccordionTrigger>
|
||||
<AccordionContent>A</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByTestId("item")).toHaveAttribute("data-slot", "accordion-item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccordionTrigger", () => {
|
||||
it("uses data-slot='accordion-trigger'", () => {
|
||||
render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger data-testid="trigger">Q</AccordionTrigger>
|
||||
<AccordionContent>A</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "accordion-trigger");
|
||||
});
|
||||
|
||||
it("renders down/up caret icons", () => {
|
||||
const { container } = render(
|
||||
<Accordion>
|
||||
<AccordionItem value="1">
|
||||
<AccordionTrigger>Q</AccordionTrigger>
|
||||
<AccordionContent>A</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>,
|
||||
);
|
||||
expect(container.querySelectorAll("[data-slot=accordion-trigger-icon]")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "./alert-dialog";
|
||||
|
||||
describe("AlertDialog", () => {
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger>Open</AlertDialogTrigger>
|
||||
<AlertDialogContent>Hidden body</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens via defaultOpen", () => {
|
||||
render(
|
||||
<AlertDialog defaultOpen>
|
||||
<AlertDialogTrigger>Open</AlertDialogTrigger>
|
||||
<AlertDialogContent>Visible body</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("Visible body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trigger has data-slot='alert-dialog-trigger'", () => {
|
||||
render(
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger data-testid="trigger">Open</AlertDialogTrigger>
|
||||
<AlertDialogContent>x</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "alert-dialog-trigger");
|
||||
});
|
||||
|
||||
it("content uses size 'default' by default", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent>visible</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveAttribute("data-size", "default");
|
||||
});
|
||||
|
||||
it("content supports size='sm'", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent size="sm">visible</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveAttribute("data-size", "sm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertDialog subcomponents", () => {
|
||||
it("AlertDialogHeader uses data-slot='alert-dialog-header'", () => {
|
||||
render(<AlertDialogHeader data-testid="h">x</AlertDialogHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "alert-dialog-header");
|
||||
});
|
||||
|
||||
it("AlertDialogFooter uses data-slot='alert-dialog-footer'", () => {
|
||||
render(<AlertDialogFooter data-testid="f">x</AlertDialogFooter>);
|
||||
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "alert-dialog-footer");
|
||||
});
|
||||
|
||||
it("AlertDialogMedia uses data-slot='alert-dialog-media'", () => {
|
||||
render(<AlertDialogMedia data-testid="m">x</AlertDialogMedia>);
|
||||
expect(screen.getByTestId("m")).toHaveAttribute("data-slot", "alert-dialog-media");
|
||||
});
|
||||
|
||||
it("AlertDialogTitle uses data-slot='alert-dialog-title'", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("Are you sure?")).toHaveAttribute("data-slot", "alert-dialog-title");
|
||||
});
|
||||
|
||||
it("AlertDialogDescription uses data-slot='alert-dialog-description'", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogDescription>This is permanent</AlertDialogDescription>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
expect(screen.getByText("This is permanent")).toHaveAttribute("data-slot", "alert-dialog-description");
|
||||
});
|
||||
|
||||
it("AlertDialogAction renders a Button with data-slot='alert-dialog-action'", () => {
|
||||
render(<AlertDialogAction>Confirm</AlertDialogAction>);
|
||||
const button = screen.getByText("Confirm");
|
||||
expect(button).toHaveAttribute("data-slot", "alert-dialog-action");
|
||||
});
|
||||
|
||||
it("AlertDialogCancel uses data-slot='alert-dialog-cancel'", () => {
|
||||
render(
|
||||
<AlertDialog open>
|
||||
<AlertDialogTrigger>x</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>,
|
||||
);
|
||||
const button = screen.getByText("Cancel");
|
||||
expect(button).toHaveAttribute("data-slot", "alert-dialog-cancel");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./alert";
|
||||
|
||||
describe("Alert", () => {
|
||||
it("renders children with role='alert'", () => {
|
||||
render(<Alert>Heads up!</Alert>);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Heads up!");
|
||||
});
|
||||
|
||||
it("applies data-slot='alert' for slotting", () => {
|
||||
render(<Alert>x</Alert>);
|
||||
expect(screen.getByRole("alert")).toHaveAttribute("data-slot", "alert");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Alert className="my-class">x</Alert>);
|
||||
expect(screen.getByRole("alert")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("supports destructive variant", () => {
|
||||
render(<Alert variant="destructive">x</Alert>);
|
||||
// We just verify the component renders without error
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertTitle", () => {
|
||||
it("renders children", () => {
|
||||
render(<AlertTitle>Title</AlertTitle>);
|
||||
expect(screen.getByText("Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies data-slot='alert-title'", () => {
|
||||
render(<AlertTitle>x</AlertTitle>);
|
||||
expect(screen.getByText("x")).toHaveAttribute("data-slot", "alert-title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertDescription", () => {
|
||||
it("renders children", () => {
|
||||
render(<AlertDescription>Description text</AlertDescription>);
|
||||
expect(screen.getByText("Description text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies data-slot='alert-description'", () => {
|
||||
render(<AlertDescription>x</AlertDescription>);
|
||||
expect(screen.getByText("x")).toHaveAttribute("data-slot", "alert-description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertAction", () => {
|
||||
it("renders children", () => {
|
||||
render(<AlertAction>Action</AlertAction>);
|
||||
expect(screen.getByText("Action")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies data-slot='alert-action'", () => {
|
||||
render(<AlertAction>x</AlertAction>);
|
||||
expect(screen.getByText("x")).toHaveAttribute("data-slot", "alert-action");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Alert composition", () => {
|
||||
it("composes all subcomponents", () => {
|
||||
render(
|
||||
<Alert>
|
||||
<AlertTitle>Title</AlertTitle>
|
||||
<AlertDescription>Body</AlertDescription>
|
||||
<AlertAction>OK</AlertAction>
|
||||
</Alert>,
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/TitleBodyOK/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from "./avatar";
|
||||
|
||||
describe("Avatar", () => {
|
||||
it("renders with data-slot='avatar'", () => {
|
||||
render(<Avatar data-testid="av" />);
|
||||
expect(screen.getByTestId("av")).toHaveAttribute("data-slot", "avatar");
|
||||
});
|
||||
|
||||
it("defaults size to 'default'", () => {
|
||||
render(<Avatar data-testid="av" />);
|
||||
expect(screen.getByTestId("av")).toHaveAttribute("data-size", "default");
|
||||
});
|
||||
|
||||
it.each(["default", "sm", "lg"] as const)("supports size=%s", (size) => {
|
||||
render(<Avatar data-testid="av" size={size} />);
|
||||
expect(screen.getByTestId("av")).toHaveAttribute("data-size", size);
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Avatar data-testid="av" className="my-class" />);
|
||||
expect(screen.getByTestId("av")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarFallback", () => {
|
||||
it("renders fallback content", () => {
|
||||
render(
|
||||
<Avatar>
|
||||
<AvatarFallback data-testid="fb">JD</AvatarFallback>
|
||||
</Avatar>,
|
||||
);
|
||||
// Note: fallback only renders if image fails — but data-slot should be set.
|
||||
// We just confirm no crash and that fallback is present in DOM.
|
||||
const fb = screen.queryByTestId("fb");
|
||||
// AvatarFallback may or may not be in the DOM depending on image state.
|
||||
// Just ensure it doesn't throw on render.
|
||||
expect(fb === null || fb.getAttribute("data-slot") === "avatar-fallback").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarBadge", () => {
|
||||
it("renders as span with data-slot='avatar-badge'", () => {
|
||||
render(<AvatarBadge data-testid="b" />);
|
||||
const badge = screen.getByTestId("b");
|
||||
expect(badge.tagName).toBe("SPAN");
|
||||
expect(badge).toHaveAttribute("data-slot", "avatar-badge");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarGroup", () => {
|
||||
it("renders as div with data-slot='avatar-group'", () => {
|
||||
render(<AvatarGroup data-testid="g" />);
|
||||
const group = screen.getByTestId("g");
|
||||
expect(group.tagName).toBe("DIV");
|
||||
expect(group).toHaveAttribute("data-slot", "avatar-group");
|
||||
});
|
||||
|
||||
it("supports children", () => {
|
||||
render(
|
||||
<AvatarGroup data-testid="g">
|
||||
<Avatar />
|
||||
<Avatar />
|
||||
</AvatarGroup>,
|
||||
);
|
||||
expect(screen.getByTestId("g").children).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarGroupCount", () => {
|
||||
it("renders count children", () => {
|
||||
render(<AvatarGroupCount>+3</AvatarGroupCount>);
|
||||
expect(screen.getByText("+3")).toHaveAttribute("data-slot", "avatar-group-count");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AvatarImage", () => {
|
||||
it("accepts src prop without throwing", () => {
|
||||
render(
|
||||
<Avatar>
|
||||
<AvatarImage src="https://example.com/x.png" alt="user" />
|
||||
</Avatar>,
|
||||
);
|
||||
// Image may not render until loaded; we just verify no crash
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
describe("Badge", () => {
|
||||
it("renders children inside a span by default", () => {
|
||||
render(<Badge>New</Badge>);
|
||||
const badge = screen.getByText("New");
|
||||
expect(badge.tagName).toBe("SPAN");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Badge className="my-extra">x</Badge>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-extra");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["default"],
|
||||
["secondary"],
|
||||
["destructive"],
|
||||
["outline"],
|
||||
["ghost"],
|
||||
["link"],
|
||||
] as const)("renders variant=%s without throwing", (variant) => {
|
||||
render(<Badge variant={variant}>{variant}</Badge>);
|
||||
expect(screen.getByText(variant)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards aria attributes", () => {
|
||||
render(<Badge aria-label="status indicator">3</Badge>);
|
||||
expect(screen.getByLabelText("status indicator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports a custom render function", () => {
|
||||
render(<Badge render={(props) => <a {...props} href="/x" />}>link</Badge>);
|
||||
const anchor = screen.getByRole("link", { name: "link" });
|
||||
expect(anchor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BrandIcon } from "./brand-icon";
|
||||
|
||||
describe("BrandIcon", () => {
|
||||
it("renders two img elements (light + dark variants)", () => {
|
||||
render(<BrandIcon />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
expect(imgs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses 'logo' as default variant", () => {
|
||||
render(<BrandIcon />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
expect(imgs.some((img) => img.getAttribute("src") === "/logo/dark.svg")).toBe(true);
|
||||
expect(imgs.some((img) => img.getAttribute("src") === "/logo/light.svg")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses 'icon' variant when specified", () => {
|
||||
render(<BrandIcon variant="icon" />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
expect(imgs.some((img) => img.getAttribute("src") === "/icon/dark.svg")).toBe(true);
|
||||
expect(imgs.some((img) => img.getAttribute("src") === "/icon/light.svg")).toBe(true);
|
||||
});
|
||||
|
||||
it("merges custom className on both imgs", () => {
|
||||
render(<BrandIcon className="my-custom" />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
for (const img of imgs) {
|
||||
expect(img).toHaveClass("my-custom");
|
||||
}
|
||||
});
|
||||
|
||||
it("hides dark variant by default (light mode); dark mode reveals it via dark:block", () => {
|
||||
render(<BrandIcon />);
|
||||
const imgs = screen.getAllByAltText("Reactive Resume");
|
||||
const darkImg = imgs.find((img) => img.getAttribute("src") === "/logo/dark.svg");
|
||||
expect(darkImg).toHaveClass("hidden");
|
||||
expect(darkImg).toHaveClass("dark:block");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from "./button-group";
|
||||
|
||||
describe("ButtonGroup", () => {
|
||||
it("renders a fieldset with data-slot='button-group'", () => {
|
||||
render(<ButtonGroup data-testid="g" />);
|
||||
const g = screen.getByTestId("g");
|
||||
expect(g.tagName).toBe("FIELDSET");
|
||||
expect(g).toHaveAttribute("data-slot", "button-group");
|
||||
});
|
||||
|
||||
it("respects horizontal orientation (default)", () => {
|
||||
render(<ButtonGroup data-testid="g" orientation="horizontal" />);
|
||||
expect(screen.getByTestId("g")).toHaveAttribute("data-orientation", "horizontal");
|
||||
});
|
||||
|
||||
it("respects vertical orientation", () => {
|
||||
render(<ButtonGroup data-testid="g" orientation="vertical" />);
|
||||
expect(screen.getByTestId("g")).toHaveAttribute("data-orientation", "vertical");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<ButtonGroup data-testid="g" className="my-custom" />);
|
||||
expect(screen.getByTestId("g")).toHaveClass("my-custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ButtonGroupText", () => {
|
||||
it("renders content inside a div by default", () => {
|
||||
render(<ButtonGroupText>Hello</ButtonGroupText>);
|
||||
expect(screen.getByText("Hello").tagName).toBe("DIV");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<ButtonGroupText className="my-class">x</ButtonGroupText>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("supports custom render function", () => {
|
||||
render(<ButtonGroupText render={(props) => <span {...props} />}>label</ButtonGroupText>);
|
||||
expect(screen.getByText("label").tagName).toBe("SPAN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ButtonGroupSeparator", () => {
|
||||
it("defaults to vertical orientation", () => {
|
||||
render(<ButtonGroupSeparator data-testid="sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "vertical");
|
||||
});
|
||||
|
||||
it("supports horizontal orientation", () => {
|
||||
render(<ButtonGroupSeparator data-testid="sep" orientation="horizontal" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "horizontal");
|
||||
});
|
||||
|
||||
it("uses data-slot='button-group-separator'", () => {
|
||||
render(<ButtonGroupSeparator data-testid="sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-slot", "button-group-separator");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
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 children", () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to type='button' to prevent accidental form submits", () => {
|
||||
render(<Button>Submit</Button>);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("type", "button");
|
||||
});
|
||||
|
||||
it("respects an explicit type override", () => {
|
||||
render(<Button type="submit">Submit</Button>);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("type", "submit");
|
||||
});
|
||||
|
||||
it("applies the data-slot attribute for shadcn/base-ui slotting", () => {
|
||||
render(<Button>x</Button>);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("data-slot", "button");
|
||||
});
|
||||
|
||||
it("merges custom className with variant classes", () => {
|
||||
render(<Button className="my-custom-class">x</Button>);
|
||||
expect(screen.getByRole("button")).toHaveClass("my-custom-class");
|
||||
});
|
||||
|
||||
it("calls onClick when clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Click</Button>);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not fire onClick when disabled", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Click
|
||||
</Button>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders disabled state", () => {
|
||||
render(<Button disabled>x</Button>);
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["default"],
|
||||
["outline"],
|
||||
["secondary"],
|
||||
["ghost"],
|
||||
["destructive"],
|
||||
["link"],
|
||||
] as const)("renders variant=%s without throwing", (variant) => {
|
||||
render(<Button variant={variant}>x</Button>);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["default"],
|
||||
["xs"],
|
||||
["sm"],
|
||||
["lg"],
|
||||
["icon"],
|
||||
["icon-xs"],
|
||||
["icon-sm"],
|
||||
["icon-lg"],
|
||||
] as const)("renders size=%s without throwing", (size) => {
|
||||
render(<Button size={size}>x</Button>);
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards aria-label", () => {
|
||||
render(<Button aria-label="Close">×</Button>);
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./card";
|
||||
|
||||
describe("Card", () => {
|
||||
it("renders with data-slot='card'", () => {
|
||||
render(<Card data-testid="c">x</Card>);
|
||||
expect(screen.getByTestId("c")).toHaveAttribute("data-slot", "card");
|
||||
});
|
||||
|
||||
it("defaults size to 'default'", () => {
|
||||
render(<Card data-testid="c">x</Card>);
|
||||
expect(screen.getByTestId("c")).toHaveAttribute("data-size", "default");
|
||||
});
|
||||
|
||||
it("supports size='sm'", () => {
|
||||
render(
|
||||
<Card data-testid="c" size="sm">
|
||||
x
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByTestId("c")).toHaveAttribute("data-size", "sm");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Card data-testid="c" className="my-custom">
|
||||
x
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByTestId("c")).toHaveClass("my-custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Card subcomponents", () => {
|
||||
it.each([
|
||||
["CardHeader", CardHeader, "card-header"],
|
||||
["CardTitle", CardTitle, "card-title"],
|
||||
["CardDescription", CardDescription, "card-description"],
|
||||
["CardAction", CardAction, "card-action"],
|
||||
["CardContent", CardContent, "card-content"],
|
||||
["CardFooter", CardFooter, "card-footer"],
|
||||
] as const)("%s renders with data-slot='%s'", (_name, Component, slot) => {
|
||||
render(
|
||||
<Component data-testid="el">
|
||||
<span>child</span>
|
||||
</Component>,
|
||||
);
|
||||
expect(screen.getByTestId("el")).toHaveAttribute("data-slot", slot);
|
||||
});
|
||||
|
||||
it("CardHeader merges custom className", () => {
|
||||
render(
|
||||
<CardHeader data-testid="h" className="my-class">
|
||||
x
|
||||
</CardHeader>,
|
||||
);
|
||||
expect(screen.getByTestId("h")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("CardTitle merges custom className", () => {
|
||||
render(
|
||||
<CardTitle data-testid="t" className="my-class">
|
||||
x
|
||||
</CardTitle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("CardFooter merges custom className", () => {
|
||||
render(
|
||||
<CardFooter data-testid="f" className="my-class">
|
||||
x
|
||||
</CardFooter>,
|
||||
);
|
||||
expect(screen.getByTestId("f")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Card composition", () => {
|
||||
it("composes a full card", () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Description</CardDescription>
|
||||
<CardAction>Action</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>Content</CardContent>
|
||||
<CardFooter>Footer</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByText("Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Action")).toBeInTheDocument();
|
||||
expect(screen.getByText("Content")).toBeInTheDocument();
|
||||
expect(screen.getByText("Footer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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 data-slot='checkbox'", () => {
|
||||
render(<Checkbox data-testid="cb" />);
|
||||
expect(screen.getByTestId("cb")).toHaveAttribute("data-slot", "checkbox");
|
||||
});
|
||||
|
||||
it("defaults to unchecked", () => {
|
||||
render(<Checkbox data-testid="cb" />);
|
||||
const cb = screen.getByTestId("cb");
|
||||
// base-ui sets aria-checked
|
||||
expect(cb.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders as checked via defaultChecked", () => {
|
||||
render(<Checkbox data-testid="cb" defaultChecked />);
|
||||
expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("can be controlled via checked prop", () => {
|
||||
render(<Checkbox data-testid="cb" checked onCheckedChange={() => {}} />);
|
||||
expect(screen.getByTestId("cb").getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls onCheckedChange when toggled", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Checkbox data-testid="cb" onCheckedChange={onChange} />);
|
||||
await userEvent.click(screen.getByTestId("cb"));
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("respects disabled state via aria-disabled (base-ui uses span+role)", () => {
|
||||
render(<Checkbox data-testid="cb" disabled />);
|
||||
const cb = screen.getByTestId("cb");
|
||||
expect(cb.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(cb).toHaveAttribute("data-disabled");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Checkbox data-testid="cb" className="my-cb" />);
|
||||
expect(screen.getByTestId("cb")).toHaveClass("my-cb");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxRoot,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
useFilter,
|
||||
} from "./combobox";
|
||||
|
||||
const items = ["Apple", "Banana", "Cherry"];
|
||||
|
||||
const renderCombobox = (overrides: Partial<Parameters<typeof ComboboxRoot>[0]> = {}) =>
|
||||
render(
|
||||
<ComboboxRoot items={items} {...overrides}>
|
||||
<ComboboxInput placeholder="Search" />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
{items.map((item) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
|
||||
describe("ComboboxRoot/Input", () => {
|
||||
it("renders ComboboxInput with data-slot='combobox-input' (input is from primitive)", () => {
|
||||
const { container } = renderCombobox();
|
||||
// The InputGroupInput wraps with data-slot 'input-group-control' which is from input-group
|
||||
expect(container.querySelector("[data-slot=input-group-control]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render content when closed", () => {
|
||||
renderCombobox();
|
||||
expect(screen.queryByText("Apple")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders content when open=true", () => {
|
||||
renderCombobox({ open: true });
|
||||
expect(screen.getByText("Apple")).toBeInTheDocument();
|
||||
expect(screen.getByText("Banana")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxValue", () => {
|
||||
it("renders inside primitive without throwing", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ComboboxRoot items={items} value="Apple">
|
||||
<ComboboxValue />
|
||||
</ComboboxRoot>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxItem", () => {
|
||||
it("renders with data-slot='combobox-item'", () => {
|
||||
renderCombobox({ open: true });
|
||||
expect(document.querySelectorAll("[data-slot=combobox-item]").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxList / ComboboxGroup / ComboboxLabel", () => {
|
||||
it("ComboboxList renders with data-slot='combobox-list'", () => {
|
||||
renderCombobox({ open: true });
|
||||
expect(document.querySelector("[data-slot=combobox-list]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ComboboxGroup and ComboboxLabel render with their slots", () => {
|
||||
render(
|
||||
<ComboboxRoot items={items} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
<ComboboxGroup items={items}>
|
||||
<ComboboxLabel>Fruits</ComboboxLabel>
|
||||
<ComboboxItem value="Apple">Apple</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(document.querySelector("[data-slot=combobox-group]")).toBeInTheDocument();
|
||||
expect(document.querySelector("[data-slot=combobox-label]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxSeparator and ComboboxEmpty", () => {
|
||||
it("ComboboxSeparator renders with data-slot='combobox-separator'", () => {
|
||||
render(
|
||||
<ComboboxRoot items={items} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
<ComboboxItem value="Apple">Apple</ComboboxItem>
|
||||
<ComboboxSeparator />
|
||||
<ComboboxItem value="Banana">Banana</ComboboxItem>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(document.querySelector("[data-slot=combobox-separator]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ComboboxEmpty renders with data-slot='combobox-empty'", () => {
|
||||
render(
|
||||
<ComboboxRoot items={[]} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No results</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(document.querySelector("[data-slot=combobox-empty]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxCollection", () => {
|
||||
it("renders without throwing", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ComboboxRoot items={items} open>
|
||||
<ComboboxInput />
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
<ComboboxCollection items={items}>
|
||||
{(item) => <ComboboxItem value={item}>{item}</ComboboxItem>}
|
||||
</ComboboxCollection>
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxClear", () => {
|
||||
it("renders inside ComboboxInput when showClear=true", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items} value="Apple">
|
||||
<ComboboxInput showClear />
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=combobox-clear]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxTrigger", () => {
|
||||
it("renders an input-group-button addon when showTrigger=true (default)", () => {
|
||||
const { container } = renderCombobox();
|
||||
// ComboboxTrigger is rendered inside InputGroupButton which sets its own data-slot.
|
||||
expect(container.querySelector("[data-slot=input-group-button]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides input-group-button addon when showTrigger=false and showClear=false", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items}>
|
||||
<ComboboxInput showTrigger={false} />
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=input-group-button]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ComboboxTrigger renders standalone with data-slot='combobox-trigger'", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items}>
|
||||
<ComboboxTrigger>Open</ComboboxTrigger>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=combobox-trigger]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComboboxChips and friends", () => {
|
||||
it("renders ComboboxChips with data-slot='combobox-chips'", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items} multiple value={["Apple"]}>
|
||||
<ComboboxChips>
|
||||
<ComboboxChipsInput />
|
||||
</ComboboxChips>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=combobox-chips]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ComboboxChipsInput with data-slot='combobox-chip-input'", () => {
|
||||
const { container } = render(
|
||||
<ComboboxRoot items={items} multiple value={["Apple"]}>
|
||||
<ComboboxChips>
|
||||
<ComboboxChipsInput />
|
||||
</ComboboxChips>
|
||||
</ComboboxRoot>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=combobox-chip-input]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFilter", () => {
|
||||
it("is exported as a function", () => {
|
||||
expect(typeof useFilter).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useComboboxAnchor", () => {
|
||||
it("is exported as a function", () => {
|
||||
expect(typeof useComboboxAnchor).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "./command";
|
||||
|
||||
describe("Command", () => {
|
||||
it("renders with data-slot='command'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandItem>x</CommandItem>
|
||||
</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className on root", () => {
|
||||
const { container } = render(
|
||||
<Command className="my-cmd">
|
||||
<CommandList />
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command]")).toHaveClass("my-cmd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandInput", () => {
|
||||
it("renders with data-slot='command-input'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandInput placeholder="Search..." />
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-input]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wrapper uses data-slot='command-input-wrapper'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandInput />
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-input-wrapper]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandList / CommandGroup", () => {
|
||||
it("CommandList uses data-slot='command-list'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>x</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-list]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("CommandEmpty renders without throwing in tree", () => {
|
||||
// CommandEmpty only mounts under specific cmdk states — just verify no crash
|
||||
expect(() =>
|
||||
render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
</CommandList>
|
||||
</Command>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("CommandGroup uses data-slot='command-group'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup heading="Group 1">
|
||||
<CommandItem>Item</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-group]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandItem", () => {
|
||||
it("uses data-slot='command-item'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandItem>Hello</CommandItem>
|
||||
</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-item]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandSeparator", () => {
|
||||
it("uses data-slot='command-separator'", () => {
|
||||
const { container } = render(
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandSeparator />
|
||||
</CommandList>
|
||||
</Command>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=command-separator]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandShortcut", () => {
|
||||
it("uses data-slot='command-shortcut'", () => {
|
||||
render(<CommandShortcut>⌘K</CommandShortcut>);
|
||||
expect(screen.getByText("⌘K")).toHaveAttribute("data-slot", "command-shortcut");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<CommandShortcut className="my-class">x</CommandShortcut>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "./context-menu";
|
||||
|
||||
const renderWithMenu = (children: React.ReactNode) =>
|
||||
render(
|
||||
<ContextMenu open>
|
||||
<ContextMenuTrigger>x</ContextMenuTrigger>
|
||||
<ContextMenuContent>{children}</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
);
|
||||
|
||||
describe("ContextMenu", () => {
|
||||
it("trigger uses data-slot='context-menu-trigger'", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger data-testid="trigger">Right-click me</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>x</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "context-menu-trigger");
|
||||
});
|
||||
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>x</ContextMenuTrigger>
|
||||
<ContextMenuContent>Hidden</ContextMenuContent>
|
||||
</ContextMenu>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuItem", () => {
|
||||
it("uses data-slot='context-menu-item'", () => {
|
||||
renderWithMenu(<ContextMenuItem>Item</ContextMenuItem>);
|
||||
expect(screen.getByText("Item")).toHaveAttribute("data-slot", "context-menu-item");
|
||||
});
|
||||
|
||||
it("defaults variant='default'", () => {
|
||||
renderWithMenu(<ContextMenuItem>Item</ContextMenuItem>);
|
||||
expect(screen.getByText("Item")).toHaveAttribute("data-variant", "default");
|
||||
});
|
||||
|
||||
it("supports variant='destructive'", () => {
|
||||
renderWithMenu(<ContextMenuItem variant="destructive">Delete</ContextMenuItem>);
|
||||
expect(screen.getByText("Delete")).toHaveAttribute("data-variant", "destructive");
|
||||
});
|
||||
|
||||
it("supports inset prop", () => {
|
||||
renderWithMenu(<ContextMenuItem inset>Inset</ContextMenuItem>);
|
||||
expect(screen.getByText("Inset")).toHaveAttribute("data-inset", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuLabel", () => {
|
||||
it("uses data-slot='context-menu-label'", () => {
|
||||
renderWithMenu(
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel>Group label</ContextMenuLabel>
|
||||
</ContextMenuGroup>,
|
||||
);
|
||||
expect(screen.getByText("Group label")).toHaveAttribute("data-slot", "context-menu-label");
|
||||
});
|
||||
|
||||
it("supports inset prop", () => {
|
||||
renderWithMenu(
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel inset>Inset label</ContextMenuLabel>
|
||||
</ContextMenuGroup>,
|
||||
);
|
||||
expect(screen.getByText("Inset label")).toHaveAttribute("data-inset", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuShortcut", () => {
|
||||
it("uses data-slot='context-menu-shortcut'", () => {
|
||||
render(<ContextMenuShortcut>⌘K</ContextMenuShortcut>);
|
||||
expect(screen.getByText("⌘K")).toHaveAttribute("data-slot", "context-menu-shortcut");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<ContextMenuShortcut className="my-class">x</ContextMenuShortcut>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuCheckboxItem and ContextMenuRadioItem", () => {
|
||||
it("CheckboxItem renders within menu", () => {
|
||||
renderWithMenu(<ContextMenuCheckboxItem checked>Toggle</ContextMenuCheckboxItem>);
|
||||
expect(screen.getByText("Toggle")).toHaveAttribute("data-slot", "context-menu-checkbox-item");
|
||||
});
|
||||
|
||||
it("RadioItem renders within menu", () => {
|
||||
renderWithMenu(
|
||||
<ContextMenuRadioGroup value="a">
|
||||
<ContextMenuRadioItem value="a">A</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>,
|
||||
);
|
||||
expect(screen.getByText("A")).toHaveAttribute("data-slot", "context-menu-radio-item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuSub", () => {
|
||||
it("SubTrigger uses data-slot='context-menu-sub-trigger'", () => {
|
||||
renderWithMenu(
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>More</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem>Inner</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>,
|
||||
);
|
||||
expect(screen.getByText("More")).toHaveAttribute("data-slot", "context-menu-sub-trigger");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./dialog";
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<Dialog>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>Hidden body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens content via defaultOpen", () => {
|
||||
render(
|
||||
<Dialog defaultOpen>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>Visible body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Visible body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens content via controlled open", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>Visible body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Visible body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens on trigger click", async () => {
|
||||
render(
|
||||
<Dialog>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>Body text</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Open"));
|
||||
expect(await screen.findByText("Body text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders close button by default in DialogContent", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Close")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides close button when showCloseButton=false", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent showCloseButton={false}>body</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.queryByText("Close")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogTitle", () => {
|
||||
it("uses data-slot='dialog-title'", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>My Title</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("My Title")).toHaveAttribute("data-slot", "dialog-title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogDescription", () => {
|
||||
it("uses data-slot='dialog-description'", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogDescription>My Description</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("My Description")).toHaveAttribute("data-slot", "dialog-description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogHeader", () => {
|
||||
it("uses data-slot='dialog-header'", () => {
|
||||
render(<DialogHeader data-testid="h">x</DialogHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "dialog-header");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogFooter", () => {
|
||||
it("uses data-slot='dialog-footer'", () => {
|
||||
render(<DialogFooter data-testid="f">x</DialogFooter>);
|
||||
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "dialog-footer");
|
||||
});
|
||||
|
||||
it("renders close button when showCloseButton=true", () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>x</DialogTrigger>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogFooter showCloseButton>body</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText("Close")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render close button by default", () => {
|
||||
render(<DialogFooter>just content</DialogFooter>);
|
||||
expect(screen.queryByText("Close")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown-menu";
|
||||
|
||||
describe("DropdownMenu", () => {
|
||||
it("trigger has data-slot='dropdown-menu-trigger'", () => {
|
||||
render(
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-testid="trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>x</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "dropdown-menu-trigger");
|
||||
});
|
||||
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>Hidden item</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden item")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders content when controlled open", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuItem", () => {
|
||||
it("uses data-slot='dropdown-menu-item'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Item")).toHaveAttribute("data-slot", "dropdown-menu-item");
|
||||
});
|
||||
|
||||
it("defaults variant='default'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Item")).toHaveAttribute("data-variant", "default");
|
||||
});
|
||||
|
||||
it("supports variant='destructive'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Delete")).toHaveAttribute("data-variant", "destructive");
|
||||
});
|
||||
|
||||
it("supports inset prop", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem inset>Inset</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Inset")).toHaveAttribute("data-inset", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuLabel", () => {
|
||||
it("uses data-slot='dropdown-menu-label'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Group label</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Item</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Group label")).toHaveAttribute("data-slot", "dropdown-menu-label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuSeparator", () => {
|
||||
it("uses data-slot='dropdown-menu-separator'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>1</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>2</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
// Portal content renders at document.body
|
||||
expect(document.querySelector("[data-slot=dropdown-menu-separator]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuShortcut", () => {
|
||||
it("uses data-slot='dropdown-menu-shortcut'", () => {
|
||||
render(<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>);
|
||||
expect(screen.getByText("⌘K")).toHaveAttribute("data-slot", "dropdown-menu-shortcut");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<DropdownMenuShortcut className="my-class">x</DropdownMenuShortcut>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuCheckboxItem", () => {
|
||||
it("uses data-slot='dropdown-menu-checkbox-item'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuCheckboxItem checked>Toggle</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("Toggle")).toHaveAttribute("data-slot", "dropdown-menu-checkbox-item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuRadioItem", () => {
|
||||
it("uses data-slot='dropdown-menu-radio-item'", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup value="a">
|
||||
<DropdownMenuRadioItem value="a">A</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("A")).toHaveAttribute("data-slot", "dropdown-menu-radio-item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropdownMenuSub", () => {
|
||||
it("renders nested submenu trigger", () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>x</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>More</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>Inner</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
expect(screen.getByText("More")).toHaveAttribute("data-slot", "dropdown-menu-sub-trigger");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "./form";
|
||||
|
||||
describe("FormItem", () => {
|
||||
it("renders with data-slot='form-item'", () => {
|
||||
render(<FormItem data-testid="i">x</FormItem>);
|
||||
expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "form-item");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<FormItem data-testid="i" className="my-class">
|
||||
x
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByTestId("i")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("provides id and hasError context to children", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl render={(props) => <input {...props} data-testid="input" />} />
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const label = screen.getByText("Name");
|
||||
expect(label).toHaveAttribute("data-error", "true");
|
||||
|
||||
const input = screen.getByTestId("input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
|
||||
it("hasError defaults to false", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl render={(props) => <input {...props} data-testid="input" />} />
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("input");
|
||||
expect(input).toHaveAttribute("aria-invalid", "false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormLabel", () => {
|
||||
it("renders with data-slot='form-label' and connects htmlFor to control via context id", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl render={(props) => <input {...props} type="email" />} />
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const label = screen.getByText("Email");
|
||||
expect(label).toHaveAttribute("data-slot", "form-label");
|
||||
const htmlFor = label.getAttribute("for");
|
||||
expect(htmlFor).toMatch(/-form-item$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormControl", () => {
|
||||
it("sets aria-describedby pointing only to description when no error", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormControl render={(props) => <input {...props} data-testid="input" />} />
|
||||
<FormDescription>Help text</FormDescription>
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("input");
|
||||
const describedBy = input.getAttribute("aria-describedby");
|
||||
expect(describedBy).toMatch(/-form-item-description$/);
|
||||
expect(describedBy).not.toContain("message");
|
||||
});
|
||||
|
||||
it("includes message in aria-describedby when hasError=true", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormControl render={(props) => <input {...props} data-testid="input" />} />
|
||||
</FormItem>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId("input");
|
||||
const describedBy = input.getAttribute("aria-describedby");
|
||||
expect(describedBy).toContain("description");
|
||||
expect(describedBy).toContain("message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormDescription", () => {
|
||||
it("uses data-slot='form-description'", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormDescription>Help</FormDescription>
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Help")).toHaveAttribute("data-slot", "form-description");
|
||||
});
|
||||
|
||||
it("id ends with '-form-item-description'", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormDescription>Help</FormDescription>
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Help").getAttribute("id")).toMatch(/-form-item-description$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormMessage", () => {
|
||||
it("returns null when no errors", () => {
|
||||
const { container } = render(
|
||||
<FormItem>
|
||||
<FormMessage />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=form-message]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns null when errors is empty", () => {
|
||||
const { container } = render(
|
||||
<FormItem>
|
||||
<FormMessage errors={[]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=form-message]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders string error message", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormMessage errors={["String error"]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("String error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error with .message property", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormMessage errors={[{ message: "Object error" }]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Object error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips falsy and unrecognized errors and shows the first valid one", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormMessage errors={[null, undefined, { wrong: "field" }, "Valid"]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Valid")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns null when no extractable error message in array", () => {
|
||||
const { container } = render(
|
||||
<FormItem>
|
||||
<FormMessage errors={[null, undefined, { wrong: "field" }]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=form-message]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses destructive class when hasError=true", () => {
|
||||
render(
|
||||
<FormItem hasError>
|
||||
<FormMessage errors={["Bad"]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Bad")).toHaveClass("text-destructive");
|
||||
});
|
||||
|
||||
it("uses muted class when hasError=false but errors are passed", () => {
|
||||
render(
|
||||
<FormItem>
|
||||
<FormMessage errors={["Hint"]} />
|
||||
</FormItem>,
|
||||
);
|
||||
expect(screen.getByText("Hint")).toHaveClass("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./hover-card";
|
||||
|
||||
describe("HoverCard", () => {
|
||||
it("trigger has data-slot='hover-card-trigger'", () => {
|
||||
render(
|
||||
<HoverCard>
|
||||
<HoverCardTrigger data-testid="trigger">Hover</HoverCardTrigger>
|
||||
<HoverCardContent>preview</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "hover-card-trigger");
|
||||
});
|
||||
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>Hover</HoverCardTrigger>
|
||||
<HoverCardContent>Hidden preview</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders content when controlled open", () => {
|
||||
render(
|
||||
<HoverCard open>
|
||||
<HoverCardTrigger>Hover</HoverCardTrigger>
|
||||
<HoverCardContent>Visible preview</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.getByText("Visible preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("HoverCardContent uses data-slot='hover-card-content' when shown", () => {
|
||||
render(
|
||||
<HoverCard open>
|
||||
<HoverCardTrigger>x</HoverCardTrigger>
|
||||
<HoverCardContent>visible</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveAttribute("data-slot", "hover-card-content");
|
||||
});
|
||||
|
||||
it("merges custom className on HoverCardContent", () => {
|
||||
render(
|
||||
<HoverCard open>
|
||||
<HoverCardTrigger>x</HoverCardTrigger>
|
||||
<HoverCardContent className="my-class">visible</HoverCardContent>
|
||||
</HoverCard>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
} from "./input-group";
|
||||
|
||||
describe("InputGroup", () => {
|
||||
it("renders a fieldset with data-slot='input-group'", () => {
|
||||
render(<InputGroup data-testid="g" />);
|
||||
const g = screen.getByTestId("g");
|
||||
expect(g.tagName).toBe("FIELDSET");
|
||||
expect(g).toHaveAttribute("data-slot", "input-group");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<InputGroup data-testid="g" className="my-class" />);
|
||||
expect(screen.getByTestId("g")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupAddon", () => {
|
||||
it("defaults align to 'inline-start'", () => {
|
||||
render(<InputGroupAddon data-testid="addon" />);
|
||||
expect(screen.getByTestId("addon")).toHaveAttribute("data-align", "inline-start");
|
||||
});
|
||||
|
||||
it.each(["inline-start", "inline-end", "block-start", "block-end"] as const)("supports align=%s", (align) => {
|
||||
render(<InputGroupAddon data-testid="addon" align={align} />);
|
||||
expect(screen.getByTestId("addon")).toHaveAttribute("data-align", align);
|
||||
});
|
||||
|
||||
it("uses data-slot='input-group-addon'", () => {
|
||||
render(<InputGroupAddon data-testid="addon" />);
|
||||
expect(screen.getByTestId("addon")).toHaveAttribute("data-slot", "input-group-addon");
|
||||
});
|
||||
|
||||
it("focuses sibling input on click outside of buttons", () => {
|
||||
render(
|
||||
<InputGroup>
|
||||
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
|
||||
<InputGroupInput data-testid="i" />
|
||||
</InputGroup>,
|
||||
);
|
||||
|
||||
const addon = screen.getByTestId("addon");
|
||||
fireEvent.click(addon);
|
||||
expect(document.activeElement).toBe(screen.getByTestId("i"));
|
||||
});
|
||||
|
||||
it("focuses sibling input on Enter key", () => {
|
||||
render(
|
||||
<InputGroup>
|
||||
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
|
||||
<InputGroupInput data-testid="i" />
|
||||
</InputGroup>,
|
||||
);
|
||||
|
||||
const addon = screen.getByTestId("addon");
|
||||
fireEvent.keyDown(addon, { key: "Enter" });
|
||||
expect(document.activeElement).toBe(screen.getByTestId("i"));
|
||||
});
|
||||
|
||||
it("focuses sibling input on Space key", () => {
|
||||
render(
|
||||
<InputGroup>
|
||||
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
|
||||
<InputGroupInput data-testid="i" />
|
||||
</InputGroup>,
|
||||
);
|
||||
|
||||
const addon = screen.getByTestId("addon");
|
||||
fireEvent.keyDown(addon, { key: " " });
|
||||
expect(document.activeElement).toBe(screen.getByTestId("i"));
|
||||
});
|
||||
|
||||
it("ignores unrelated keys", () => {
|
||||
render(
|
||||
<InputGroup>
|
||||
<InputGroupAddon data-testid="addon">addon</InputGroupAddon>
|
||||
<InputGroupInput data-testid="i" />
|
||||
</InputGroup>,
|
||||
);
|
||||
|
||||
const addon = screen.getByTestId("addon");
|
||||
fireEvent.keyDown(addon, { key: "a" });
|
||||
expect(document.activeElement).not.toBe(screen.getByTestId("i"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupButton", () => {
|
||||
it("defaults to type='button' and variant='ghost'", () => {
|
||||
render(<InputGroupButton data-testid="b">x</InputGroupButton>);
|
||||
const button = screen.getByTestId("b");
|
||||
expect(button).toHaveAttribute("type", "button");
|
||||
});
|
||||
|
||||
it("respects type override", () => {
|
||||
render(
|
||||
<InputGroupButton data-testid="b" type="submit">
|
||||
x
|
||||
</InputGroupButton>,
|
||||
);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("type", "submit");
|
||||
});
|
||||
|
||||
it("applies data-size for size variants", () => {
|
||||
render(
|
||||
<InputGroupButton data-testid="b" size="sm">
|
||||
x
|
||||
</InputGroupButton>,
|
||||
);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-size", "sm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupText", () => {
|
||||
it("renders as a span", () => {
|
||||
render(<InputGroupText>label</InputGroupText>);
|
||||
expect(screen.getByText("label").tagName).toBe("SPAN");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<InputGroupText className="my-class">x</InputGroupText>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupInput", () => {
|
||||
it("uses data-slot='input-group-control'", () => {
|
||||
render(<InputGroupInput data-testid="i" />);
|
||||
expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "input-group-control");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputGroupTextarea", () => {
|
||||
it("uses data-slot='input-group-control' and renders textarea", () => {
|
||||
render(<InputGroupTextarea data-testid="t" />);
|
||||
const t = screen.getByTestId("t");
|
||||
expect(t.tagName).toBe("TEXTAREA");
|
||||
expect(t).toHaveAttribute("data-slot", "input-group-control");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Input } from "./input";
|
||||
|
||||
describe("Input", () => {
|
||||
it("renders an input element", () => {
|
||||
render(<Input data-testid="x" />);
|
||||
expect(screen.getByTestId("x").tagName).toBe("INPUT");
|
||||
});
|
||||
|
||||
it("applies data-slot='input'", () => {
|
||||
render(<Input data-testid="x" />);
|
||||
expect(screen.getByTestId("x")).toHaveAttribute("data-slot", "input");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Input data-testid="x" className="custom-input" />);
|
||||
expect(screen.getByTestId("x")).toHaveClass("custom-input");
|
||||
});
|
||||
|
||||
it("forwards type attribute", () => {
|
||||
render(<Input data-testid="x" type="email" />);
|
||||
expect(screen.getByTestId("x")).toHaveAttribute("type", "email");
|
||||
});
|
||||
|
||||
it("calls onChange when typing", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Input data-testid="x" onChange={onChange} />);
|
||||
await userEvent.type(screen.getByTestId("x"), "abc");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards placeholder", () => {
|
||||
render(<Input placeholder="Enter name" />);
|
||||
expect(screen.getByPlaceholderText("Enter name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects disabled state", async () => {
|
||||
render(<Input data-testid="x" disabled />);
|
||||
const input = screen.getByTestId("x");
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
it("supports controlled value", () => {
|
||||
render(<Input data-testid="x" value="hello" onChange={() => {}} />);
|
||||
expect(screen.getByTestId("x")).toHaveValue("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Kbd, KbdGroup } from "./kbd";
|
||||
|
||||
describe("Kbd", () => {
|
||||
it("renders as a <kbd> element with data-slot='kbd'", () => {
|
||||
render(<Kbd data-testid="k">Ctrl</Kbd>);
|
||||
const k = screen.getByTestId("k");
|
||||
expect(k.tagName).toBe("KBD");
|
||||
expect(k).toHaveAttribute("data-slot", "kbd");
|
||||
});
|
||||
|
||||
it("renders the key text", () => {
|
||||
render(<Kbd>⌘ K</Kbd>);
|
||||
expect(screen.getByText("⌘ K")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Kbd data-testid="k" className="my-class">
|
||||
x
|
||||
</Kbd>,
|
||||
);
|
||||
expect(screen.getByTestId("k")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("KbdGroup", () => {
|
||||
it("renders as a <kbd> wrapper with data-slot='kbd-group'", () => {
|
||||
render(<KbdGroup data-testid="g">x</KbdGroup>);
|
||||
const g = screen.getByTestId("g");
|
||||
expect(g.tagName).toBe("KBD");
|
||||
expect(g).toHaveAttribute("data-slot", "kbd-group");
|
||||
});
|
||||
|
||||
it("groups multiple Kbd elements", () => {
|
||||
render(
|
||||
<KbdGroup>
|
||||
<Kbd>Ctrl</Kbd>
|
||||
<Kbd>K</Kbd>
|
||||
</KbdGroup>,
|
||||
);
|
||||
expect(screen.getByText("Ctrl")).toBeInTheDocument();
|
||||
expect(screen.getByText("K")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<KbdGroup data-testid="g" className="my-group">
|
||||
x
|
||||
</KbdGroup>,
|
||||
);
|
||||
expect(screen.getByTestId("g")).toHaveClass("my-group");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Label } from "./label";
|
||||
|
||||
describe("Label", () => {
|
||||
it("renders as a <label> element", () => {
|
||||
render(<Label>Name</Label>);
|
||||
expect(screen.getByText("Name").tagName).toBe("LABEL");
|
||||
});
|
||||
|
||||
it("applies data-slot='label'", () => {
|
||||
render(<Label>x</Label>);
|
||||
expect(screen.getByText("x")).toHaveAttribute("data-slot", "label");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Label className="my-custom">x</Label>);
|
||||
expect(screen.getByText("x")).toHaveClass("my-custom");
|
||||
});
|
||||
|
||||
it("supports htmlFor association with inputs", () => {
|
||||
render(
|
||||
<>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<input id="name" />
|
||||
</>,
|
||||
);
|
||||
const input = screen.getByLabelText("Name");
|
||||
expect(input.tagName).toBe("INPUT");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "./popover";
|
||||
|
||||
describe("Popover", () => {
|
||||
it("trigger element has data-slot='popover-trigger'", () => {
|
||||
render(
|
||||
<Popover>
|
||||
<PopoverTrigger data-testid="trigger">Open</PopoverTrigger>
|
||||
<PopoverContent>content</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "popover-trigger");
|
||||
});
|
||||
|
||||
it("opens content on trigger click", async () => {
|
||||
render(
|
||||
<Popover>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>Hidden text</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Hidden text")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("Open"));
|
||||
expect(await screen.findByText("Hidden text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports default open via prop", () => {
|
||||
render(
|
||||
<Popover defaultOpen>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>Initially visible</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("Initially visible")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can be controlled via open prop", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>Controlled</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("Controlled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopoverHeader", () => {
|
||||
it("uses data-slot='popover-header'", () => {
|
||||
render(<PopoverHeader data-testid="h">Title</PopoverHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "popover-header");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<PopoverHeader data-testid="h" className="my-class">
|
||||
x
|
||||
</PopoverHeader>,
|
||||
);
|
||||
expect(screen.getByTestId("h")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopoverTitle", () => {
|
||||
it("uses data-slot='popover-title'", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverTitle>My title</PopoverTitle>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("My title")).toHaveAttribute("data-slot", "popover-title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopoverDescription", () => {
|
||||
it("uses data-slot='popover-description'", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverDescription>My description</PopoverDescription>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("My description")).toHaveAttribute("data-slot", "popover-description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PopoverContent", () => {
|
||||
it("uses data-slot='popover-content'", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent>visible content</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
const content = screen.getByText("visible content");
|
||||
expect(content).toHaveAttribute("data-slot", "popover-content");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger>Open</PopoverTrigger>
|
||||
<PopoverContent className="my-class">visible</PopoverContent>
|
||||
</Popover>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveClass("my-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Progress, ProgressIndicator, ProgressLabel, ProgressTrack, ProgressValue } from "./progress";
|
||||
|
||||
describe("Progress", () => {
|
||||
it("renders with data-slot='progress'", () => {
|
||||
render(<Progress data-testid="p" value={50} />);
|
||||
expect(screen.getByTestId("p")).toHaveAttribute("data-slot", "progress");
|
||||
});
|
||||
|
||||
it("auto-renders track and indicator children", () => {
|
||||
const { container } = render(<Progress value={50} data-testid="p" />);
|
||||
expect(container.querySelector("[data-slot=progress-track]")).toBeInTheDocument();
|
||||
expect(container.querySelector("[data-slot=progress-indicator]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards value attribute via aria-valuenow", () => {
|
||||
render(<Progress data-testid="p" value={75} />);
|
||||
expect(screen.getByTestId("p")).toHaveAttribute("aria-valuenow", "75");
|
||||
});
|
||||
|
||||
it("supports custom children rendered before the track", () => {
|
||||
render(
|
||||
<Progress data-testid="p" value={50}>
|
||||
<ProgressLabel>My Progress</ProgressLabel>
|
||||
</Progress>,
|
||||
);
|
||||
expect(screen.getByText("My Progress")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProgressLabel", () => {
|
||||
it("renders with data-slot='progress-label'", () => {
|
||||
render(
|
||||
<Progress value={50}>
|
||||
<ProgressLabel>Label</ProgressLabel>
|
||||
</Progress>,
|
||||
);
|
||||
expect(screen.getByText("Label")).toHaveAttribute("data-slot", "progress-label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProgressValue", () => {
|
||||
it("renders with data-slot='progress-value'", () => {
|
||||
render(
|
||||
<Progress value={50}>
|
||||
<ProgressValue />
|
||||
</Progress>,
|
||||
);
|
||||
// The value text is rendered by base-ui — just verify the slot exists
|
||||
const { container } = render(
|
||||
<Progress value={50}>
|
||||
<ProgressValue data-testid="v" />
|
||||
</Progress>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=progress-value]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProgressTrack and ProgressIndicator", () => {
|
||||
it("can be rendered explicitly with custom classes", () => {
|
||||
const { container } = render(
|
||||
<Progress value={25}>
|
||||
<ProgressTrack className="track-custom">
|
||||
<ProgressIndicator className="ind-custom" />
|
||||
</ProgressTrack>
|
||||
</Progress>,
|
||||
);
|
||||
expect(container.querySelectorAll("[data-slot=progress-track]")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "./resizable";
|
||||
|
||||
describe("ResizableGroup", () => {
|
||||
it("renders with data-slot='resizable-panel-group'", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal">
|
||||
<ResizablePanel>A</ResizablePanel>
|
||||
<ResizableSeparator />
|
||||
<ResizablePanel>B</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=resizable-panel-group]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal" className="my-group">
|
||||
<ResizablePanel>x</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=resizable-panel-group]")).toHaveClass("my-group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResizablePanel", () => {
|
||||
it("renders with data-slot='resizable-panel'", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal">
|
||||
<ResizablePanel>x</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=resizable-panel]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResizableSeparator", () => {
|
||||
it("renders with data-slot='resizable-handle' and no inner handle by default", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal">
|
||||
<ResizablePanel>A</ResizablePanel>
|
||||
<ResizableSeparator />
|
||||
<ResizablePanel>B</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
const handle = container.querySelector("[data-slot=resizable-handle]");
|
||||
expect(handle).toBeInTheDocument();
|
||||
// No inner handle div by default
|
||||
expect(handle?.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders inner handle when withHandle=true", () => {
|
||||
const { container } = render(
|
||||
<ResizableGroup direction="horizontal">
|
||||
<ResizablePanel>A</ResizablePanel>
|
||||
<ResizableSeparator withHandle />
|
||||
<ResizablePanel>B</ResizablePanel>
|
||||
</ResizableGroup>,
|
||||
);
|
||||
const handle = container.querySelector("[data-slot=resizable-handle]");
|
||||
expect(handle?.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ScrollArea } from "./scroll-area";
|
||||
|
||||
describe("ScrollArea", () => {
|
||||
it("renders with data-slot='scroll-area'", () => {
|
||||
render(
|
||||
<ScrollArea data-testid="sa">
|
||||
<div>content</div>
|
||||
</ScrollArea>,
|
||||
);
|
||||
expect(screen.getByTestId("sa")).toHaveAttribute("data-slot", "scroll-area");
|
||||
});
|
||||
|
||||
it("renders viewport with children", () => {
|
||||
const { container } = render(
|
||||
<ScrollArea>
|
||||
<div>content</div>
|
||||
</ScrollArea>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=scroll-area-viewport]")).toBeInTheDocument();
|
||||
expect(screen.getByText("content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className on root", () => {
|
||||
render(
|
||||
<ScrollArea data-testid="sa" className="my-class">
|
||||
<div>x</div>
|
||||
</ScrollArea>,
|
||||
);
|
||||
expect(screen.getByTestId("sa")).toHaveClass("my-class");
|
||||
});
|
||||
|
||||
it("preserves the relative positioning class", () => {
|
||||
render(
|
||||
<ScrollArea data-testid="sa">
|
||||
<div>x</div>
|
||||
</ScrollArea>,
|
||||
);
|
||||
expect(screen.getByTestId("sa")).toHaveClass("relative");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Separator } from "./separator";
|
||||
|
||||
describe("Separator", () => {
|
||||
it("renders with data-slot='separator'", () => {
|
||||
render(<Separator data-testid="sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-slot", "separator");
|
||||
});
|
||||
|
||||
it("defaults to horizontal orientation", () => {
|
||||
render(<Separator data-testid="sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "horizontal");
|
||||
});
|
||||
|
||||
it("supports vertical orientation", () => {
|
||||
render(<Separator data-testid="sep" orientation="vertical" />);
|
||||
expect(screen.getByTestId("sep")).toHaveAttribute("data-orientation", "vertical");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Separator data-testid="sep" className="my-sep" />);
|
||||
expect(screen.getByTestId("sep")).toHaveClass("my-sep");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./sheet";
|
||||
|
||||
describe("Sheet", () => {
|
||||
it("does not render content by default", () => {
|
||||
render(
|
||||
<Sheet>
|
||||
<SheetTrigger>Open</SheetTrigger>
|
||||
<SheetContent>Hidden body</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens via defaultOpen", () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open</SheetTrigger>
|
||||
<SheetContent>Visible body</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText("Visible body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders default 'right' side", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>Open</SheetTrigger>
|
||||
<SheetContent>visible</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
const content = screen.getByText("visible");
|
||||
expect(content).toHaveAttribute("data-side", "right");
|
||||
});
|
||||
|
||||
it.each(["top", "right", "bottom", "left"] as const)("supports side=%s", (side) => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent side={side}>visible {side}</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText(`visible ${side}`)).toHaveAttribute("data-side", side);
|
||||
});
|
||||
|
||||
it("renders close button by default", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent>x</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText("Close")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides close button with showCloseButton=false", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent showCloseButton={false}>x</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.queryByText("Close")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SheetHeader / SheetFooter", () => {
|
||||
it("SheetHeader uses data-slot='sheet-header'", () => {
|
||||
render(<SheetHeader data-testid="h">x</SheetHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "sheet-header");
|
||||
});
|
||||
|
||||
it("SheetFooter uses data-slot='sheet-footer'", () => {
|
||||
render(<SheetFooter data-testid="f">x</SheetFooter>);
|
||||
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "sheet-footer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SheetTitle / SheetDescription", () => {
|
||||
it("SheetTitle uses data-slot='sheet-title'", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetTitle>Title</SheetTitle>
|
||||
</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText("Title")).toHaveAttribute("data-slot", "sheet-title");
|
||||
});
|
||||
|
||||
it("SheetDescription uses data-slot='sheet-description'", () => {
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetTrigger>x</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetDescription>Description</SheetDescription>
|
||||
</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
expect(screen.getByText("Description")).toHaveAttribute("data-slot", "sheet-description");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
} from "./sidebar";
|
||||
|
||||
const renderInProvider = (children: React.ReactNode, props: Partial<Parameters<typeof SidebarProvider>[0]> = {}) =>
|
||||
render(
|
||||
<SidebarProvider {...props}>
|
||||
<Sidebar>{children}</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
describe("SidebarProvider", () => {
|
||||
it("renders children inside a sidebar-wrapper div", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=sidebar-wrapper]")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className on wrapper", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider className="my-wrapper">
|
||||
<div>x</div>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=sidebar-wrapper]")).toHaveClass("my-wrapper");
|
||||
});
|
||||
|
||||
it("calls onOpenChange when toggled in controlled mode", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SidebarProvider open={true} onOpenChange={onOpenChange}>
|
||||
<Sidebar>x</Sidebar>
|
||||
<SidebarTrigger />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /toggle sidebar/i }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("toggles via Cmd/Ctrl+B keyboard shortcut", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SidebarProvider open={true} onOpenChange={onOpenChange}>
|
||||
<Sidebar>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
await userEvent.keyboard("{Control>}b{/Control}");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("renders with data-slot='sidebar' when collapsible='none'", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="none">x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=sidebar]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with data-state='expanded' by default", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider defaultOpen>
|
||||
<Sidebar>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
// data-state is on the desktop wrapper
|
||||
const sidebar = container.querySelector("[data-slot=sidebar]");
|
||||
expect(sidebar).toHaveAttribute("data-state", "expanded");
|
||||
});
|
||||
|
||||
it("renders with data-state='collapsed' when defaultOpen=false", () => {
|
||||
const { container } = render(
|
||||
<SidebarProvider defaultOpen={false}>
|
||||
<Sidebar>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const sidebar = container.querySelector("[data-slot=sidebar]");
|
||||
expect(sidebar).toHaveAttribute("data-state", "collapsed");
|
||||
});
|
||||
|
||||
it.each(["sidebar", "floating", "inset"] as const)("supports variant=%s", (variant) => {
|
||||
const { container } = render(
|
||||
<SidebarProvider>
|
||||
<Sidebar variant={variant}>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const sidebar = container.querySelector("[data-slot=sidebar]");
|
||||
expect(sidebar).toHaveAttribute("data-variant", variant);
|
||||
});
|
||||
|
||||
it.each(["left", "right"] as const)("supports side=%s", (side) => {
|
||||
const { container } = render(
|
||||
<SidebarProvider>
|
||||
<Sidebar side={side}>x</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const sidebar = container.querySelector("[data-slot=sidebar]");
|
||||
expect(sidebar).toHaveAttribute("data-side", side);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarTrigger", () => {
|
||||
it("renders with sr-only label 'Toggle Sidebar'", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarTrigger />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(screen.getByText("Toggle Sidebar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has data-slot='sidebar-trigger' and data-sidebar='trigger'", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarTrigger data-testid="trigger" />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(trigger).toHaveAttribute("data-slot", "sidebar-trigger");
|
||||
expect(trigger).toHaveAttribute("data-sidebar", "trigger");
|
||||
});
|
||||
|
||||
it("invokes user onClick before toggling", async () => {
|
||||
const onClick = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SidebarProvider open={true} onOpenChange={onOpenChange}>
|
||||
<SidebarTrigger onClick={onClick} />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /toggle sidebar/i }));
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarRail", () => {
|
||||
it("renders with data-slot='sidebar-rail' and aria-label='Toggle Sidebar'", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarRail />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
const rail = screen.getByLabelText("Toggle Sidebar");
|
||||
expect(rail).toHaveAttribute("data-slot", "sidebar-rail");
|
||||
});
|
||||
|
||||
it("toggles sidebar on click", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SidebarProvider open={true} onOpenChange={onOpenChange}>
|
||||
<SidebarRail />
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Toggle Sidebar"));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarInset", () => {
|
||||
it("renders as <main> with data-slot='sidebar-inset'", () => {
|
||||
render(<SidebarInset data-testid="inset">x</SidebarInset>);
|
||||
const inset = screen.getByTestId("inset");
|
||||
expect(inset.tagName).toBe("MAIN");
|
||||
expect(inset).toHaveAttribute("data-slot", "sidebar-inset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarInput", () => {
|
||||
it("renders with data-slot='sidebar-input'", () => {
|
||||
render(<SidebarInput data-testid="i" />);
|
||||
expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "sidebar-input");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarHeader / SidebarFooter / SidebarSeparator", () => {
|
||||
it("SidebarHeader uses data-slot='sidebar-header'", () => {
|
||||
render(<SidebarHeader data-testid="h">x</SidebarHeader>);
|
||||
expect(screen.getByTestId("h")).toHaveAttribute("data-slot", "sidebar-header");
|
||||
});
|
||||
|
||||
it("SidebarFooter uses data-slot='sidebar-footer'", () => {
|
||||
render(<SidebarFooter data-testid="f">x</SidebarFooter>);
|
||||
expect(screen.getByTestId("f")).toHaveAttribute("data-slot", "sidebar-footer");
|
||||
});
|
||||
|
||||
it("SidebarSeparator uses data-slot='sidebar-separator'", () => {
|
||||
render(<SidebarSeparator data-testid="s" />);
|
||||
expect(screen.getByTestId("s")).toHaveAttribute("data-slot", "sidebar-separator");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarContent / SidebarGroup", () => {
|
||||
it("SidebarContent uses data-slot='sidebar-content'", () => {
|
||||
render(<SidebarContent data-testid="c">x</SidebarContent>);
|
||||
expect(screen.getByTestId("c")).toHaveAttribute("data-slot", "sidebar-content");
|
||||
});
|
||||
|
||||
it("SidebarGroup uses data-slot='sidebar-group'", () => {
|
||||
render(<SidebarGroup data-testid="g">x</SidebarGroup>);
|
||||
expect(screen.getByTestId("g")).toHaveAttribute("data-slot", "sidebar-group");
|
||||
});
|
||||
|
||||
it("SidebarGroupContent uses data-slot='sidebar-group-content'", () => {
|
||||
render(<SidebarGroupContent data-testid="g">x</SidebarGroupContent>);
|
||||
expect(screen.getByTestId("g")).toHaveAttribute("data-slot", "sidebar-group-content");
|
||||
});
|
||||
|
||||
it("SidebarGroupLabel uses data-slot='sidebar-group-label'", () => {
|
||||
render(<SidebarGroupLabel data-testid="l">Group</SidebarGroupLabel>);
|
||||
expect(screen.getByTestId("l")).toHaveAttribute("data-slot", "sidebar-group-label");
|
||||
});
|
||||
|
||||
it("SidebarGroupAction uses data-slot='sidebar-group-action'", () => {
|
||||
render(<SidebarGroupAction data-testid="a">+</SidebarGroupAction>);
|
||||
expect(screen.getByTestId("a")).toHaveAttribute("data-slot", "sidebar-group-action");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarMenu and items", () => {
|
||||
it("SidebarMenu uses data-slot='sidebar-menu' and renders as <ul>", () => {
|
||||
render(<SidebarMenu data-testid="m" />);
|
||||
const menu = screen.getByTestId("m");
|
||||
expect(menu.tagName).toBe("UL");
|
||||
expect(menu).toHaveAttribute("data-slot", "sidebar-menu");
|
||||
});
|
||||
|
||||
it("SidebarMenuItem uses data-slot='sidebar-menu-item' and renders as <li>", () => {
|
||||
render(<SidebarMenuItem data-testid="i">x</SidebarMenuItem>);
|
||||
const item = screen.getByTestId("i");
|
||||
expect(item.tagName).toBe("LI");
|
||||
expect(item).toHaveAttribute("data-slot", "sidebar-menu-item");
|
||||
});
|
||||
|
||||
it("SidebarMenuButton uses data-slot='sidebar-menu-button'", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarMenuButton data-testid="b">Click</SidebarMenuButton>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-button");
|
||||
});
|
||||
|
||||
it("SidebarMenuButton supports isActive", () => {
|
||||
render(
|
||||
<SidebarProvider>
|
||||
<SidebarMenuButton data-testid="b" isActive>
|
||||
x
|
||||
</SidebarMenuButton>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
// data-active is set as a presence attribute when isActive is truthy
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-active");
|
||||
});
|
||||
|
||||
it("SidebarMenuAction uses data-slot='sidebar-menu-action'", () => {
|
||||
render(<SidebarMenuAction data-testid="a">x</SidebarMenuAction>);
|
||||
expect(screen.getByTestId("a")).toHaveAttribute("data-slot", "sidebar-menu-action");
|
||||
});
|
||||
|
||||
it("SidebarMenuBadge uses data-slot='sidebar-menu-badge'", () => {
|
||||
render(<SidebarMenuBadge data-testid="b">5</SidebarMenuBadge>);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-badge");
|
||||
});
|
||||
|
||||
it("SidebarMenuSkeleton renders without throwing", () => {
|
||||
render(<SidebarMenuSkeleton data-testid="sk" />);
|
||||
expect(screen.getByTestId("sk")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("SidebarMenuSkeleton supports showIcon", () => {
|
||||
const { container } = render(<SidebarMenuSkeleton showIcon />);
|
||||
// Should render the icon Skeleton
|
||||
expect(container.querySelectorAll("[data-slot=skeleton]").length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarMenuSub family", () => {
|
||||
it("SidebarMenuSub uses data-slot='sidebar-menu-sub' and is <ul>", () => {
|
||||
render(<SidebarMenuSub data-testid="ms" />);
|
||||
const ms = screen.getByTestId("ms");
|
||||
expect(ms.tagName).toBe("UL");
|
||||
expect(ms).toHaveAttribute("data-slot", "sidebar-menu-sub");
|
||||
});
|
||||
|
||||
it("SidebarMenuSubItem uses data-slot='sidebar-menu-sub-item' and is <li>", () => {
|
||||
render(<SidebarMenuSubItem data-testid="ms">x</SidebarMenuSubItem>);
|
||||
const ms = screen.getByTestId("ms");
|
||||
expect(ms.tagName).toBe("LI");
|
||||
expect(ms).toHaveAttribute("data-slot", "sidebar-menu-sub-item");
|
||||
});
|
||||
|
||||
it("SidebarMenuSubButton uses data-slot='sidebar-menu-sub-button'", () => {
|
||||
render(<SidebarMenuSubButton data-testid="b">x</SidebarMenuSubButton>);
|
||||
expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-sub-button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sidebar composition", () => {
|
||||
it("composes a complete sidebar without errors", () => {
|
||||
expect(() =>
|
||||
renderInProvider(
|
||||
<>
|
||||
<SidebarHeader>Header</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Group 1</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>Item</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>Footer</SidebarFooter>
|
||||
</>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Skeleton } from "./skeleton";
|
||||
|
||||
describe("Skeleton", () => {
|
||||
it("renders with data-slot='skeleton'", () => {
|
||||
render(<Skeleton data-testid="sk" />);
|
||||
expect(screen.getByTestId("sk")).toHaveAttribute("data-slot", "skeleton");
|
||||
});
|
||||
|
||||
it("applies pulse animation classes", () => {
|
||||
render(<Skeleton data-testid="sk" />);
|
||||
expect(screen.getByTestId("sk")).toHaveClass("animate-pulse");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Skeleton className="h-10 w-20" data-testid="sk" />);
|
||||
const skeleton = screen.getByTestId("sk");
|
||||
expect(skeleton).toHaveClass("h-10");
|
||||
expect(skeleton).toHaveClass("w-20");
|
||||
});
|
||||
|
||||
it("forwards arbitrary props", () => {
|
||||
render(<Skeleton data-testid="sk" aria-busy="true" />);
|
||||
expect(screen.getByTestId("sk")).toHaveAttribute("aria-busy", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Slider } from "./slider";
|
||||
|
||||
describe("Slider", () => {
|
||||
it("renders with data-slot='slider'", () => {
|
||||
render(<Slider data-testid="s" defaultValue={[50]} />);
|
||||
expect(screen.getByTestId("s")).toHaveAttribute("data-slot", "slider");
|
||||
});
|
||||
|
||||
it("renders one thumb per value", () => {
|
||||
const { container } = render(<Slider defaultValue={[10, 90]} />);
|
||||
expect(container.querySelectorAll("[data-slot=slider-thumb]")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders single thumb for single value", () => {
|
||||
const { container } = render(<Slider defaultValue={[50]} />);
|
||||
expect(container.querySelectorAll("[data-slot=slider-thumb]")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders track and range", () => {
|
||||
const { container } = render(<Slider defaultValue={[50]} />);
|
||||
expect(container.querySelector("[data-slot=slider-track]")).toBeInTheDocument();
|
||||
expect(container.querySelector("[data-slot=slider-range]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects custom min and max", () => {
|
||||
render(<Slider data-testid="s" defaultValue={[5]} min={0} max={10} />);
|
||||
// Value, min, max are reflected on the root element via aria attrs in some envs
|
||||
expect(screen.getByTestId("s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Slider data-testid="s" defaultValue={[50]} className="custom-slider" />);
|
||||
expect(screen.getByTestId("s")).toHaveClass("custom-slider");
|
||||
});
|
||||
|
||||
it("renders 2 thumbs with default min/max if no defaultValue or value passed", () => {
|
||||
const { container } = render(<Slider />);
|
||||
// Source code: defaults to [min, max] when neither value nor defaultValue is array
|
||||
expect(container.querySelectorAll("[data-slot=slider-thumb]")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Toaster } from "./sonner";
|
||||
|
||||
describe("Toaster", () => {
|
||||
it("renders without errors when wrapped in ThemeProvider", () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
// Sonner renders into document.body, but we just verify no crash
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards arbitrary props to underlying Sonner", () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<Toaster position="top-right" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not throw when rendered without theme context", () => {
|
||||
// Toaster falls back to "system" theme when next-themes context is absent.
|
||||
expect(() => render(<Toaster />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Spinner } from "./spinner";
|
||||
|
||||
describe("Spinner", () => {
|
||||
it("renders with role='status' for screen readers", () => {
|
||||
render(<Spinner />);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has accessible label 'Loading'", () => {
|
||||
render(<Spinner />);
|
||||
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Spinner className="my-spinner" />);
|
||||
expect(screen.getByRole("status")).toHaveClass("my-spinner");
|
||||
});
|
||||
|
||||
it("applies animate-spin by default", () => {
|
||||
render(<Spinner />);
|
||||
expect(screen.getByRole("status")).toHaveClass("animate-spin");
|
||||
});
|
||||
|
||||
it("uses currentColor by default (mapped to SVG fill/stroke by Phosphor)", () => {
|
||||
render(<Spinner />);
|
||||
const svg = screen.getByRole("status");
|
||||
// Phosphor maps the `color` prop to inline style (fill/stroke), not an attribute.
|
||||
// The hex/RGB or "currentColor" appears somewhere in style/attr — verify presence in serialized output.
|
||||
expect(svg.outerHTML).toContain("currentColor");
|
||||
});
|
||||
|
||||
it("respects color override", () => {
|
||||
render(<Spinner color="red" />);
|
||||
const svg = screen.getByRole("status");
|
||||
expect(svg.outerHTML).toMatch(/red/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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("renders with data-slot='switch'", () => {
|
||||
render(<Switch data-testid="sw" />);
|
||||
expect(screen.getByTestId("sw")).toHaveAttribute("data-slot", "switch");
|
||||
});
|
||||
|
||||
it("defaults size to 'default'", () => {
|
||||
render(<Switch data-testid="sw" />);
|
||||
expect(screen.getByTestId("sw")).toHaveAttribute("data-size", "default");
|
||||
});
|
||||
|
||||
it("supports size='sm'", () => {
|
||||
render(<Switch data-testid="sw" size="sm" />);
|
||||
expect(screen.getByTestId("sw")).toHaveAttribute("data-size", "sm");
|
||||
});
|
||||
|
||||
it("starts unchecked by default", () => {
|
||||
render(<Switch data-testid="sw" />);
|
||||
expect(screen.getByTestId("sw").getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("respects defaultChecked", () => {
|
||||
render(<Switch data-testid="sw" defaultChecked />);
|
||||
expect(screen.getByTestId("sw").getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls onCheckedChange when toggled", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Switch data-testid="sw" onCheckedChange={onChange} />);
|
||||
await userEvent.click(screen.getByTestId("sw"));
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
expect(onChange).toHaveBeenCalledWith(true, expect.anything());
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Switch data-testid="sw" className="my-custom" />);
|
||||
expect(screen.getByTestId("sw")).toHaveClass("my-custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
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";
|
||||
|
||||
const renderTabs = (props: Partial<Parameters<typeof Tabs>[0]> = {}) => {
|
||||
return render(
|
||||
<Tabs defaultValue="a" {...props}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="a">Tab A</TabsTrigger>
|
||||
<TabsTrigger value="b">Tab B</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a">Content A</TabsContent>
|
||||
<TabsContent value="b">Content B</TabsContent>
|
||||
</Tabs>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Tabs", () => {
|
||||
it("uses data-slot='tabs'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults orientation to 'horizontal'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs]")).toHaveAttribute("data-orientation", "horizontal");
|
||||
});
|
||||
|
||||
it("supports vertical orientation", () => {
|
||||
const { container } = renderTabs({ orientation: "vertical" });
|
||||
expect(container.querySelector("[data-slot=tabs]")).toHaveAttribute("data-orientation", "vertical");
|
||||
});
|
||||
|
||||
it("renders the default selected tab content", () => {
|
||||
renderTabs();
|
||||
expect(screen.getByText("Content A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the other tab on click", async () => {
|
||||
renderTabs();
|
||||
await userEvent.click(screen.getByText("Tab B"));
|
||||
expect(screen.getByText("Content B")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className on root", () => {
|
||||
const { container } = render(
|
||||
<Tabs defaultValue="a" className="my-tabs">
|
||||
<TabsList>
|
||||
<TabsTrigger value="a">A</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a">x</TabsContent>
|
||||
</Tabs>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=tabs]")).toHaveClass("my-tabs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabsList", () => {
|
||||
it("uses data-slot='tabs-list'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs-list]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults variant to 'default'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs-list]")).toHaveAttribute("data-variant", "default");
|
||||
});
|
||||
|
||||
it("supports variant='line'", () => {
|
||||
const { container } = render(
|
||||
<Tabs defaultValue="a">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="a">A</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a">x</TabsContent>
|
||||
</Tabs>,
|
||||
);
|
||||
expect(container.querySelector("[data-slot=tabs-list]")).toHaveAttribute("data-variant", "line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabsTrigger", () => {
|
||||
it("uses data-slot='tabs-trigger'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs-trigger]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabsContent", () => {
|
||||
it("uses data-slot='tabs-content'", () => {
|
||||
const { container } = renderTabs();
|
||||
expect(container.querySelector("[data-slot=tabs-content]")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
describe("Textarea", () => {
|
||||
it("renders a <textarea> element", () => {
|
||||
render(<Textarea data-testid="t" />);
|
||||
expect(screen.getByTestId("t").tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
it("applies data-slot='textarea'", () => {
|
||||
render(<Textarea data-testid="t" />);
|
||||
expect(screen.getByTestId("t")).toHaveAttribute("data-slot", "textarea");
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(<Textarea data-testid="t" className="custom" />);
|
||||
expect(screen.getByTestId("t")).toHaveClass("custom");
|
||||
});
|
||||
|
||||
it("calls onChange when typing", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Textarea data-testid="t" onChange={onChange} />);
|
||||
await userEvent.type(screen.getByTestId("t"), "hi");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects disabled state", () => {
|
||||
render(<Textarea data-testid="t" disabled />);
|
||||
expect(screen.getByTestId("t")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("forwards rows and cols", () => {
|
||||
render(<Textarea data-testid="t" rows={5} cols={40} />);
|
||||
expect(screen.getByTestId("t")).toHaveAttribute("rows", "5");
|
||||
expect(screen.getByTestId("t")).toHaveAttribute("cols", "40");
|
||||
});
|
||||
|
||||
it("supports controlled value", () => {
|
||||
render(<Textarea data-testid="t" value="hello" onChange={() => {}} />);
|
||||
expect(screen.getByTestId("t")).toHaveValue("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Toggle } from "./toggle";
|
||||
|
||||
describe("Toggle", () => {
|
||||
it("renders with data-slot='toggle'", () => {
|
||||
render(<Toggle data-testid="t">on</Toggle>);
|
||||
expect(screen.getByTestId("t")).toHaveAttribute("data-slot", "toggle");
|
||||
});
|
||||
|
||||
it("starts unpressed by default", () => {
|
||||
render(<Toggle data-testid="t">on</Toggle>);
|
||||
expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("can be controlled via pressed prop", () => {
|
||||
render(
|
||||
<Toggle data-testid="t" pressed>
|
||||
on
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls onPressedChange when clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Toggle data-testid="t" onPressedChange={onChange}>
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("t"));
|
||||
expect(onChange).toHaveBeenCalledWith(true, expect.anything());
|
||||
});
|
||||
|
||||
it.each([["default"], ["outline"]] as const)("renders variant=%s", (variant) => {
|
||||
render(
|
||||
<Toggle data-testid="t" variant={variant}>
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([["default"], ["sm"], ["lg"]] as const)("renders size=%s", (size) => {
|
||||
render(
|
||||
<Toggle data-testid="t" size={size}>
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Toggle data-testid="t" className="my-custom">
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toHaveClass("my-custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
||||
|
||||
describe("Tooltip", () => {
|
||||
it("trigger has data-slot='tooltip-trigger'", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger data-testid="trigger">Hover me</TooltipTrigger>
|
||||
<TooltipContent>Helpful info</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "tooltip-trigger");
|
||||
});
|
||||
|
||||
it("does not show content by default", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>Hover me</TooltipTrigger>
|
||||
<TooltipContent>Helpful info</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.queryByText("Helpful info")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows content when controlled open", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger>Hover me</TooltipTrigger>
|
||||
<TooltipContent>Helpful info</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("Helpful info")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("content has data-slot='tooltip-content' when shown", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger>Hover me</TooltipTrigger>
|
||||
<TooltipContent>Helpful info</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("Helpful info")).toHaveAttribute("data-slot", "tooltip-content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TooltipProvider", () => {
|
||||
it("renders children without errors", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<div data-testid="child">child</div>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accepts custom delay", () => {
|
||||
render(
|
||||
<TooltipProvider delay={500}>
|
||||
<div>x</div>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
// No throw = pass
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TooltipContent", () => {
|
||||
it("merges custom className when shown", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger>x</TooltipTrigger>
|
||||
<TooltipContent className="my-tooltip">visible</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("visible")).toHaveClass("my-tooltip");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user