diff --git a/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx b/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx index eab95fa7e..0d5bf934e 100644 --- a/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx +++ b/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx @@ -1,56 +1,75 @@ import { Checkbox } from "@components/UI/Checkbox/index.tsx"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type React from "react"; 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; - }) => ( - - ), -})); - describe("Checkbox", () => { beforeEach(cleanup); - it("renders unchecked by default", () => { + it("renders unchecked by default (uncontrolled)", () => { render(); const checkbox = screen.getByRole("checkbox"); + const presentation = screen.getByRole("presentation"); 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", () => { - render(); + it("respects defaultChecked in uncontrolled mode", () => { + render(); expect(screen.getByRole("checkbox")).toBeChecked(); - expect(screen.getByRole("presentation")).toBeInTheDocument(); }); - it("calls onChange when clicked", () => { + it("renders checked when controlled with checked=true", () => { + render(); + 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(); render(); - fireEvent.click(screen.getByRole("presentation")); - expect(onChange).toHaveBeenCalledWith(true); + const checkbox = screen.getByRole("checkbox"); + const presentation = screen.getByRole("presentation"); - fireEvent.click(screen.getByRole("presentation")); - expect(onChange).toHaveBeenCalledWith(false); + fireEvent.click(presentation); + 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(); + + 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(); + + const checkbox = screen.getByRole("checkbox"); + const presentation = screen.getByRole("presentation"); + + fireEvent.click(presentation); + expect(onChange).toHaveBeenLastCalledWith(true); + + // parent updates `checked` based on onChange + rerender(); + expect(checkbox).toBeChecked(); + expect(presentation).toHaveClass("bg-slate-500"); }); it("uses provided id", () => { @@ -58,23 +77,16 @@ describe("Checkbox", () => { expect(screen.getByRole("checkbox").id).toBe("custom-id"); }); - it("renders children in Label component", () => { + it("renders children inside the label", () => { render(Test Label); - expect(screen.getByTestId("label-component")).toHaveTextContent( - "Test Label", - ); + expect(screen.getByTestId("label-component")).toHaveTextContent("Test Label"); }); - it("applies custom className", () => { + it("applies custom className to wrapper label", () => { const { container } = render(); expect(container.firstChild).toHaveClass("custom-class"); }); - it("applies labelClassName to Label", () => { - render(Test); - expect(screen.getByTestId("label-component")).toHaveClass("label-class"); - }); - it("disables checkbox when disabled prop is true", () => { render(); expect(screen.getByRole("checkbox")).toBeDisabled(); @@ -84,7 +96,6 @@ describe("Checkbox", () => { it("does not call onChange when disabled", () => { const onChange = vi.fn(); render(); - fireEvent.click(screen.getByRole("presentation")); expect(onChange).not.toHaveBeenCalled(); }); @@ -99,24 +110,19 @@ describe("Checkbox", () => { expect(screen.getByRole("checkbox")).toHaveAttribute("name", "test-name"); }); - it("passes through additional props", () => { + it("passes through additional props to the input", () => { render(); - expect(screen.getByRole("checkbox")).toHaveAttribute( - "data-testid", - "extra-prop", - ); + expect(screen.getByRole("checkbox")).toHaveAttribute("data-testid", "extra-prop"); }); - it("toggles checked state correctly", () => { + it("uncontrolled: toggles checked state when clicking the visual box", () => { render(); const checkbox = screen.getByRole("checkbox"); const presentation = screen.getByRole("presentation"); expect(checkbox).not.toBeChecked(); - fireEvent.click(presentation); expect(checkbox).toBeChecked(); - fireEvent.click(presentation); expect(checkbox).not.toBeChecked(); }); diff --git a/packages/web/src/components/UI/Checkbox/index.tsx b/packages/web/src/components/UI/Checkbox/index.tsx index eaed84ea2..c18f6241b 100644 --- a/packages/web/src/components/UI/Checkbox/index.tsx +++ b/packages/web/src/components/UI/Checkbox/index.tsx @@ -1,9 +1,10 @@ import { cn } from "@core/utils/cn.ts"; import { Check } from "lucide-react"; -import { useId } from "react"; +import { useId, useState } from "react"; interface CheckboxProps { checked?: boolean; + defaultChecked?: boolean; onChange?: (checked: boolean) => void; className?: string; labelClassName?: string; @@ -15,7 +16,8 @@ interface CheckboxProps { } export function Checkbox({ - checked = false, + checked, + defaultChecked = false, onChange, className, id: propId, @@ -28,10 +30,21 @@ export function Checkbox({ const generatedId = useId(); const id = propId || generatedId; - const handleToggle = (): void => { - if (!disabled) { - onChange?.(!checked); + const isControlled = checked !== undefined; + const [internal, setInternal] = useState(defaultChecked); + const value = checked ?? internal; + + const handleToggle = (e: React.ChangeEvent) => { + if (disabled) { + return; + } + + const next = e.target.checked; + + if (!isControlled) { + setInternal(next); } + onChange?.(next); }; return ( @@ -41,11 +54,12 @@ export function Checkbox({ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer", className, )} + data-testid="label-component" > - {checked && ( + {value && (
diff --git a/packages/web/src/components/generic/Filter/FilterComponents.tsx b/packages/web/src/components/generic/Filter/FilterComponents.tsx index ceec2fa11..509b76c7b 100644 --- a/packages/web/src/components/generic/Filter/FilterComponents.tsx +++ b/packages/web/src/components/generic/Filter/FilterComponents.tsx @@ -170,6 +170,7 @@ export const FilterMulti = >({ key={val} checked={selected.includes(val)} onChange={(checked) => toggleValue(val, checked)} + className="flex items-center gap-2" > {getLabel(val)}