Checkbox
v0.5.0StableA bare checkbox control (built on Radix's `Checkbox`) — the Radix checkbox button is this component's own root element. It has no built-in label/hint/error; wrap it in `FormField` for that chrome.
registry/behivetech/atoms/checkbox
checkbox.tsx
1import * as RadixCheckbox from "@radix-ui/react-checkbox";2import { useId, useState } from "react";3import { getClassName } from "@bht-component-registry/class-names";4import styles from "./checkbox.module.scss";56import type { CheckedState } from "@radix-ui/react-checkbox";78export type { CheckedState };910export interface CheckboxProps extends Omit<11 RadixCheckbox.CheckboxProps,12 "className"13> {14 /** Additional class names to merge with the component root element */15 className?: string;16 /** Marks the field as invalid: adds the `--invalid` modifier and sets `aria-invalid="true"` */17 invalid?: boolean;18}1920/**21 * Bare checkbox control — the Radix checkbox button is this component's own22 * root element, no built-in label/hint/error. Wrap it in `FormField` for23 * that chrome: `<FormField label="..." htmlFor="x"><Checkbox id="x" /></FormField>`.24 */25export const Checkbox = ({26 className,27 id,28 checked,29 defaultChecked = false,30 onCheckedChange,31 invalid = false,32 "aria-invalid": ariaInvalid,33 ...rest34}: CheckboxProps) => {35 const generatedId = useId();36 const checkboxId = id ?? generatedId;3738 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";3940 // The controlled `checked` wins; otherwise the component tracks its own41 // state so `--checked` / `--indeterminate` work uncontrolled too.42 const [internalChecked, setInternalChecked] =43 useState<CheckedState>(defaultChecked);44 const currentChecked = checked ?? internalChecked;4546 const handleCheckedChange = (next: CheckedState) => {47 if (checked === undefined) {48 setInternalChecked(next);49 }50 onCheckedChange?.(next);51 };5253 const [rootClass, getChildClass] = getClassName({54 className,55 modifiers: {56 checked: currentChecked === true,57 indeterminate: currentChecked === "indeterminate",58 invalid: isInvalid,59 },60 rootClass: "BHT__Checkbox",61 styles,62 });6364 return (65 <RadixCheckbox.Root66 {...rest}67 id={checkboxId}68 checked={currentChecked}69 onCheckedChange={handleCheckedChange}70 className={rootClass}71 aria-invalid={isInvalid ? true : ariaInvalid}72 >73 <RadixCheckbox.Indicator className={getChildClass("indicator")}>74 {currentChecked === "indeterminate" ? (75 <svg76 viewBox="0 0 12 4"77 fill="none"78 stroke="currentColor"79 strokeWidth="2.5"80 strokeLinecap="round"81 aria-hidden="true"82 >83 <path d="M1 2H11" />84 </svg>85 ) : (86 <svg87 viewBox="0 0 12 10"88 fill="none"89 stroke="currentColor"90 strokeWidth="2.5"91 strokeLinecap="round"92 strokeLinejoin="round"93 aria-hidden="true"94 >95 <path d="M1 5L4.5 8.5L11 1" />96 </svg>97 )}98 </RadixCheckbox.Indicator>99 </RadixCheckbox.Root>100 );101};
checkbox.module.scss
1// Material Design 3 checkbox.2//3// `color` on the root is not used for text — it is the state layer's tint4// (`currentColor` in ::after), so it tracks the box: on-surface-variant when5// unchecked, primary when checked, error when invalid.67.BHT__Checkbox {8 // Radix renders as <button role="checkbox">9 position: relative;10 width: 1rem;11 height: 1rem;12 flex-shrink: 0;13 border: 2px solid14 var(--md-sys-color-on-surface-variant, var(--color-border-strong));15 border-radius: 2px;16 background: transparent;17 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));18 display: inline-flex;19 align-items: center;20 justify-content: center;21 padding: 0;22 cursor: pointer;23 transition:24 background var(--md-sys-motion-duration-short-3, 150ms)25 var(--md-sys-motion-easing-standard, ease),26 border-color var(--md-sys-motion-duration-short-3, 150ms)27 var(--md-sys-motion-easing-standard, ease);2829 // Touch target: a 48px square hit area around the 16px box, without30 // changing its drawn size. px on purpose — a finger doesn't scale with type.31 &::before {32 content: "";33 position: absolute;34 top: 50%;35 left: 50%;36 translate: -50% -50%;37 width: 48px;38 height: 48px;39 }4041 // State layer: MD3 draws it as a circle around the box, not on the box.42 &::after {43 content: "";44 position: absolute;45 top: 50%;46 left: 50%;47 translate: -50% -50%;48 width: 2.5rem;49 height: 2.5rem;50 border-radius: var(--md-sys-shape-corner-full, 9999px);51 background: currentColor;52 opacity: 0;53 pointer-events: none;54 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)55 var(--md-sys-motion-easing-standard, ease);56 }5758 &:hover:not(:disabled)::after {59 opacity: var(--md-sys-state-hover-opacity, 0.08);60 }6162 &:focus-visible::after {63 opacity: var(--md-sys-state-focus-opacity, 0.1);64 }6566 &:active:not(:disabled)::after {67 opacity: var(--md-sys-state-pressed-opacity, 0.1);68 }6970 &--checked,71 &--indeterminate {72 &.BHT__Checkbox {73 background: var(--md-sys-color-primary, var(--color-primary));74 border-color: var(--md-sys-color-primary, var(--color-primary));75 color: var(--md-sys-color-primary, var(--color-primary));76 }77 }7879 &:focus-visible {80 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));81 outline-offset: 2px;82 }8384 &:disabled {85 opacity: 0.5;86 cursor: not-allowed;87 }8889 &--invalid {90 &.BHT__Checkbox {91 border-color: var(--md-sys-color-error, var(--color-error));92 color: var(--md-sys-color-error, var(--color-error));9394 &.BHT__Checkbox--checked,95 &.BHT__Checkbox--indeterminate {96 background: var(--md-sys-color-error, var(--color-error));97 border-color: var(--md-sys-color-error, var(--color-error));9899 .BHT__Checkbox {100 &__indicator {101 color: var(--md-sys-color-on-error, var(--color-on-primary));102 }103 }104 }105 }106 }107108 &__indicator {109 color: var(--md-sys-color-on-primary, var(--color-on-primary));110 display: flex;111 align-items: center;112 justify-content: center;113 width: 0.625rem;114 height: 0.625rem;115116 svg {117 width: 100%;118 height: 100%;119 }120 }121}
index.ts
1export { Checkbox } from "./checkbox.js";2export type { CheckboxProps, CheckedState } from "./checkbox.js";
checkbox.example.tsx
1import { useState } from "react";2import { FormField } from "@behivetech/forms.form-field";3import { Checkbox } from "./checkbox.js";45export const BasicCheckbox = () => {6 const [checked, setChecked] = useState(false);7 return (8 <Checkbox9 checked={checked}10 onCheckedChange={(val) => setChecked(val === true)}11 />12 );13};1415export const CheckboxWithFormField = () => {16 const [checked, setChecked] = useState(false);17 return (18 <FormField19 label="Accept terms and conditions"20 htmlFor="accept-terms"21 hint="You can withdraw consent at any time."22 >23 <Checkbox24 id="accept-terms"25 checked={checked}26 onCheckedChange={(val) => setChecked(val === true)}27 />28 </FormField>29 );30};3132export const CheckboxWithError = () => (33 <FormField34 label="Agree to terms"35 htmlFor="agree"36 error="You must agree to continue"37 >38 <Checkbox id="agree" invalid checked={false} onCheckedChange={() => {}} />39 </FormField>40);4142export const IndeterminateCheckbox = () => (43 <Checkbox checked="indeterminate" onCheckedChange={() => {}} />44);4546export const DisabledCheckbox = () => (47 <Checkbox disabled checked={false} onCheckedChange={() => {}} />48);4950export const RequiredCheckbox = () => {51 const [checked, setChecked] = useState(false);52 return (53 <FormField54 label="I have read the privacy policy"55 htmlFor="privacy"56 required57 >58 <Checkbox59 id="privacy"60 required61 checked={checked}62 onCheckedChange={(val) => setChecked(val === true)}63 />64 </FormField>65 );66};
checkbox.spec.tsx
1import { render, screen } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Checkbox } from "./checkbox.js";45describe("Checkbox", () => {6 it("renders as a checkbox role", () => {7 render(<Checkbox />);8 expect(screen.getByRole("checkbox")).toBeTruthy();9 });1011 it("calls onCheckedChange when clicked", async () => {12 const handleChange = vi.fn();13 render(<Checkbox onCheckedChange={handleChange} />);14 await userEvent.click(screen.getByRole("checkbox"));15 expect(handleChange).toHaveBeenCalledWith(true);16 });1718 it("does not fire onCheckedChange when disabled", async () => {19 const handleChange = vi.fn();20 render(<Checkbox disabled onCheckedChange={handleChange} />);21 await userEvent.click(screen.getByRole("checkbox"));22 expect(handleChange).not.toHaveBeenCalled();23 });2425 it("passes aria-invalid through to the root element", () => {26 render(<Checkbox aria-invalid="true" />);27 expect(screen.getByRole("checkbox").getAttribute("aria-invalid")).toBe(28 "true",29 );30 });3132 it("adds the --invalid modifier and aria-invalid for the invalid prop", () => {33 render(<Checkbox invalid />);34 const el = screen.getByRole("checkbox");35 expect(el.className).toContain("--invalid");36 expect(el.getAttribute("aria-invalid")).toBe("true");37 });3839 it("adds the --invalid modifier for a direct aria-invalid", () => {40 render(<Checkbox aria-invalid="true" />);41 expect(screen.getByRole("checkbox").className).toContain("--invalid");42 });4344 it("has no --invalid modifier by default", () => {45 render(<Checkbox />);46 expect(screen.getByRole("checkbox").className).not.toContain("--invalid");47 });4849 it("sets data-state to indeterminate", () => {50 render(<Checkbox checked="indeterminate" onCheckedChange={() => {}} />);51 expect(screen.getByRole("checkbox").getAttribute("data-state")).toBe(52 "indeterminate",53 );54 });5556 it("adds --checked and aria-checked when controlled", () => {57 render(<Checkbox checked onCheckedChange={() => {}} />);58 const el = screen.getByRole("checkbox");59 expect(el.className).toContain("--checked");60 expect(el.getAttribute("aria-checked")).toBe("true");61 expect(el.getAttribute("data-state")).toBe("checked");62 });6364 it("adds --checked from defaultChecked and drops it when toggled off", async () => {65 const handleChange = vi.fn();66 render(<Checkbox defaultChecked onCheckedChange={handleChange} />);67 const el = screen.getByRole("checkbox");68 expect(el.className).toContain("--checked");69 expect(el.getAttribute("aria-checked")).toBe("true");70 await userEvent.click(el);71 expect(handleChange).toHaveBeenCalledWith(false);72 expect(el.className).not.toContain("--checked");73 expect(el.getAttribute("aria-checked")).toBe("false");74 });7576 it("adds --checked after an uncontrolled click and forwards the change", async () => {77 const handleChange = vi.fn();78 render(<Checkbox onCheckedChange={handleChange} />);79 const el = screen.getByRole("checkbox");80 expect(el.className).not.toContain("--checked");81 await userEvent.click(el);82 expect(handleChange).toHaveBeenCalledWith(true);83 expect(el.className).toContain("--checked");84 expect(el.getAttribute("data-state")).toBe("checked");85 });8687 it("keeps a controlled checkbox in step with its prop, not a click", async () => {88 render(<Checkbox checked={false} onCheckedChange={() => {}} />);89 const el = screen.getByRole("checkbox");90 await userEvent.click(el);91 expect(el.className).not.toContain("--checked");92 });9394 it("adds --indeterminate when controlled", () => {95 render(<Checkbox checked="indeterminate" onCheckedChange={() => {}} />);96 const el = screen.getByRole("checkbox");97 expect(el.className).toContain("--indeterminate");98 expect(el.className).not.toContain("--checked");99 expect(el.getAttribute("aria-checked")).toBe("mixed");100 });101102 it("adds --indeterminate from defaultChecked and draws the dash", () => {103 const { container } = render(<Checkbox defaultChecked="indeterminate" />);104 expect(screen.getByRole("checkbox").className).toContain("--indeterminate");105 expect(container.querySelector("svg path")?.getAttribute("d")).toBe(106 "M1 2H11",107 );108 });109110 it("applies additional className", () => {111 render(<Checkbox className="custom" />);112 expect(screen.getByRole("checkbox").className).toContain("custom");113 });114});