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,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();
});
});
});
+2 -2
View File
@@ -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();
});
});
});
});