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)}