Select and Context Menu component with storybook and testing

This commit is contained in:
adilallo
2025-10-10 12:07:13 -06:00
parent 2bc5fcdf45
commit 9c72afdc52
20 changed files with 3827 additions and 88 deletions
@@ -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();
});
});
});
+305
View File
@@ -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);
});
});