diff --git a/biome.json b/biome.json index 0f4d4e7..6136b7c 100644 --- a/biome.json +++ b/biome.json @@ -11,6 +11,9 @@ "style": { "useConst": "error", "useTemplate": "error" + }, + "a11y": { + "useSemanticElements": "off" } } }, diff --git a/packages/core/src/components/checkbox/checkbox.tsx b/packages/core/src/components/checkbox/checkbox.tsx new file mode 100644 index 0000000..9e46e39 --- /dev/null +++ b/packages/core/src/components/checkbox/checkbox.tsx @@ -0,0 +1,97 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { createControllableSignal } from "../../primitives/create-controllable-signal"; + +/** Represents all three states a checkbox can be in. */ +export type CheckedState = boolean | "indeterminate"; + +export interface CheckboxProps + extends Omit, "onChange"> { + /** Controlled checked state. */ + checked?: CheckedState; + /** Default checked state (uncontrolled). @default false */ + defaultChecked?: CheckedState; + /** Called when checked state changes. */ + onCheckedChange?: (checked: CheckedState) => void; + /** Native form field name — renders a hidden checkbox input. */ + name?: string; + /** Value submitted with the form when checked. @default "on" */ + value?: string; + required?: boolean; + children?: JSX.Element; +} + +/** + * A tri-state checkbox control supporting checked, unchecked, and indeterminate. + * Click cycle: indeterminate → checked → unchecked → checked. + */ +export function Checkbox(props: CheckboxProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "checked", + "defaultChecked", + "onCheckedChange", + "disabled", + "required", + "name", + "value", + "children", + ]); + + const [isChecked, setChecked] = createControllableSignal( + local.onCheckedChange + ? { + value: () => local.checked, + defaultValue: () => local.defaultChecked ?? false, + onChange: local.onCheckedChange, + } + : { + value: () => local.checked, + defaultValue: () => local.defaultChecked ?? false, + }, + ); + + const ariaChecked = (): boolean | "mixed" => { + const c = isChecked(); + return c === "indeterminate" ? "mixed" : c; + }; + + const dataState = (): string => { + const c = isChecked(); + if (c === "indeterminate") return "indeterminate"; + return c ? "checked" : "unchecked"; + }; + + return ( + + ); +} diff --git a/packages/core/src/components/checkbox/index.ts b/packages/core/src/components/checkbox/index.ts new file mode 100644 index 0000000..23768d9 --- /dev/null +++ b/packages/core/src/components/checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox } from "./checkbox"; +export type { CheckboxProps, CheckedState } from "./checkbox"; diff --git a/packages/core/tests/components/checkbox/checkbox.test.tsx b/packages/core/tests/components/checkbox/checkbox.test.tsx new file mode 100644 index 0000000..8a117b0 --- /dev/null +++ b/packages/core/tests/components/checkbox/checkbox.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { Checkbox } from "../../../src/components/checkbox/index"; + +describe("Checkbox", () => { + it("renders with role=checkbox", () => { + render(() => ); + expect(screen.getByRole("checkbox")).toBeTruthy(); + }); + + it("is unchecked by default", () => { + render(() => ); + expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("false"); + expect(screen.getByRole("checkbox").getAttribute("data-state")).toBe("unchecked"); + }); + + it("checks on click", () => { + render(() => ); + fireEvent.click(screen.getByRole("checkbox")); + expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("true"); + expect(screen.getByRole("checkbox").getAttribute("data-state")).toBe("checked"); + }); + + it("unchecks when clicked again", () => { + render(() => ); + fireEvent.click(screen.getByRole("checkbox")); + expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("false"); + }); + + it("supports indeterminate default state", () => { + render(() => ); + expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("mixed"); + expect(screen.getByRole("checkbox").getAttribute("data-state")).toBe("indeterminate"); + }); + + it("indeterminate -> checked on click", () => { + render(() => ); + fireEvent.click(screen.getByRole("checkbox")); + expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("true"); + }); + + it("does not toggle when disabled", () => { + render(() => ); + fireEvent.click(screen.getByRole("checkbox")); + expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("false"); + }); + + it("controlled: stays at given value", () => { + render(() => {}} />); + fireEvent.click(screen.getByRole("checkbox")); + expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("false"); + }); +});