UNPKG

@loke/ui

Version:
169 lines (124 loc) 4.94 kB
--- name: checkbox type: core domain: forms requires: [loke-ui] description: > Checkbox + CheckboxIndicator primitives. Checked/unchecked/indeterminate states via data-state. Hidden native input for form participation. Indeterminate-to-checked toggle cycle. Detached-form prop pattern. Label association required for accessible name. --- # Checkbox `@loke/ui/checkbox` — headless checkbox built on `Primitive.button` with a hidden native `<input type="checkbox">` for form participation. **Exports:** `Checkbox`, `CheckboxIndicator`, `createCheckboxScope` ## Setup ```tsx import { Checkbox, CheckboxIndicator } from "@loke/ui/checkbox"; import { Label } from "@loke/ui/label"; function AcceptTerms() { return ( <div style={{ display: "flex", alignItems: "center", gap: 8 }}> <Checkbox id="terms" name="terms" defaultChecked={false}> <CheckboxIndicator> {/* style data-state="checked" and data-state="indeterminate" */} </CheckboxIndicator> </Checkbox> <Label htmlFor="terms">Accept terms</Label> </div> ); } ``` `data-state` values: `"checked"` | `"unchecked"` | `"indeterminate"` ## Core Patterns ### Indeterminate state Use `"indeterminate"` (the string literal) as the `CheckedState` value. On click, the component moves from `"indeterminate"` `true`, not to `false`. ```tsx import { useState } from "react"; import { Checkbox, CheckboxIndicator, type CheckedState } from "@loke/ui/checkbox"; function BulkSelect({ items }: { items: string[] }) { const [selected, setSelected] = useState<string[]>([]); const allChecked = selected.length === items.length; const someChecked = selected.length > 0 && !allChecked; const headState: CheckedState = allChecked ? true : someChecked ? "indeterminate" : false; return ( <Checkbox checked={headState} onCheckedChange={(state) => { // state will be true when coming from indeterminate setSelected(state === true ? items : []); }} > <CheckboxIndicator>{someChecked ? "—" : "✓"}</CheckboxIndicator> </Checkbox> ); } ``` ### Controlled state ```tsx const [checked, setChecked] = useState<CheckedState>(false); <Checkbox checked={checked} onCheckedChange={setChecked}> <CheckboxIndicator>✓</CheckboxIndicator> </Checkbox> ``` ### Form participation When inside a `<form>`, a hidden `<input type="checkbox">` is rendered automatically. The `name` and `value` props map to it. ```tsx <form action="/submit" method="post"> <Checkbox name="newsletter" value="yes" defaultChecked> <CheckboxIndicator>✓</CheckboxIndicator> </Checkbox> </form> ``` **Detached form**if the Checkbox renders outside the `<form>` element, pass the `form` prop with the form's `id`: ```tsx <form id="settings-form" onSubmit={handleSubmit} /> <Checkbox name="notifications" form="settings-form"> <CheckboxIndicator>✓</CheckboxIndicator> </Checkbox> ``` ## Common Mistakes ### 1. Wrong indeterminate toggle expectation **Wrong:** Assuming indeterminate unchecked on click. ```tsx // This will not behave as expected: // indeterminate -> false -> true -> false ... onCheckedChange={(state) => { if (state === "indeterminate") setChecked(false); }} ``` **Correct:** The built-in cycle is `indeterminate true false true`. Do not override the click handler to force `false` when the state is `"indeterminate"` — it fires *after* the state has already moved to `true`. Source: `src/components/checkbox/checkbox.tsx` — toggle logic: `isIndeterminate(prevChecked) ? true : !prevChecked` ### 2. Detached form — hidden input not submitted **Wrong:** Rendering Checkbox outside a `<form>` without the `form` prop. The component uses `button.closest("form")` to detect its form. If no form ancestor is found, the hidden native input is not rendered and the value is not submitted. **Correct:** ```tsx <Checkbox name="active" form="my-form-id" defaultChecked> <CheckboxIndicator>✓</CheckboxIndicator> </Checkbox> ``` Source: `src/components/checkbox/checkbox.tsx` — `BubbleInput` form detection ### 3. Missing label — no accessible name Checkbox renders as `<button role="checkbox">`. Without an associated Label, screen readers announce it as unlabelled. **Wrong:** ```tsx <Checkbox> <CheckboxIndicator>✓</CheckboxIndicator> </Checkbox> <span>Agree to terms</span> ``` **Correct:** Either wrap or use `htmlFor`: ```tsx <Label htmlFor="agree"> <Checkbox id="agree"> <CheckboxIndicator>✓</CheckboxIndicator> </Checkbox> Agree to terms </Label> ``` Source: `src/components/checkbox/checkbox.tsx` — renders `Primitive.button` ## Cross-references - **Label** (`@loke/ui/label`) — accessible labelling for Checkbox and Switch - **Switch** (`@loke/ui/switch`) — use for binary on/off; no indeterminate state - **Choosing the Right Component**Checkbox vs Switch decision guide