@loke/ui
Version:
169 lines (124 loc) • 4.94 kB
Markdown
---
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
`/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** (`/ui/label`) — accessible labelling for Checkbox and Switch
- **Switch** (`/ui/switch`) — use for binary on/off; no indeterminate state
- **Choosing the Right Component** — Checkbox vs Switch decision guide