Fix checkbox tests (#767)
Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
co-authored by
philon-
parent
284ccd43f8
commit
a7f56c0bd5
@@ -1,56 +1,75 @@
|
|||||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import type React from "react";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("@components/UI/Label.tsx", () => ({
|
|
||||||
Label: ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
htmlFor,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className: string;
|
|
||||||
htmlFor: string;
|
|
||||||
id: string;
|
|
||||||
}) => (
|
|
||||||
<label
|
|
||||||
data-testid="label-component"
|
|
||||||
className={className}
|
|
||||||
htmlFor={htmlFor}
|
|
||||||
id={id}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Checkbox", () => {
|
describe("Checkbox", () => {
|
||||||
beforeEach(cleanup);
|
beforeEach(cleanup);
|
||||||
|
|
||||||
it("renders unchecked by default", () => {
|
it("renders unchecked by default (uncontrolled)", () => {
|
||||||
render(<Checkbox />);
|
render(<Checkbox />);
|
||||||
const checkbox = screen.getByRole("checkbox");
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
const presentation = screen.getByRole("presentation");
|
||||||
expect(checkbox).not.toBeChecked();
|
expect(checkbox).not.toBeChecked();
|
||||||
expect(screen.queryByText("Check")).not.toBeInTheDocument();
|
// unchecked -> no filled bg class
|
||||||
|
expect(presentation).not.toHaveClass("bg-slate-500");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders checked when checked prop is true", () => {
|
it("respects defaultChecked in uncontrolled mode", () => {
|
||||||
render(<Checkbox checked />);
|
render(<Checkbox defaultChecked />);
|
||||||
expect(screen.getByRole("checkbox")).toBeChecked();
|
expect(screen.getByRole("checkbox")).toBeChecked();
|
||||||
expect(screen.getByRole("presentation")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onChange when clicked", () => {
|
it("renders checked when controlled with checked=true", () => {
|
||||||
|
render(<Checkbox checked />);
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
const presentation = screen.getByRole("presentation");
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
expect(presentation).toHaveClass("bg-slate-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange when clicked (uncontrolled) and toggles DOM state", () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
render(<Checkbox onChange={onChange} />);
|
render(<Checkbox onChange={onChange} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("presentation"));
|
const checkbox = screen.getByRole("checkbox");
|
||||||
expect(onChange).toHaveBeenCalledWith(true);
|
const presentation = screen.getByRole("presentation");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("presentation"));
|
fireEvent.click(presentation);
|
||||||
expect(onChange).toHaveBeenCalledWith(false);
|
expect(onChange).toHaveBeenLastCalledWith(true);
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
|
fireEvent.click(presentation);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(false);
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("controlled: calls onChange but does not toggle without prop update", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<Checkbox checked={false} onChange={onChange} />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
const presentation = screen.getByRole("presentation");
|
||||||
|
|
||||||
|
fireEvent.click(presentation);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(true);
|
||||||
|
// still unchecked because parent didn't update prop
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("controlled: reflects external prop changes after onChange", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { rerender } = render(<Checkbox checked={false} onChange={onChange} />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
const presentation = screen.getByRole("presentation");
|
||||||
|
|
||||||
|
fireEvent.click(presentation);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(true);
|
||||||
|
|
||||||
|
// parent updates `checked` based on onChange
|
||||||
|
rerender(<Checkbox checked={true} onChange={onChange} />);
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
expect(presentation).toHaveClass("bg-slate-500");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses provided id", () => {
|
it("uses provided id", () => {
|
||||||
@@ -58,23 +77,16 @@ describe("Checkbox", () => {
|
|||||||
expect(screen.getByRole("checkbox").id).toBe("custom-id");
|
expect(screen.getByRole("checkbox").id).toBe("custom-id");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders children in Label component", () => {
|
it("renders children inside the label", () => {
|
||||||
render(<Checkbox>Test Label</Checkbox>);
|
render(<Checkbox>Test Label</Checkbox>);
|
||||||
expect(screen.getByTestId("label-component")).toHaveTextContent(
|
expect(screen.getByTestId("label-component")).toHaveTextContent("Test Label");
|
||||||
"Test Label",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies custom className", () => {
|
it("applies custom className to wrapper label", () => {
|
||||||
const { container } = render(<Checkbox className="custom-class" />);
|
const { container } = render(<Checkbox className="custom-class" />);
|
||||||
expect(container.firstChild).toHaveClass("custom-class");
|
expect(container.firstChild).toHaveClass("custom-class");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies labelClassName to Label", () => {
|
|
||||||
render(<Checkbox labelClassName="label-class">Test</Checkbox>);
|
|
||||||
expect(screen.getByTestId("label-component")).toHaveClass("label-class");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables checkbox when disabled prop is true", () => {
|
it("disables checkbox when disabled prop is true", () => {
|
||||||
render(<Checkbox disabled />);
|
render(<Checkbox disabled />);
|
||||||
expect(screen.getByRole("checkbox")).toBeDisabled();
|
expect(screen.getByRole("checkbox")).toBeDisabled();
|
||||||
@@ -84,7 +96,6 @@ describe("Checkbox", () => {
|
|||||||
it("does not call onChange when disabled", () => {
|
it("does not call onChange when disabled", () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
render(<Checkbox onChange={onChange} disabled />);
|
render(<Checkbox onChange={onChange} disabled />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("presentation"));
|
fireEvent.click(screen.getByRole("presentation"));
|
||||||
expect(onChange).not.toHaveBeenCalled();
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -99,24 +110,19 @@ describe("Checkbox", () => {
|
|||||||
expect(screen.getByRole("checkbox")).toHaveAttribute("name", "test-name");
|
expect(screen.getByRole("checkbox")).toHaveAttribute("name", "test-name");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes through additional props", () => {
|
it("passes through additional props to the input", () => {
|
||||||
render(<Checkbox data-testid="extra-prop" />);
|
render(<Checkbox data-testid="extra-prop" />);
|
||||||
expect(screen.getByRole("checkbox")).toHaveAttribute(
|
expect(screen.getByRole("checkbox")).toHaveAttribute("data-testid", "extra-prop");
|
||||||
"data-testid",
|
|
||||||
"extra-prop",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles checked state correctly", () => {
|
it("uncontrolled: toggles checked state when clicking the visual box", () => {
|
||||||
render(<Checkbox />);
|
render(<Checkbox />);
|
||||||
const checkbox = screen.getByRole("checkbox");
|
const checkbox = screen.getByRole("checkbox");
|
||||||
const presentation = screen.getByRole("presentation");
|
const presentation = screen.getByRole("presentation");
|
||||||
|
|
||||||
expect(checkbox).not.toBeChecked();
|
expect(checkbox).not.toBeChecked();
|
||||||
|
|
||||||
fireEvent.click(presentation);
|
fireEvent.click(presentation);
|
||||||
expect(checkbox).toBeChecked();
|
expect(checkbox).toBeChecked();
|
||||||
|
|
||||||
fireEvent.click(presentation);
|
fireEvent.click(presentation);
|
||||||
expect(checkbox).not.toBeChecked();
|
expect(checkbox).not.toBeChecked();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { useId } from "react";
|
import { useId, useState } from "react";
|
||||||
|
|
||||||
interface CheckboxProps {
|
interface CheckboxProps {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
defaultChecked?: boolean;
|
||||||
onChange?: (checked: boolean) => void;
|
onChange?: (checked: boolean) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
labelClassName?: string;
|
labelClassName?: string;
|
||||||
@@ -15,7 +16,8 @@ interface CheckboxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Checkbox({
|
export function Checkbox({
|
||||||
checked = false,
|
checked,
|
||||||
|
defaultChecked = false,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
id: propId,
|
id: propId,
|
||||||
@@ -28,10 +30,21 @@ export function Checkbox({
|
|||||||
const generatedId = useId();
|
const generatedId = useId();
|
||||||
const id = propId || generatedId;
|
const id = propId || generatedId;
|
||||||
|
|
||||||
const handleToggle = (): void => {
|
const isControlled = checked !== undefined;
|
||||||
if (!disabled) {
|
const [internal, setInternal] = useState<boolean>(defaultChecked);
|
||||||
onChange?.(!checked);
|
const value = checked ?? internal;
|
||||||
|
|
||||||
|
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const next = e.target.checked;
|
||||||
|
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternal(next);
|
||||||
|
}
|
||||||
|
onChange?.(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,11 +54,12 @@ export function Checkbox({
|
|||||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
data-testid="label-component"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id={id}
|
id={id}
|
||||||
checked={checked}
|
checked={value}
|
||||||
onChange={handleToggle}
|
onChange={handleToggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
required={required}
|
required={required}
|
||||||
@@ -57,10 +71,12 @@ export function Checkbox({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-gray-500 transition-colors",
|
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-gray-500 transition-colors",
|
||||||
"peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2",
|
"peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2",
|
||||||
{ "border-slate-500 bg-slate-500": checked },
|
{ "border-slate-500 bg-slate-500": value },
|
||||||
|
{ "opacity-50": disabled },
|
||||||
)}
|
)}
|
||||||
|
role="presentation"
|
||||||
>
|
>
|
||||||
{checked && (
|
{value && (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<Check className="h-4 w-4 text-white" />
|
<Check className="h-4 w-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export const FilterMulti = <K extends EnumArrayKeys<FilterState>>({
|
|||||||
key={val}
|
key={val}
|
||||||
checked={selected.includes(val)}
|
checked={selected.includes(val)}
|
||||||
onChange={(checked) => toggleValue(val, checked)}
|
onChange={(checked) => toggleValue(val, checked)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span className="dark:text-slate-200">{getLabel(val)}</span>
|
<span className="dark:text-slate-200">{getLabel(val)}</span>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
|||||||
Reference in New Issue
Block a user