Select and Context Menu component with storybook and testing
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
import React from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, describe, it, vi } from "vitest";
|
||||
import { axe, toHaveNoViolations } from "jest-axe";
|
||||
import ContextMenu from "../../app/components/ContextMenu";
|
||||
import ContextMenuItem from "../../app/components/ContextMenuItem";
|
||||
import ContextMenuSection from "../../app/components/ContextMenuSection";
|
||||
import ContextMenuDivider from "../../app/components/ContextMenuDivider";
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
describe("ContextMenu Components Accessibility", () => {
|
||||
describe("ContextMenu Accessibility", () => {
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 2</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has proper role and structure", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 2</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu).toBeInTheDocument();
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("has proper focus management", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 2</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const firstItem = screen.getByRole("menuitem", { name: "Item 1" });
|
||||
expect(firstItem).toHaveAttribute("tabIndex", "0");
|
||||
expect(firstItem).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuItem Accessibility", () => {
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()}>Test Item</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has proper ARIA attributes", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()}>Test Item</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
it("updates aria-current when selected", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={true}>
|
||||
Test Item
|
||||
</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveAttribute("aria-current", "true");
|
||||
});
|
||||
|
||||
it("is keyboard accessible", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={onClick}>Test Item</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
item.focus();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is accessible with Space key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={onClick}>Test Item</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
item.focus();
|
||||
|
||||
await user.keyboard(" ");
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has proper focus indicators", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()}>Test Item</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveClass(
|
||||
"hover:!bg-[var(--color-surface-default-secondary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("announces selection state to screen readers", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={true}>
|
||||
Test Item
|
||||
</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveAttribute("aria-current", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuSection Accessibility", () => {
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(
|
||||
<ContextMenu>
|
||||
<ContextMenuSection title="Test Section">
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
</ContextMenu>
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has proper heading structure", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuSection title="Test Section">
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const title = screen.getByText("Test Section");
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has sufficient color contrast for section title", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuSection title="Test Section">
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const title = screen.getByText("Test Section");
|
||||
expect(title).toHaveClass("text-[var(--color-content-default-primary)]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuDivider Accessibility", () => {
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(<ContextMenuDivider />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has proper semantic structure", () => {
|
||||
render(<ContextMenuDivider />);
|
||||
|
||||
const divider = screen.getByRole("separator");
|
||||
expect(divider).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has sufficient visual contrast", () => {
|
||||
render(<ContextMenuDivider />);
|
||||
|
||||
const divider = screen.getByRole("separator");
|
||||
expect(divider).toHaveClass(
|
||||
"border-[var(--color-border-default-tertiary)]"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integrated Menu Accessibility", () => {
|
||||
const TestMenu = () => (
|
||||
<ContextMenu>
|
||||
<ContextMenuSection title="First Section">
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={true}>
|
||||
Item 2
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuSection title="Second Section">
|
||||
<ContextMenuItem onClick={vi.fn()} hasSubmenu={true}>
|
||||
Item 3
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
it("has no accessibility violations when integrated", async () => {
|
||||
const { container } = render(<TestMenu />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has proper menu structure", () => {
|
||||
render(<TestMenu />);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu).toBeInTheDocument();
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
expect(screen.getByText("First Section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second Section")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maintains proper focus order", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestMenu />);
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
// Check that all items are focusable
|
||||
items.forEach((item) => {
|
||||
expect(item).toHaveAttribute("tabIndex", "0");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles keyboard navigation correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={onClick}>Item 1</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 2</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
items[0].focus();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Color Contrast", () => {
|
||||
it("has sufficient contrast for menu items", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()}>Test Item</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveClass(
|
||||
"text-[var(--color-content-default-brand-primary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("has sufficient contrast for section titles", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuSection title="Test Section" />
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const title = screen.getByText("Test Section");
|
||||
expect(title).toHaveClass("text-[var(--color-content-default-primary)]");
|
||||
});
|
||||
|
||||
it("has sufficient contrast for dividers", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuDivider />
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const divider = screen.getByRole("separator");
|
||||
expect(divider).toHaveClass(
|
||||
"border-[var(--color-border-default-tertiary)]"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Screen Reader Support", () => {
|
||||
it("announces menu structure correctly", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuSection title="Test Section">
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={true}>
|
||||
Item 2
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu).toBeInTheDocument();
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items[0]).not.toHaveAttribute("aria-current");
|
||||
expect(items[1]).toHaveAttribute("aria-current", "true");
|
||||
});
|
||||
|
||||
it("announces selection state changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<ContextMenuItem onClick={vi.fn()} selected={false}>
|
||||
Test Item
|
||||
</ContextMenuItem>
|
||||
);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).not.toHaveAttribute("aria-current");
|
||||
|
||||
rerender(
|
||||
<ContextMenuItem onClick={vi.fn()} selected={true}>
|
||||
Test Item
|
||||
</ContextMenuItem>
|
||||
);
|
||||
|
||||
expect(item).toHaveAttribute("aria-current", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WCAG Compliance", () => {
|
||||
it("meets WCAG 2.1 AA standards", async () => {
|
||||
const { container } = render(
|
||||
<ContextMenu>
|
||||
<ContextMenuSection title="Test Section">
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={true}>
|
||||
Item 2
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 3</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("meets WCAG standards in all states", async () => {
|
||||
const { container } = render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={true}>
|
||||
Selected Item
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()} hasSubmenu={true}>
|
||||
Submenu Item
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()} disabled={true}>
|
||||
Disabled Item
|
||||
</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import React from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, describe, it, vi } from "vitest";
|
||||
import { axe, toHaveNoViolations } from "jest-axe";
|
||||
import Select from "../../app/components/Select";
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
describe("Select Component Accessibility", () => {
|
||||
const defaultProps = {
|
||||
label: "Test Select",
|
||||
placeholder: "Select an option",
|
||||
options: [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
{ value: "option3", label: "Option 3" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("ARIA Attributes", () => {
|
||||
it("has correct initial ARIA attributes", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(selectButton).toHaveAttribute("aria-haspopup", "listbox");
|
||||
});
|
||||
|
||||
it("updates aria-expanded when dropdown opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(selectButton).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
|
||||
it("has proper role for dropdown menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("has proper role for menu items", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const options = screen.getAllByRole("menuitem");
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0]).toHaveTextContent("Option 1");
|
||||
expect(options[1]).toHaveTextContent("Option 2");
|
||||
expect(options[2]).toHaveTextContent("Option 3");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keyboard Navigation", () => {
|
||||
it("opens dropdown with Enter key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
selectButton.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens dropdown with Space key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
selectButton.focus();
|
||||
await user.keyboard(" ");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown with Escape key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("selects option with click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Select {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
target: { value: "option1", text: "Option 1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Screen Reader Support", () => {
|
||||
it("announces selected option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} value="option2" />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveTextContent("Option 2");
|
||||
});
|
||||
|
||||
it("announces placeholder when no option selected", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveTextContent("Select an option");
|
||||
});
|
||||
|
||||
it("has accessible name from label", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveAccessibleName("Test Select");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Focus Management", () => {
|
||||
it("maintains focus on select button when dropdown opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(selectButton).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns focus to select button after selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(selectButton).toHaveFocus();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disabled State", () => {
|
||||
it("is not focusable when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} disabled={true} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toBeDisabled();
|
||||
|
||||
await user.tab();
|
||||
expect(selectButton).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("has correct ARIA attributes when disabled", () => {
|
||||
render(<Select {...defaultProps} disabled={true} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error State", () => {
|
||||
it("announces error state to screen readers", () => {
|
||||
render(<Select {...defaultProps} error={true} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass(
|
||||
"border-[var(--color-border-default-utility-negative)]"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WCAG Compliance", () => {
|
||||
it("meets WCAG 2.1 AA standards", async () => {
|
||||
const { container } = render(<Select {...defaultProps} />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("meets WCAG standards in disabled state", async () => {
|
||||
const { container } = render(
|
||||
<Select {...defaultProps} disabled={true} />
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("meets WCAG standards in error state", async () => {
|
||||
const { container } = render(<Select {...defaultProps} error={true} />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("meets WCAG standards when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Color Contrast", () => {
|
||||
it("has sufficient color contrast for text", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass(
|
||||
"text-[var(--color-content-default-primary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("has sufficient color contrast for labels", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const label = screen.getByText("Test Select");
|
||||
expect(label).toHaveClass("text-[var(--color-content-default-primary)]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Focus Indicators", () => {
|
||||
it("has visible focus indicator", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass(
|
||||
"focus-visible:border-[var(--color-border-default-utility-info)]"
|
||||
);
|
||||
expect(selectButton).toHaveClass(
|
||||
"focus-visible:shadow-[0_0_5px_3px_#3281F8]"
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes between focus and hover states", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
// Focus state should be different from hover state
|
||||
expect(selectButton).toHaveClass(
|
||||
"focus-visible:border-[var(--color-border-default-utility-info)]"
|
||||
);
|
||||
expect(selectButton).toHaveClass(
|
||||
"hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -312,6 +312,6 @@ describe("RadioGroup Accessibility", () => {
|
||||
expect(handleChange).toHaveBeenCalledWith({ value: "option2" });
|
||||
|
||||
// Only one should be selected at a time
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
expect(handleChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("ContextMenu Components Storybook Tests", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--default"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders default context menu", async ({ page }) => {
|
||||
const menu = page.getByRole("listbox");
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const items = page.getByRole("option");
|
||||
const count = await items.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders menu items correctly", async ({ page }) => {
|
||||
const menuItems = page.getByRole("option");
|
||||
const count = await menuItems.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(menuItems.nth(i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("handles menu item clicks", async ({ page }) => {
|
||||
const menuItems = page.getByRole("option");
|
||||
const firstItem = menuItems.first();
|
||||
|
||||
await firstItem.click();
|
||||
|
||||
// Check that click was handled (no error should occur)
|
||||
await expect(firstItem).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows selected state correctly", async ({ page }) => {
|
||||
// Navigate to MenuItem story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--menu-item"
|
||||
);
|
||||
|
||||
const menuItems = page.getByRole("option");
|
||||
const count = await menuItems.count();
|
||||
|
||||
// Check that at least one item has selected state
|
||||
let hasSelected = false;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const isSelected = await menuItems.nth(i).getAttribute("aria-selected");
|
||||
if (isSelected === "true") {
|
||||
hasSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasSelected).toBe(true);
|
||||
});
|
||||
|
||||
test("shows submenu indicators", async ({ page }) => {
|
||||
// Navigate to MenuItem story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--menu-item"
|
||||
);
|
||||
|
||||
const submenuArrows = page.getByTestId("submenu-arrow");
|
||||
const count = await submenuArrows.count();
|
||||
|
||||
if (count > 0) {
|
||||
await expect(submenuArrows.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows checkmarks for selected items", async ({ page }) => {
|
||||
// Navigate to MenuItem story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--menu-item"
|
||||
);
|
||||
|
||||
const checkmarks = page.getByTestId("checkmark");
|
||||
const count = await checkmarks.count();
|
||||
|
||||
if (count > 0) {
|
||||
await expect(checkmarks.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("renders menu sections correctly", async ({ page }) => {
|
||||
// Navigate to MenuSection story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--menu-section"
|
||||
);
|
||||
|
||||
const sectionTitles = page.getByText(/Section/);
|
||||
const count = await sectionTitles.count();
|
||||
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(sectionTitles.nth(i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("renders menu dividers correctly", async ({ page }) => {
|
||||
// Navigate to MenuDivider story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--menu-divider"
|
||||
);
|
||||
|
||||
const dividers = page.getByTestId("context-menu-divider");
|
||||
const count = await dividers.count();
|
||||
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(dividers.nth(i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows all variants correctly", async ({ page }) => {
|
||||
// Navigate to All Variants story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--all-variants"
|
||||
);
|
||||
|
||||
const menu = page.getByRole("listbox");
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const menuItems = page.getByRole("option");
|
||||
const count = await menuItems.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
// Check for sections
|
||||
const sectionTitles = page.getByText(/Section/);
|
||||
const sectionCount = await sectionTitles.count();
|
||||
expect(sectionCount).toBeGreaterThan(0);
|
||||
|
||||
// Check for dividers
|
||||
const dividers = page.getByTestId("context-menu-divider");
|
||||
const dividerCount = await dividers.count();
|
||||
expect(dividerCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("handles keyboard navigation", async ({ page }) => {
|
||||
const menuItems = page.getByRole("option");
|
||||
const firstItem = menuItems.first();
|
||||
|
||||
await firstItem.focus();
|
||||
await expect(firstItem).toBeFocused();
|
||||
|
||||
// Navigate with arrow keys
|
||||
await page.keyboard.press("ArrowDown");
|
||||
const secondItem = menuItems.nth(1);
|
||||
await expect(secondItem).toBeFocused();
|
||||
});
|
||||
|
||||
test("handles Enter key selection", async ({ page }) => {
|
||||
const menuItems = page.getByRole("option");
|
||||
const firstItem = menuItems.first();
|
||||
|
||||
await firstItem.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
// Should handle the selection without error
|
||||
await expect(firstItem).toBeVisible();
|
||||
});
|
||||
|
||||
test("handles Space key selection", async ({ page }) => {
|
||||
const menuItems = page.getByRole("option");
|
||||
const firstItem = menuItems.first();
|
||||
|
||||
await firstItem.focus();
|
||||
await page.keyboard.press(" ");
|
||||
|
||||
// Should handle the selection without error
|
||||
await expect(firstItem).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows hover effects", async ({ page }) => {
|
||||
const menuItems = page.getByRole("option");
|
||||
const firstItem = menuItems.first();
|
||||
|
||||
await firstItem.hover();
|
||||
|
||||
// Check that hover styles are applied
|
||||
const backgroundColor = await firstItem.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.backgroundColor;
|
||||
});
|
||||
|
||||
// Should have some background color change on hover
|
||||
expect(backgroundColor).toBeDefined();
|
||||
});
|
||||
|
||||
test("has correct styling for different sizes", async ({ page }) => {
|
||||
// Navigate to All Variants story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--all-variants"
|
||||
);
|
||||
|
||||
const menuItems = page.getByRole("option");
|
||||
const count = await menuItems.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = menuItems.nth(i);
|
||||
await expect(item).toBeVisible();
|
||||
|
||||
// Check that items have proper text styling
|
||||
const fontSize = await item.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.fontSize;
|
||||
});
|
||||
|
||||
expect(fontSize).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("has proper ARIA attributes", async ({ page }) => {
|
||||
const menu = page.getByRole("listbox");
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const menuItems = page.getByRole("option");
|
||||
const count = await menuItems.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = menuItems.nth(i);
|
||||
const ariaSelected = await item.getAttribute("aria-selected");
|
||||
expect(ariaSelected).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("handles disabled items correctly", async ({ page }) => {
|
||||
// Navigate to All Variants story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--all-variants"
|
||||
);
|
||||
|
||||
const menuItems = page.getByRole("option");
|
||||
const count = await menuItems.count();
|
||||
|
||||
// Check for disabled items
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = menuItems.nth(i);
|
||||
const isDisabled = await item.isDisabled();
|
||||
|
||||
if (isDisabled) {
|
||||
// Disabled items should not respond to clicks
|
||||
await item.click();
|
||||
// Should not cause any errors
|
||||
await expect(item).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("has proper color contrast", async ({ page }) => {
|
||||
const menuItems = page.getByRole("option");
|
||||
const firstItem = menuItems.first();
|
||||
|
||||
const color = await firstItem.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.color;
|
||||
});
|
||||
|
||||
expect(color).toBeDefined();
|
||||
expect(color).not.toBe("rgba(0, 0, 0, 0)"); // Should not be transparent
|
||||
});
|
||||
|
||||
test("renders with custom styling", async ({ page }) => {
|
||||
// Navigate to With Custom Styling story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--with-custom-styling"
|
||||
);
|
||||
|
||||
const menu = page.getByRole("listbox");
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
// Check that custom styling is applied
|
||||
const customClass = await menu.getAttribute("class");
|
||||
expect(customClass).toContain("custom-menu");
|
||||
});
|
||||
|
||||
test("handles interactive story correctly", async ({ page }) => {
|
||||
// Navigate to Interactive story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-contextmenu--interactive"
|
||||
);
|
||||
|
||||
const menuItems = page.getByRole("option");
|
||||
const count = await menuItems.count();
|
||||
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
// Test interaction with different items
|
||||
for (let i = 0; i < Math.min(count, 3); i++) {
|
||||
const item = menuItems.nth(i);
|
||||
await item.click();
|
||||
|
||||
// Should handle click without error
|
||||
await expect(item).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Select Component Storybook Tests", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("http://localhost:6006/?path=/story/forms-select--default");
|
||||
});
|
||||
|
||||
test("renders default select component", async ({ page }) => {
|
||||
const selectButton = page.getByRole("button", { name: /select/i });
|
||||
await expect(selectButton).toBeVisible();
|
||||
|
||||
const label = page.getByText("Test Select");
|
||||
await expect(label).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens dropdown when clicked", async ({ page }) => {
|
||||
const selectButton = page.getByRole("button", { name: /select/i });
|
||||
await selectButton.click();
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
await expect(page.getByText("Option 1")).toBeVisible();
|
||||
await expect(page.getByText("Option 2")).toBeVisible();
|
||||
await expect(page.getByText("Option 3")).toBeVisible();
|
||||
});
|
||||
|
||||
test("selects option when clicked", async ({ page }) => {
|
||||
const selectButton = page.getByRole("button", { name: /select/i });
|
||||
await selectButton.click();
|
||||
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
|
||||
await page.getByText("Option 1").click();
|
||||
|
||||
// Check that the selected value is displayed
|
||||
await expect(selectButton).toContainText("Option 1");
|
||||
|
||||
// Check that dropdown is closed
|
||||
await expect(page.getByRole("listbox")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("closes dropdown when clicking outside", async ({ page }) => {
|
||||
const selectButton = page.getByRole("button", { name: /select/i });
|
||||
await selectButton.click();
|
||||
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
|
||||
// Click outside the dropdown
|
||||
await page.click("body", { position: { x: 10, y: 10 } });
|
||||
|
||||
await expect(page.getByRole("listbox")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("handles keyboard navigation", async ({ page }) => {
|
||||
const selectButton = page.getByRole("button", { name: /select/i });
|
||||
await selectButton.focus();
|
||||
|
||||
// Open with Enter key
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
|
||||
// Close with Escape key
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("listbox")).not.toBeVisible();
|
||||
|
||||
// Open with Space key
|
||||
await page.keyboard.press(" ");
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows different sizes correctly", async ({ page }) => {
|
||||
// Navigate to All Sizes story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-select--all-sizes"
|
||||
);
|
||||
|
||||
const selectButtons = page.getByRole("button");
|
||||
const count = await selectButtons.count();
|
||||
|
||||
// Should have multiple select components
|
||||
expect(count).toBeGreaterThan(1);
|
||||
|
||||
// Test that all sizes are visible
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(selectButtons.nth(i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows different states correctly", async ({ page }) => {
|
||||
// Navigate to All States story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-select--all-states"
|
||||
);
|
||||
|
||||
const selectButtons = page.getByRole("button");
|
||||
const count = await selectButtons.count();
|
||||
|
||||
// Should have multiple select components in different states
|
||||
expect(count).toBeGreaterThan(1);
|
||||
|
||||
// Test that all states are visible
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(selectButtons.nth(i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("hover state shows correct styling", async ({ page }) => {
|
||||
// Navigate to Hover story
|
||||
await page.goto("http://localhost:6006/?path=/story/forms-select--hover");
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
await expect(selectButton).toBeVisible();
|
||||
|
||||
// Check that hover state is applied (shadow effect)
|
||||
const boxShadow = await selectButton.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.boxShadow;
|
||||
});
|
||||
|
||||
expect(boxShadow).toContain("2px");
|
||||
});
|
||||
|
||||
test("focus state shows correct styling", async ({ page }) => {
|
||||
// Navigate to Focus story
|
||||
await page.goto("http://localhost:6006/?path=/story/forms-select--focus");
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
await expect(selectButton).toBeVisible();
|
||||
|
||||
// Check that focus state is applied (blue border and shadow)
|
||||
const borderColor = await selectButton.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.borderColor;
|
||||
});
|
||||
|
||||
const boxShadow = await selectButton.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.boxShadow;
|
||||
});
|
||||
|
||||
expect(boxShadow).toContain("3px");
|
||||
});
|
||||
|
||||
test("error state shows correct styling", async ({ page }) => {
|
||||
// Navigate to Error story
|
||||
await page.goto("http://localhost:6006/?path=/story/forms-select--error");
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
await expect(selectButton).toBeVisible();
|
||||
|
||||
// Check that error state is applied (red border)
|
||||
const borderColor = await selectButton.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.borderColor;
|
||||
});
|
||||
|
||||
expect(borderColor).toContain("rgb");
|
||||
});
|
||||
|
||||
test("disabled state prevents interaction", async ({ page }) => {
|
||||
// Navigate to Disabled story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-select--disabled"
|
||||
);
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
await expect(selectButton).toBeVisible();
|
||||
await expect(selectButton).toBeDisabled();
|
||||
|
||||
// Try to click disabled select
|
||||
await selectButton.click();
|
||||
|
||||
// Dropdown should not open
|
||||
await expect(page.getByRole("listbox")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("interactive story allows selection", async ({ page }) => {
|
||||
// Navigate to Interactive story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-select--interactive"
|
||||
);
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
await selectButton.click();
|
||||
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
|
||||
// Select an option
|
||||
await page.getByText("Option 1").click();
|
||||
|
||||
// Check that selection is reflected
|
||||
await expect(selectButton).toContainText("Option 1");
|
||||
});
|
||||
|
||||
test("horizontal label variant displays correctly", async ({ page }) => {
|
||||
// Navigate to Horizontal Label story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-select--horizontal-label"
|
||||
);
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
await expect(selectButton).toBeVisible();
|
||||
|
||||
const label = page.getByText("Test Select");
|
||||
await expect(label).toBeVisible();
|
||||
|
||||
// Check that label and select are in horizontal layout
|
||||
const labelBox = await label.boundingBox();
|
||||
const selectBox = await selectButton.boundingBox();
|
||||
|
||||
expect(labelBox?.y).toBeCloseTo(selectBox?.y || 0, 5);
|
||||
});
|
||||
|
||||
test("small size has correct height", async ({ page }) => {
|
||||
// Navigate to Small story
|
||||
await page.goto("http://localhost:6006/?path=/story/forms-select--small");
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
await expect(selectButton).toBeVisible();
|
||||
|
||||
const height = await selectButton.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.height;
|
||||
});
|
||||
|
||||
expect(height).toBe("30px");
|
||||
});
|
||||
|
||||
test("medium size has correct height", async ({ page }) => {
|
||||
// Navigate to Medium story
|
||||
await page.goto("http://localhost:6006/?path=/story/forms-select--medium");
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
await expect(selectButton).toBeVisible();
|
||||
|
||||
const height = await selectButton.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.height;
|
||||
});
|
||||
|
||||
expect(height).toBe("36px");
|
||||
});
|
||||
|
||||
test("large size has correct height", async ({ page }) => {
|
||||
// Navigate to Large story
|
||||
await page.goto("http://localhost:6006/?path=/story/forms-select--large");
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
await expect(selectButton).toBeVisible();
|
||||
|
||||
const height = await selectButton.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.height;
|
||||
});
|
||||
|
||||
expect(height).toBe("40px");
|
||||
});
|
||||
|
||||
test("focus behavior works correctly", async ({ page }) => {
|
||||
// Navigate to Interactive story
|
||||
await page.goto(
|
||||
"http://localhost:6006/?path=/story/forms-select--interactive"
|
||||
);
|
||||
|
||||
const selectButton = page.getByRole("button");
|
||||
|
||||
// Tab to focus the select
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(selectButton).toBeFocused();
|
||||
|
||||
// Check that focus-visible styles are applied
|
||||
const boxShadow = await selectButton.evaluate((el) => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.boxShadow;
|
||||
});
|
||||
|
||||
// Should have focus indicator
|
||||
expect(boxShadow).toContain("3px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import React, { useState } from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, describe, it, vi } from "vitest";
|
||||
import ContextMenu from "../../app/components/ContextMenu";
|
||||
import ContextMenuItem from "../../app/components/ContextMenuItem";
|
||||
import ContextMenuSection from "../../app/components/ContextMenuSection";
|
||||
import ContextMenuDivider from "../../app/components/ContextMenuDivider";
|
||||
|
||||
describe("ContextMenu Components Integration", () => {
|
||||
const TestMenu = ({ onItemClick, selectedValue }) => (
|
||||
<ContextMenu>
|
||||
<ContextMenuSection title="Actions">
|
||||
<ContextMenuItem
|
||||
onClick={() => onItemClick("action1")}
|
||||
selected={selectedValue === "action1"}
|
||||
>
|
||||
Action 1
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onItemClick("action2")}
|
||||
selected={selectedValue === "action2"}
|
||||
>
|
||||
Action 2
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuSection title="Settings">
|
||||
<ContextMenuItem
|
||||
onClick={() => onItemClick("setting1")}
|
||||
hasSubmenu={true}
|
||||
>
|
||||
Setting 1
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onItemClick("setting2")}
|
||||
disabled={true}
|
||||
>
|
||||
Setting 2
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
describe("Menu Interaction", () => {
|
||||
it("handles item selection correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onItemClick = vi.fn();
|
||||
render(<TestMenu onItemClick={onItemClick} selectedValue="" />);
|
||||
|
||||
const action1 = screen.getByText("Action 1");
|
||||
await user.click(action1);
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledWith("action1");
|
||||
});
|
||||
|
||||
it("shows selected state correctly", () => {
|
||||
render(<TestMenu onItemClick={vi.fn()} selectedValue="action1" />);
|
||||
|
||||
const action1 = screen.getByRole("menuitem", { name: "Action 1" });
|
||||
expect(action1).toHaveClass(
|
||||
"bg-[var(--color-surface-default-secondary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles disabled items correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onItemClick = vi.fn();
|
||||
render(<TestMenu onItemClick={onItemClick} selectedValue="" />);
|
||||
|
||||
const setting2 = screen.getByText("Setting 2");
|
||||
await user.click(setting2);
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows submenu indicators correctly", () => {
|
||||
render(<TestMenu onItemClick={vi.fn()} selectedValue="" />);
|
||||
|
||||
const setting1 = screen.getByText("Setting 1");
|
||||
const arrow = screen
|
||||
.getByRole("menuitem", { name: "Setting 1" })
|
||||
.querySelector("svg");
|
||||
expect(arrow).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keyboard Navigation", () => {
|
||||
it("navigates through menu items with arrow keys", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestMenu onItemClick={vi.fn()} selectedValue="" />);
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items).toHaveLength(4);
|
||||
|
||||
// Check that enabled items are focusable and disabled items are not
|
||||
const enabledItems = items.filter(
|
||||
(item) =>
|
||||
!item.hasAttribute("aria-disabled") ||
|
||||
item.getAttribute("aria-disabled") !== "true"
|
||||
);
|
||||
const disabledItems = items.filter(
|
||||
(item) => item.getAttribute("aria-disabled") === "true"
|
||||
);
|
||||
|
||||
enabledItems.forEach((item) => {
|
||||
expect(item).toHaveAttribute("tabIndex", "0");
|
||||
});
|
||||
|
||||
disabledItems.forEach((item) => {
|
||||
expect(item).toHaveAttribute("tabIndex", "-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("selects items with Enter key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onItemClick = vi.fn();
|
||||
render(<TestMenu onItemClick={onItemClick} selectedValue="" />);
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
items[0].focus();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onItemClick).toHaveBeenCalledWith("action1");
|
||||
});
|
||||
|
||||
it("selects items with Space key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onItemClick = vi.fn();
|
||||
render(<TestMenu onItemClick={onItemClick} selectedValue="" />);
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
items[0].focus();
|
||||
|
||||
await user.keyboard(" ");
|
||||
expect(onItemClick).toHaveBeenCalledWith("action1");
|
||||
});
|
||||
|
||||
it("skips disabled items during navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestMenu onItemClick={vi.fn()} selectedValue="" />);
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items).toHaveLength(4);
|
||||
|
||||
// Check that disabled items have tabIndex="-1"
|
||||
const disabledItem = screen.getByRole("menuitem", { name: "Setting 2" });
|
||||
expect(disabledItem).toHaveAttribute("tabIndex", "-1");
|
||||
expect(disabledItem).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dynamic Menu Updates", () => {
|
||||
const DynamicMenu = ({ items, selectedValue, onItemClick }) => (
|
||||
<ContextMenu>
|
||||
{items.map((item, index) => (
|
||||
<ContextMenuItem
|
||||
key={item.id}
|
||||
onClick={() => onItemClick(item.id)}
|
||||
selected={selectedValue === item.id}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
it("handles dynamic item updates", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onItemClick = vi.fn();
|
||||
const { rerender } = render(
|
||||
<DynamicMenu
|
||||
items={[
|
||||
{ id: "1", label: "Item 1" },
|
||||
{ id: "2", label: "Item 2" },
|
||||
]}
|
||||
selectedValue=""
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const item1 = screen.getByText("Item 1");
|
||||
await user.click(item1);
|
||||
expect(onItemClick).toHaveBeenCalledWith("1");
|
||||
|
||||
// Update items
|
||||
rerender(
|
||||
<DynamicMenu
|
||||
items={[
|
||||
{ id: "1", label: "Item 1" },
|
||||
{ id: "2", label: "Item 2" },
|
||||
{ id: "3", label: "Item 3" },
|
||||
]}
|
||||
selectedValue="1"
|
||||
onItemClick={onItemClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Item 3")).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: "Item 1" })).toHaveClass(
|
||||
"bg-[var(--color-surface-default-secondary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles item removal", () => {
|
||||
const { rerender } = render(
|
||||
<DynamicMenu
|
||||
items={[
|
||||
{ id: "1", label: "Item 1" },
|
||||
{ id: "2", label: "Item 2" },
|
||||
{ id: "3", label: "Item 3" },
|
||||
]}
|
||||
selectedValue="2"
|
||||
onItemClick={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<DynamicMenu
|
||||
items={[
|
||||
{ id: "1", label: "Item 1" },
|
||||
{ id: "3", label: "Item 3" },
|
||||
]}
|
||||
selectedValue=""
|
||||
onItemClick={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Item 2")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Menu State Management", () => {
|
||||
const StatefulMenu = () => {
|
||||
const [selectedValue, setSelectedValue] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setIsOpen(!isOpen)}>
|
||||
{isOpen ? "Close Menu" : "Open Menu"}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ContextMenu>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setSelectedValue("option1");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
selected={selectedValue === "option1"}
|
||||
>
|
||||
Option 1
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setSelectedValue("option2");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
selected={selectedValue === "option2"}
|
||||
>
|
||||
Option 2
|
||||
</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
it("manages menu open/close state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StatefulMenu />);
|
||||
|
||||
const toggleButton = screen.getByRole("button", { name: "Open Menu" });
|
||||
await user.click(toggleButton);
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Close Menu" })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes menu after selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StatefulMenu />);
|
||||
|
||||
const toggleButton = screen.getByRole("button", { name: "Open Menu" });
|
||||
await user.click(toggleButton);
|
||||
|
||||
const option1 = screen.getByText("Option 1");
|
||||
await user.click(option1);
|
||||
|
||||
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Open Menu" })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("handles large menu lists efficiently", async () => {
|
||||
const user = userEvent.setup();
|
||||
const largeItems = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `item${i}`,
|
||||
label: `Item ${i}`,
|
||||
}));
|
||||
|
||||
const LargeMenu = () => (
|
||||
<ContextMenu>
|
||||
{largeItems.map((item) => (
|
||||
<ContextMenuItem key={item.id} onClick={vi.fn()}>
|
||||
{item.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
render(<LargeMenu />);
|
||||
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items).toHaveLength(100);
|
||||
|
||||
// Test that all items are focusable
|
||||
items.forEach((item) => {
|
||||
expect(item).toHaveAttribute("tabIndex", "0");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles rapid state changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={false}>
|
||||
Item 1
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={false}>
|
||||
Item 2
|
||||
</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
// Rapidly change selection state
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rerender(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={i % 2 === 0}>
|
||||
Item 1
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={i % 2 === 1}>
|
||||
Item 2
|
||||
</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// Should still be functional
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("handles missing onClick gracefully", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem>Item without onClick</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByText("Item without onClick");
|
||||
expect(item).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles invalid props gracefully", () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={null}>
|
||||
Item with invalid selected
|
||||
</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByText("Item with invalid selected");
|
||||
expect(item).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -285,9 +285,9 @@ describe("Input Component Integration", () => {
|
||||
|
||||
// Set hover state
|
||||
fireEvent.click(hoverButton);
|
||||
expect(input).toHaveClass("border-2");
|
||||
expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
|
||||
expect(input).toHaveClass(
|
||||
"border-[var(--color-border-default-brand-primary)]"
|
||||
"shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
|
||||
);
|
||||
|
||||
// Set active state
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import React, { useState } from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, describe, it, vi } from "vitest";
|
||||
import Select from "../../app/components/Select";
|
||||
|
||||
describe("Select Component Integration", () => {
|
||||
const TestForm = ({ initialValue = "" }) => {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const handleChange = (newValue) => {
|
||||
setValue(newValue);
|
||||
if (errors.select) {
|
||||
setErrors({ ...errors, select: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!value) {
|
||||
setErrors({ select: "Please select an option" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Select
|
||||
label="Test Select"
|
||||
placeholder="Select an option"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
error={!!errors.select}
|
||||
options={[
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
{ value: "option3", label: "Option 3" },
|
||||
]}
|
||||
/>
|
||||
{errors.select && <div data-testid="error">{errors.select}</div>}
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
describe("Form Integration", () => {
|
||||
it("integrates with form submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestForm />);
|
||||
|
||||
const selectButton = screen.getByRole("button", { name: /Test Select/ });
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(screen.queryByTestId("error")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows validation error when no option selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestForm />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(screen.getByTestId("error")).toHaveTextContent(
|
||||
"Please select an option"
|
||||
);
|
||||
});
|
||||
|
||||
it("clears error when option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestForm />);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Submit" });
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(screen.getByTestId("error")).toBeInTheDocument();
|
||||
|
||||
const selectButton = screen.getByRole("button", { name: /Test Select/ });
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
expect(screen.queryByTestId("error")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple Select Components", () => {
|
||||
const MultiSelectForm = () => {
|
||||
const [values, setValues] = useState({ select1: "", select2: "" });
|
||||
|
||||
const handleChange = (field) => (newValue) => {
|
||||
setValues({ ...values, [field]: newValue });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
label="First Select"
|
||||
placeholder="Select first option"
|
||||
value={values.select1}
|
||||
onChange={handleChange("select1")}
|
||||
options={[
|
||||
{ value: "a1", label: "A1" },
|
||||
{ value: "a2", label: "A2" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Second Select"
|
||||
placeholder="Select second option"
|
||||
value={values.select2}
|
||||
onChange={handleChange("select2")}
|
||||
options={[
|
||||
{ value: "b1", label: "B1" },
|
||||
{ value: "b2", label: "B2" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
it("handles multiple select components independently", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MultiSelectForm />);
|
||||
|
||||
const firstSelect = screen.getByRole("button", {
|
||||
name: /First Select/,
|
||||
});
|
||||
const secondSelect = screen.getByRole("button", {
|
||||
name: /Second Select/,
|
||||
});
|
||||
|
||||
await user.click(firstSelect);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("A1")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText("A1"));
|
||||
|
||||
await user.click(secondSelect);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("B1")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText("B1"));
|
||||
|
||||
expect(firstSelect).toHaveTextContent("A1");
|
||||
expect(secondSelect).toHaveTextContent("B1");
|
||||
});
|
||||
|
||||
it("closes one dropdown when another is opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MultiSelectForm />);
|
||||
|
||||
const firstSelect = screen.getByRole("button", {
|
||||
name: /First Select/,
|
||||
});
|
||||
const secondSelect = screen.getByRole("button", {
|
||||
name: /Second Select/,
|
||||
});
|
||||
|
||||
await user.click(firstSelect);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("A1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(secondSelect);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("A1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("B1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keyboard Navigation Between Components", () => {
|
||||
const KeyboardForm = () => {
|
||||
const [values, setValues] = useState({ select1: "", select2: "" });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input placeholder="First input" />
|
||||
<Select
|
||||
label="First Select"
|
||||
placeholder="Select first option"
|
||||
value={values.select1}
|
||||
onChange={(value) => setValues({ ...values, select1: value })}
|
||||
options={[{ value: "a1", label: "A1" }]}
|
||||
/>
|
||||
<input placeholder="Second input" />
|
||||
<Select
|
||||
label="Second Select"
|
||||
placeholder="Select second option"
|
||||
value={values.select2}
|
||||
onChange={(value) => setValues({ ...values, select2: value })}
|
||||
options={[{ value: "b1", label: "B1" }]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
it("handles keyboard navigation between inputs and selects", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<KeyboardForm />);
|
||||
|
||||
const firstInput = screen.getByPlaceholderText("First input");
|
||||
const firstSelect = screen.getByRole("button", {
|
||||
name: /First Select/,
|
||||
});
|
||||
const secondInput = screen.getByPlaceholderText("Second input");
|
||||
const secondSelect = screen.getByRole("button", {
|
||||
name: /Second Select/,
|
||||
});
|
||||
|
||||
await user.tab();
|
||||
expect(firstInput).toHaveFocus();
|
||||
|
||||
await user.tab();
|
||||
expect(firstSelect).toHaveFocus();
|
||||
|
||||
await user.tab();
|
||||
expect(secondInput).toHaveFocus();
|
||||
|
||||
await user.tab();
|
||||
expect(secondSelect).toHaveFocus();
|
||||
});
|
||||
|
||||
it("opens select with Enter key during tab navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<KeyboardForm />);
|
||||
|
||||
const firstSelect = screen.getByRole("button", {
|
||||
name: /First Select/,
|
||||
});
|
||||
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
expect(firstSelect).toHaveFocus();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("A1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dynamic Prop Changes", () => {
|
||||
const DynamicSelect = ({ disabled, error, size }) => {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
return (
|
||||
<Select
|
||||
label="Dynamic Select"
|
||||
placeholder="Select an option"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
size={size}
|
||||
options={[
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
it("handles dynamic disabled state changes", async () => {
|
||||
const { rerender } = render(<DynamicSelect disabled={false} />);
|
||||
|
||||
const selectButton = screen.getByRole("button", {
|
||||
name: /Dynamic Select/,
|
||||
});
|
||||
expect(selectButton).not.toBeDisabled();
|
||||
|
||||
rerender(<DynamicSelect disabled={true} />);
|
||||
expect(selectButton).toBeDisabled();
|
||||
|
||||
rerender(<DynamicSelect disabled={false} />);
|
||||
expect(selectButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("handles dynamic error state changes", async () => {
|
||||
const { rerender } = render(<DynamicSelect error={false} />);
|
||||
|
||||
const selectButton = screen.getByRole("button", {
|
||||
name: /Dynamic Select/,
|
||||
});
|
||||
expect(selectButton).not.toHaveClass(
|
||||
"border-[var(--color-border-default-utility-negative)]"
|
||||
);
|
||||
|
||||
rerender(<DynamicSelect error={true} />);
|
||||
expect(selectButton).toHaveClass(
|
||||
"border-[var(--color-border-default-utility-negative)]"
|
||||
);
|
||||
|
||||
rerender(<DynamicSelect error={false} />);
|
||||
expect(selectButton).not.toHaveClass(
|
||||
"border-[var(--color-border-default-utility-negative)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles dynamic size changes", async () => {
|
||||
const { rerender } = render(<DynamicSelect size="small" />);
|
||||
|
||||
const selectButton = screen.getByRole("button", {
|
||||
name: /Dynamic Select/,
|
||||
});
|
||||
expect(selectButton).toHaveClass("h-[32px]");
|
||||
|
||||
rerender(<DynamicSelect size="medium" />);
|
||||
expect(selectButton).toHaveClass("h-[36px]");
|
||||
|
||||
rerender(<DynamicSelect size="large" />);
|
||||
expect(selectButton).toHaveClass("h-[40px]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Focus State Behavior", () => {
|
||||
it("enters focus state when tabbed to (not active state)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestForm />);
|
||||
|
||||
const selectButton = screen.getByRole("button", { name: /Test Select/ });
|
||||
await user.tab();
|
||||
|
||||
expect(selectButton).toHaveFocus();
|
||||
// Should have focus state styling, not active state
|
||||
expect(selectButton).toHaveClass(
|
||||
"focus-visible:border-[var(--color-border-default-utility-info)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not enter focus state when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestForm />);
|
||||
|
||||
const selectButton = screen.getByRole("button", { name: /Test Select/ });
|
||||
await user.click(selectButton);
|
||||
|
||||
expect(selectButton).toHaveFocus();
|
||||
// Click should not trigger focus-visible styles (class is always present but only active on keyboard focus)
|
||||
// The focus-visible class is always in the component but only applies on keyboard focus
|
||||
expect(selectButton).toHaveClass(
|
||||
"focus-visible:border-[var(--color-border-default-utility-info)]"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("handles rapid state changes without issues", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<TestForm />);
|
||||
|
||||
const selectButton = screen.getByRole("button", { name: /Test Select/ });
|
||||
|
||||
// Rapidly change props
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rerender(<TestForm />);
|
||||
await user.click(selectButton);
|
||||
await user.keyboard("{Escape}");
|
||||
}
|
||||
|
||||
// Should still be functional
|
||||
await user.click(selectButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles large option lists efficiently", async () => {
|
||||
const user = userEvent.setup();
|
||||
const largeOptions = Array.from({ length: 100 }, (_, i) => ({
|
||||
value: `option${i}`,
|
||||
label: `Option ${i}`,
|
||||
}));
|
||||
|
||||
render(
|
||||
<Select
|
||||
label="Large Select"
|
||||
placeholder="Select an option"
|
||||
options={largeOptions}
|
||||
/>
|
||||
);
|
||||
|
||||
const selectButton = screen.getByRole("button", { name: /Large Select/ });
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 0")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 99")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, describe, it, vi, beforeEach } from "vitest";
|
||||
import { axe, toHaveNoViolations } from "jest-axe";
|
||||
import ContextMenu from "../../app/components/ContextMenu";
|
||||
import ContextMenuItem from "../../app/components/ContextMenuItem";
|
||||
import ContextMenuSection from "../../app/components/ContextMenuSection";
|
||||
import ContextMenuDivider from "../../app/components/ContextMenuDivider";
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
describe("ContextMenu Component", () => {
|
||||
const defaultProps = {
|
||||
children: "Context Menu Content",
|
||||
};
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("renders with default props", () => {
|
||||
render(<ContextMenu {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Context Menu Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with custom className", () => {
|
||||
render(<ContextMenu {...defaultProps} className="custom-class" />);
|
||||
|
||||
const menu = screen.getByText("Context Menu Content").closest("div");
|
||||
expect(menu).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it("applies correct base styles", () => {
|
||||
render(<ContextMenu {...defaultProps} />);
|
||||
|
||||
const menu = screen.getByText("Context Menu Content").closest("div");
|
||||
expect(menu).toHaveClass(
|
||||
"bg-black",
|
||||
"border",
|
||||
"rounded-[var(--measures-radius-medium)]",
|
||||
"shadow-lg",
|
||||
"p-[4px]"
|
||||
);
|
||||
});
|
||||
|
||||
it("has solid black background", () => {
|
||||
render(<ContextMenu {...defaultProps} />);
|
||||
|
||||
const menu = screen.getByText("Context Menu Content").closest("div");
|
||||
expect(menu).toHaveStyle({ backgroundColor: "#000000" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(
|
||||
<ContextMenu {...defaultProps}>
|
||||
<ContextMenuItem onClick={vi.fn()}>Menu Item</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has proper role", () => {
|
||||
render(<ContextMenu {...defaultProps} />);
|
||||
|
||||
const menu = screen.getByText("Context Menu Content").closest("div");
|
||||
expect(menu).toHaveAttribute("role", "menu");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuItem Component", () => {
|
||||
const defaultProps = {
|
||||
children: "Menu Item",
|
||||
onClick: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("renders with default props", () => {
|
||||
render(<ContextMenuItem {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Menu Item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders as selected when selected prop is true", () => {
|
||||
render(<ContextMenuItem {...defaultProps} selected={true} />);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveClass(
|
||||
"bg-[var(--color-surface-default-secondary)]",
|
||||
"rounded-[var(--measures-radius-small)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders with submenu arrow when hasSubmenu prop is true", () => {
|
||||
render(<ContextMenuItem {...defaultProps} hasSubmenu={true} />);
|
||||
|
||||
// Check for the right-pointing chevron SVG
|
||||
const item = screen.getByRole("menuitem");
|
||||
const svg = item.querySelector("svg:last-child");
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with checkmark when selected prop is true", () => {
|
||||
render(<ContextMenuItem {...defaultProps} selected={true} />);
|
||||
|
||||
// Check for the checkmark SVG
|
||||
const item = screen.getByRole("menuitem");
|
||||
const svg = item.querySelector("svg:first-child");
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies correct size styles", () => {
|
||||
render(<ContextMenuItem {...defaultProps} size="small" />);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveClass("text-[10px]", "leading-[14px]");
|
||||
});
|
||||
|
||||
it("applies medium size styles", () => {
|
||||
render(<ContextMenuItem {...defaultProps} size="medium" />);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveClass("text-[14px]", "leading-[20px]");
|
||||
});
|
||||
|
||||
it("applies large size styles", () => {
|
||||
render(<ContextMenuItem {...defaultProps} size="large" />);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveClass("text-[16px]", "leading-[24px]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Interaction", () => {
|
||||
it("calls onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContextMenuItem {...defaultProps} />);
|
||||
|
||||
const item = screen.getByText("Menu Item");
|
||||
await user.click(item);
|
||||
|
||||
expect(defaultProps.onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call onClick when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContextMenuItem {...defaultProps} disabled={true} />);
|
||||
|
||||
const item = screen.getByText("Menu Item");
|
||||
await user.click(item);
|
||||
|
||||
expect(defaultProps.onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has hover effects", () => {
|
||||
render(<ContextMenuItem {...defaultProps} />);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveClass(
|
||||
"hover:!bg-[var(--color-surface-default-secondary)]"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(
|
||||
<ContextMenu>
|
||||
<ContextMenuItem {...defaultProps} />
|
||||
</ContextMenu>
|
||||
);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has proper role", () => {
|
||||
render(<ContextMenuItem {...defaultProps} />);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Styling", () => {
|
||||
it("applies correct text color", () => {
|
||||
render(<ContextMenuItem {...defaultProps} />);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveClass(
|
||||
"text-[var(--color-content-default-brand-primary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("applies correct padding", () => {
|
||||
render(<ContextMenuItem {...defaultProps} />);
|
||||
|
||||
const item = screen.getByRole("menuitem");
|
||||
expect(item).toHaveClass("px-[8px]", "py-[4px]");
|
||||
});
|
||||
|
||||
it("applies correct gap between checkmark and text", () => {
|
||||
render(<ContextMenuItem {...defaultProps} selected={true} />);
|
||||
|
||||
const item = screen.getByText("Menu Item").closest("div");
|
||||
expect(item).toHaveClass("gap-[8px]");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuSection Component", () => {
|
||||
const defaultProps = {
|
||||
title: "Section Title",
|
||||
children: "Section Content",
|
||||
};
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("renders with title and children", () => {
|
||||
render(<ContextMenuSection {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Section Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Section Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders without title when not provided", () => {
|
||||
render(<ContextMenuSection>Section Content</ContextMenuSection>);
|
||||
|
||||
expect(screen.getByText("Section Content")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Section Title")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies correct title styling", () => {
|
||||
render(<ContextMenuSection {...defaultProps} />);
|
||||
|
||||
const title = screen.getByText("Section Title");
|
||||
expect(title).toHaveClass(
|
||||
"text-[var(--color-content-default-primary)]",
|
||||
"font-medium"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(<ContextMenuSection {...defaultProps} />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenuDivider Component", () => {
|
||||
describe("Rendering", () => {
|
||||
it("renders divider", () => {
|
||||
render(<ContextMenuDivider />);
|
||||
|
||||
const divider = screen.getByRole("separator");
|
||||
expect(divider).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies correct styling", () => {
|
||||
render(<ContextMenuDivider />);
|
||||
|
||||
const divider = screen.getByRole("separator");
|
||||
expect(divider).toHaveClass(
|
||||
"border-t",
|
||||
"border-[var(--color-border-default-tertiary)]",
|
||||
"my-1"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(<ContextMenuDivider />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextMenu Components Integration", () => {
|
||||
const TestMenu = () => (
|
||||
<ContextMenu>
|
||||
<ContextMenuSection title="First Section">
|
||||
<ContextMenuItem onClick={vi.fn()}>Item 1</ContextMenuItem>
|
||||
<ContextMenuItem onClick={vi.fn()} selected={true}>
|
||||
Item 2
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuSection title="Second Section">
|
||||
<ContextMenuItem onClick={vi.fn()} hasSubmenu={true}>
|
||||
Item 3
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSection>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
it("renders all components together", () => {
|
||||
render(<TestMenu />);
|
||||
|
||||
expect(screen.getByText("First Section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second Section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 3")).toBeInTheDocument();
|
||||
expect(screen.getByRole("separator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has no accessibility violations when integrated", async () => {
|
||||
const { container } = render(<TestMenu />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,7 @@ describe("Input Component", () => {
|
||||
test("applies correct size classes", () => {
|
||||
const { rerender } = render(<Input size="small" />);
|
||||
let input = screen.getByRole("textbox");
|
||||
expect(input).toHaveClass("h-[30px]");
|
||||
expect(input).toHaveClass("h-[32px]");
|
||||
|
||||
rerender(<Input size="medium" />);
|
||||
input = screen.getByRole("textbox");
|
||||
@@ -146,9 +146,9 @@ describe("Input Component", () => {
|
||||
test("applies hover state classes", () => {
|
||||
render(<Input state="hover" />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveClass("border-2");
|
||||
expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
|
||||
expect(input).toHaveClass(
|
||||
"border-[var(--color-border-default-brand-primary)]"
|
||||
"shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -162,7 +162,9 @@ describe("Input Component", () => {
|
||||
render(<Input state="default" />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveClass("border-[var(--color-border-default-tertiary)]");
|
||||
expect(input).toHaveClass("hover:outline");
|
||||
expect(input).toHaveClass(
|
||||
"hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
|
||||
);
|
||||
});
|
||||
|
||||
test("applies custom className", () => {
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, describe, it, vi } from "vitest";
|
||||
import { axe, toHaveNoViolations } from "jest-axe";
|
||||
import Select from "../../app/components/Select";
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
describe("Select Component", () => {
|
||||
const defaultProps = {
|
||||
label: "Test Select",
|
||||
placeholder: "Select an option",
|
||||
options: [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
{ value: "option3", label: "Option 3" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("renders with default props", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Select")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select an option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders without label when not provided", () => {
|
||||
render(
|
||||
<Select placeholder="Select an option" options={defaultProps.options} />
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Test Select")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Select an option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with horizontal label variant", () => {
|
||||
render(<Select {...defaultProps} labelVariant="horizontal" />);
|
||||
|
||||
const container = screen.getByText("Test Select").closest("div");
|
||||
expect(container).toHaveClass("flex", "items-center");
|
||||
});
|
||||
|
||||
it("renders with default label variant", () => {
|
||||
render(<Select {...defaultProps} labelVariant="default" />);
|
||||
|
||||
const container = screen.getByText("Test Select").closest("div");
|
||||
expect(container).toHaveClass("flex", "flex-col");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Size Variants", () => {
|
||||
it("renders small size correctly", () => {
|
||||
render(<Select {...defaultProps} size="small" />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass("h-[32px]");
|
||||
});
|
||||
|
||||
it("renders medium size correctly", () => {
|
||||
render(<Select {...defaultProps} size="medium" />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass("h-[36px]");
|
||||
});
|
||||
|
||||
it("renders large size correctly", () => {
|
||||
render(<Select {...defaultProps} size="large" />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass("h-[40px]");
|
||||
});
|
||||
|
||||
it("applies correct height for small horizontal label", () => {
|
||||
render(
|
||||
<Select {...defaultProps} size="small" labelVariant="horizontal" />
|
||||
);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass("h-[30px]");
|
||||
});
|
||||
|
||||
it("applies correct height for small default label", () => {
|
||||
render(<Select {...defaultProps} size="small" labelVariant="default" />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass("h-[32px]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("State Variants", () => {
|
||||
it("renders default state", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass(
|
||||
"border-[var(--color-border-default-tertiary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders hover state", () => {
|
||||
render(<Select {...defaultProps} state="hover" />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass(
|
||||
"shadow-[0_0_0_2px_var(--color-border-default-tertiary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders focus state", () => {
|
||||
render(<Select {...defaultProps} state="focus" />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass(
|
||||
"border-[var(--color-border-default-utility-info)]"
|
||||
);
|
||||
expect(selectButton).toHaveClass("shadow-[0_0_5px_3px_#3281F8]");
|
||||
});
|
||||
|
||||
it("renders error state", () => {
|
||||
render(<Select {...defaultProps} error={true} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass(
|
||||
"border-[var(--color-border-default-utility-negative)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders disabled state", () => {
|
||||
render(<Select {...defaultProps} disabled={true} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveClass("cursor-not-allowed");
|
||||
expect(selectButton).toHaveClass("opacity-40");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Interaction", () => {
|
||||
it("opens dropdown when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown when clicked again", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("selects an option when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Select {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
target: {
|
||||
value: "option1",
|
||||
text: "Option 1",
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes dropdown when option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not open when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} disabled={true} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keyboard Navigation", () => {
|
||||
it("opens dropdown with Enter key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
selectButton.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens dropdown with Space key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
selectButton.focus();
|
||||
await user.keyboard(" ");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdown with Escape key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not respond to keyboard when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} disabled={true} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
selectButton.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Click Outside", () => {
|
||||
it("closes dropdown when clicking outside", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<div>
|
||||
<Select {...defaultProps} />
|
||||
<div data-testid="outside">Outside element</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("outside"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Value Display", () => {
|
||||
it("shows placeholder when no value selected", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Select an option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows selected value when option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Select an option")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows selected value when value prop is provided", () => {
|
||||
render(<Select {...defaultProps} value="option2" />);
|
||||
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("has no accessibility violations", async () => {
|
||||
const { container } = render(<Select {...defaultProps} />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("has proper ARIA attributes", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
expect(selectButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(selectButton).toHaveAttribute("aria-haspopup", "listbox");
|
||||
});
|
||||
|
||||
it("updates aria-expanded when dropdown opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(selectButton).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
|
||||
it("associates label with select button", () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const label = screen.getByText("Test Select");
|
||||
const selectButton = screen.getByRole("button");
|
||||
|
||||
expect(label).toHaveAttribute("for", selectButton.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Focus Behavior", () => {
|
||||
it("enters focus state when tabbed to", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.tab();
|
||||
|
||||
expect(selectButton).toHaveFocus();
|
||||
expect(selectButton).toHaveClass(
|
||||
"focus-visible:border-[var(--color-border-default-utility-info)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not enter focus state when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Select {...defaultProps} />);
|
||||
|
||||
const selectButton = screen.getByRole("button");
|
||||
await user.click(selectButton);
|
||||
|
||||
expect(selectButton).toHaveFocus();
|
||||
// Focus state should not be applied on click, only on keyboard navigation
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user