Checkbox component

This commit is contained in:
Mats Bosson 2026-03-29 07:19:39 +07:00
parent 5bd158782a
commit 3a84dfaac9
4 changed files with 155 additions and 0 deletions

View File

@ -11,6 +11,9 @@
"style": { "style": {
"useConst": "error", "useConst": "error",
"useTemplate": "error" "useTemplate": "error"
},
"a11y": {
"useSemanticElements": "off"
} }
} }
}, },

View File

@ -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<JSX.ButtonHTMLAttributes<HTMLButtonElement>, "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<CheckedState>(
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 (
<button
type="button"
role="checkbox"
aria-checked={ariaChecked()}
aria-required={local.required || undefined}
data-state={dataState()}
disabled={local.disabled}
{...rest}
onClick={() => {
if (!local.disabled) {
const current = isChecked();
setChecked(current === "indeterminate" ? true : !current);
}
}}
>
{local.children}
{local.name && (
<input
type="checkbox"
aria-hidden="true"
tabIndex={-1}
name={local.name}
value={local.value ?? "on"}
checked={isChecked() === true}
ref={(el) => {
if (el) el.indeterminate = isChecked() === "indeterminate";
}}
style={{ display: "none" }}
/>
)}
</button>
);
}

View File

@ -0,0 +1,2 @@
export { Checkbox } from "./checkbox";
export type { CheckboxProps, CheckedState } from "./checkbox";

View File

@ -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(() => <Checkbox />);
expect(screen.getByRole("checkbox")).toBeTruthy();
});
it("is unchecked by default", () => {
render(() => <Checkbox />);
expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("false");
expect(screen.getByRole("checkbox").getAttribute("data-state")).toBe("unchecked");
});
it("checks on click", () => {
render(() => <Checkbox />);
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(() => <Checkbox defaultChecked />);
fireEvent.click(screen.getByRole("checkbox"));
expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("false");
});
it("supports indeterminate default state", () => {
render(() => <Checkbox defaultChecked="indeterminate" />);
expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("mixed");
expect(screen.getByRole("checkbox").getAttribute("data-state")).toBe("indeterminate");
});
it("indeterminate -> checked on click", () => {
render(() => <Checkbox defaultChecked="indeterminate" />);
fireEvent.click(screen.getByRole("checkbox"));
expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("true");
});
it("does not toggle when disabled", () => {
render(() => <Checkbox disabled />);
fireEvent.click(screen.getByRole("checkbox"));
expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("false");
});
it("controlled: stays at given value", () => {
render(() => <Checkbox checked={false} onCheckedChange={() => {}} />);
fireEvent.click(screen.getByRole("checkbox"));
expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe("false");
});
});