Radio button and group component with storybook and testing
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import RadioButton from "../../app/components/RadioButton";
|
||||
|
||||
describe("RadioButton", () => {
|
||||
it("renders with default props", () => {
|
||||
render(<RadioButton />);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
expect(radioButton).toBeInTheDocument();
|
||||
expect(radioButton).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("renders with label", () => {
|
||||
render(<RadioButton label="Test Radio" />);
|
||||
|
||||
expect(screen.getByText("Test Radio")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows checked state", () => {
|
||||
render(<RadioButton checked={true} label="Checked Radio" />);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
expect(radioButton).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("calls onChange when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioButton checked={false} onChange={handleChange} label="Test Radio" />
|
||||
);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
await user.click(radioButton);
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({
|
||||
checked: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onChange with value when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioButton
|
||||
checked={false}
|
||||
value="test-value"
|
||||
onChange={handleChange}
|
||||
label="Test Radio"
|
||||
/>
|
||||
);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
await user.click(radioButton);
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({
|
||||
checked: true,
|
||||
value: "test-value",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call onChange when clicking already checked radio button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioButton checked={true} onChange={handleChange} label="Test Radio" />
|
||||
);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
await user.click(radioButton);
|
||||
|
||||
// Radio buttons should not be unchecked by clicking them again
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles keyboard activation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioButton checked={false} onChange={handleChange} label="Test Radio" />
|
||||
);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
radioButton.focus();
|
||||
await user.keyboard(" ");
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({
|
||||
checked: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles Enter key activation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioButton checked={false} onChange={handleChange} label="Test Radio" />
|
||||
);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
await user.click(radioButton); // Focus the element first
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({
|
||||
checked: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies standard mode classes", () => {
|
||||
render(<RadioButton mode="standard" label="Standard Radio" />);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
expect(radioButton).toHaveClass(
|
||||
"outline-[var(--color-border-default-tertiary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("applies inverse mode classes", () => {
|
||||
render(<RadioButton mode="inverse" label="Inverse Radio" />);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
expect(radioButton).toHaveClass(
|
||||
"outline-[var(--color-border-inverse-primary)]"
|
||||
);
|
||||
});
|
||||
|
||||
it("applies focus state classes", () => {
|
||||
render(<RadioButton state="focus" label="Focus Radio" />);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
expect(radioButton).toHaveClass("focus:outline");
|
||||
});
|
||||
|
||||
it("applies hover state classes", () => {
|
||||
render(<RadioButton state="hover" label="Hover Radio" />);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
expect(radioButton).toHaveClass("hover:outline");
|
||||
});
|
||||
|
||||
it("renders hidden input for form submission", () => {
|
||||
render(
|
||||
<RadioButton
|
||||
name="test-radio"
|
||||
value="test-value"
|
||||
checked={true}
|
||||
label="Test Radio"
|
||||
/>
|
||||
);
|
||||
|
||||
const hiddenInput = screen.getByDisplayValue("test-value");
|
||||
expect(hiddenInput).toBeInTheDocument();
|
||||
expect(hiddenInput).toHaveAttribute("type", "radio");
|
||||
expect(hiddenInput).toHaveAttribute("name", "test-radio");
|
||||
expect(hiddenInput).toBeChecked();
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
render(<RadioButton className="custom-class" label="Custom Radio" />);
|
||||
|
||||
const label = screen.getByText("Custom Radio").closest("label");
|
||||
expect(label).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it("generates unique ID when not provided", () => {
|
||||
render(<RadioButton label="Radio 1" />);
|
||||
render(<RadioButton label="Radio 2" />);
|
||||
|
||||
const radioButtons = screen.getAllByRole("radio");
|
||||
expect(radioButtons[0]).toHaveAttribute("id");
|
||||
expect(radioButtons[1]).toHaveAttribute("id");
|
||||
expect(radioButtons[0].id).not.toBe(radioButtons[1].id);
|
||||
});
|
||||
|
||||
it("uses provided ID", () => {
|
||||
render(<RadioButton id="custom-id" label="Custom ID Radio" />);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
expect(radioButton).toHaveAttribute("id", "custom-id");
|
||||
});
|
||||
|
||||
it("associates label with radio button for accessibility", () => {
|
||||
render(<RadioButton label="Accessible Radio" />);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
const labelId = radioButton.getAttribute("aria-labelledby");
|
||||
expect(labelId).toBeTruthy();
|
||||
|
||||
const labelElement = document.getElementById(labelId);
|
||||
expect(labelElement).toHaveTextContent("Accessible Radio");
|
||||
});
|
||||
|
||||
it("uses aria-label when provided", () => {
|
||||
render(<RadioButton ariaLabel="Custom Aria Label" />);
|
||||
|
||||
const radioButton = screen.getByRole("radio");
|
||||
expect(radioButton).toHaveAttribute("aria-label", "Custom Aria Label");
|
||||
});
|
||||
|
||||
it("shows dot indicator when checked", () => {
|
||||
render(
|
||||
<RadioButton checked={true} mode="standard" label="Checked Radio" />
|
||||
);
|
||||
|
||||
const dot = screen.getByRole("radio").querySelector("div");
|
||||
expect(dot).toHaveClass("w-[16px]", "h-[16px]", "rounded-full");
|
||||
});
|
||||
|
||||
it("hides dot indicator when unchecked", () => {
|
||||
render(
|
||||
<RadioButton checked={false} mode="standard" label="Unchecked Radio" />
|
||||
);
|
||||
|
||||
const dot = screen.getByRole("radio").querySelector("div");
|
||||
// Check if the dot has transparent background or no background color set
|
||||
const computedStyle = window.getComputedStyle(dot);
|
||||
const backgroundColor = computedStyle.backgroundColor;
|
||||
|
||||
// The dot should either be transparent or have no background color
|
||||
expect(
|
||||
backgroundColor === "transparent" ||
|
||||
backgroundColor === "rgba(0, 0, 0, 0)" ||
|
||||
backgroundColor === ""
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import RadioGroup from "../../app/components/RadioGroup";
|
||||
|
||||
describe("RadioGroup", () => {
|
||||
const defaultOptions = [
|
||||
{ value: "option1", label: "Option 1" },
|
||||
{ value: "option2", label: "Option 2" },
|
||||
{ value: "option3", label: "Option 3" },
|
||||
];
|
||||
|
||||
it("renders with default props", () => {
|
||||
render(<RadioGroup options={defaultOptions} />);
|
||||
|
||||
const radioGroup = screen.getByRole("radiogroup");
|
||||
expect(radioGroup).toBeInTheDocument();
|
||||
|
||||
const radioButtons = screen.getAllByRole("radio");
|
||||
expect(radioButtons).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders all options", () => {
|
||||
render(<RadioGroup options={defaultOptions} />);
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows selected option", () => {
|
||||
render(<RadioGroup options={defaultOptions} value="option2" />);
|
||||
|
||||
const radioButtons = screen.getAllByRole("radio");
|
||||
expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
|
||||
expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
|
||||
expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("calls onChange when option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioGroup
|
||||
options={defaultOptions}
|
||||
value="option1"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const option2 = screen.getByText("Option 2").closest("label");
|
||||
await user.click(option2);
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({ value: "option2" });
|
||||
});
|
||||
|
||||
it("updates selection when different option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioGroup
|
||||
options={defaultOptions}
|
||||
value="option1"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click option 3
|
||||
const option3 = screen.getByText("Option 3").closest("label");
|
||||
await user.click(option3);
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({ value: "option3" });
|
||||
});
|
||||
|
||||
it("handles keyboard navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioGroup
|
||||
options={defaultOptions}
|
||||
value="option1"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const radioButtons = screen.getAllByRole("radio");
|
||||
radioButtons[1].focus();
|
||||
await user.keyboard(" ");
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({ value: "option2" });
|
||||
});
|
||||
|
||||
it("handles Enter key activation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioGroup
|
||||
options={defaultOptions}
|
||||
value="option1"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const radioButtons = screen.getAllByRole("radio");
|
||||
await user.click(radioButtons[2]);
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({ value: "option3" });
|
||||
});
|
||||
|
||||
it("applies standard mode to all radio buttons", () => {
|
||||
render(<RadioGroup options={defaultOptions} mode="standard" />);
|
||||
|
||||
const radioButtons = screen.getAllByRole("radio");
|
||||
radioButtons.forEach((button) => {
|
||||
expect(button).toHaveClass(
|
||||
"outline-[var(--color-border-default-tertiary)]"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies inverse mode to all radio buttons", () => {
|
||||
render(<RadioGroup options={defaultOptions} mode="inverse" />);
|
||||
|
||||
const radioButtons = screen.getAllByRole("radio");
|
||||
radioButtons.forEach((button) => {
|
||||
expect(button).toHaveClass(
|
||||
"outline-[var(--color-border-inverse-primary)]"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies state to all radio buttons", () => {
|
||||
render(<RadioGroup options={defaultOptions} state="focus" />);
|
||||
|
||||
const radioButtons = screen.getAllByRole("radio");
|
||||
radioButtons.forEach((button) => {
|
||||
expect(button).toHaveClass("focus:outline");
|
||||
});
|
||||
});
|
||||
|
||||
it("generates unique group name when not provided", () => {
|
||||
render(<RadioGroup options={defaultOptions} />);
|
||||
render(<RadioGroup options={defaultOptions} />);
|
||||
|
||||
const hiddenInputs = screen.getAllByRole("radio", { hidden: true });
|
||||
const names = hiddenInputs.map((input) => input.getAttribute("name"));
|
||||
|
||||
// Should have unique names
|
||||
const uniqueNames = new Set(names);
|
||||
expect(uniqueNames.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("uses provided name for all radio buttons", () => {
|
||||
render(<RadioGroup options={defaultOptions} name="test-group" />);
|
||||
|
||||
const hiddenInputs = screen.getAllByDisplayValue("option1");
|
||||
hiddenInputs.forEach((input) => {
|
||||
expect(input).toHaveAttribute("name", "test-group");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies custom className to container", () => {
|
||||
render(<RadioGroup options={defaultOptions} className="custom-group" />);
|
||||
|
||||
const radioGroup = screen.getByRole("radiogroup");
|
||||
expect(radioGroup).toHaveClass("custom-group");
|
||||
});
|
||||
|
||||
it("passes aria-label to radiogroup", () => {
|
||||
render(
|
||||
<RadioGroup options={defaultOptions} aria-label="Test Radio Group" />
|
||||
);
|
||||
|
||||
const radioGroup = screen.getByRole("radiogroup");
|
||||
expect(radioGroup).toHaveAttribute("aria-label", "Test Radio Group");
|
||||
});
|
||||
|
||||
it("handles empty options array", () => {
|
||||
render(<RadioGroup options={[]} />);
|
||||
|
||||
const radioGroup = screen.getByRole("radiogroup");
|
||||
expect(radioGroup).toBeInTheDocument();
|
||||
|
||||
const radioButtons = screen.queryAllByRole("radio");
|
||||
expect(radioButtons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles options with ariaLabel", () => {
|
||||
const optionsWithAria = [
|
||||
{ value: "option1", label: "Option 1", ariaLabel: "First Option" },
|
||||
{ value: "option2", label: "Option 2", ariaLabel: "Second Option" },
|
||||
];
|
||||
|
||||
render(<RadioGroup options={optionsWithAria} />);
|
||||
|
||||
const radioButtons = screen.getAllByRole("radio");
|
||||
expect(radioButtons[0]).toHaveAttribute("aria-label", "First Option");
|
||||
expect(radioButtons[1]).toHaveAttribute("aria-label", "Second Option");
|
||||
});
|
||||
|
||||
it("maintains selection state correctly", () => {
|
||||
const { rerender } = render(
|
||||
<RadioGroup options={defaultOptions} value="option1" />
|
||||
);
|
||||
|
||||
let radioButtons = screen.getAllByRole("radio");
|
||||
expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
rerender(<RadioGroup options={defaultOptions} value="option3" />);
|
||||
|
||||
radioButtons = screen.getAllByRole("radio");
|
||||
expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
|
||||
expect(radioButtons[2]).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("does not call onChange when clicking already selected option", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RadioGroup
|
||||
options={defaultOptions}
|
||||
value="option2"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const option2 = screen.getByText("Option 2").closest("label");
|
||||
await user.click(option2);
|
||||
|
||||
// Should not call onChange since it's already selected
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user