API
Checkbox
Native checkboxes, restyled, with select-all groups.
Installation
pnpm dlx zazz-ui add checkboxnpx zazz-ui add checkboxyarn dlx zazz-ui add checkboxbunx zazz-ui add checkboxAlso adds fields, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
Default
<fieldset data-ui="field-group">
<legend>Preferences</legend>
<div
style="
--display: flex-col;
--gap: 2;
"
>
<label data-ui="field" data-field-orientation="horizontal">
<input type="checkbox" name="preferences-all" data-checkbox-controls="preferences" />
<span data-field-slot="label">Select all</span>
</label>
<label data-ui="field" data-field-orientation="horizontal">
<input type="checkbox" name="preferences" value="newsletter" />
<span data-field-slot="label">Subscribe to the newsletter</span>
</label>
<label data-ui="field" data-field-orientation="horizontal">
<input type="checkbox" name="preferences" value="terms" checked />
<span data-field-slot="label">Accept terms & conditions</span>
</label>
</div>
</fieldset>/**
* checkbox.css — Checkbox (input[type="checkbox"]:not([role="switch"]))
*
* @layer variables, reset
* @requires _layers.css, _variables.css, _properties.css (--_*-resolved, --_focus-shadow), fields.css (--ui-field-* background/border)
* @uses appearance: none — redraws the native control from theme tokens
* @uses checked/indeterminate fill with --primary and layer a glyph (never color alone)
* @tokens --ui-checkbox-* (@layer variables; named after the utilities they back;
* surface/border default to the --ui-field-* family)
* @see checkbox.ts: derives select-all group state (data-checkbox-controls)
* into the JS-only indeterminate property; styling keys off :indeterminate
*/
@layer variables {
:root {
/* surface + border follow --ui-field-* */
--ui-checkbox-size: calc(var(--spacing) * 4.5);
--ui-checkbox-bg: var(--ui-field-bg);
--ui-checkbox-foreground: var(--color-primary-foreground);
--ui-checkbox-border-width: var(--ui-field-border-width);
--ui-checkbox-border-style: var(--ui-field-border-style);
--ui-checkbox-border-color: var(--ui-field-border-color);
--ui-checkbox-border-color--hover: var(--ui-field-border-color--hover);
--ui-checkbox-bg--checked: var(--color-primary);
--ui-checkbox-border-color--checked: var(--color-primary);
--ui-checkbox-rounded: var(--radius-xs);
--ui-checkbox-glyph-size: 1em;
--ui-checkbox-checkmark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E")
center / contain no-repeat;
--ui-checkbox-indeterminate-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E")
center / contain no-repeat;
}
}
@layer reset {
:where(input[type="checkbox"]:not([role="switch"])) {
position: relative;
-webkit-appearance: none;
appearance: none;
flex-shrink: 0;
/* dual-mode: --size is a scale number or a length */
--_size-len: var(--_size-resolved, var(--ui-checkbox-size));
--_size-num: var(--_size-resolved, var(--ui-checkbox-size));
inline-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));
block-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));
background-color: var(--_bg-resolved, var(--ui-checkbox-bg));
border-width: var(--_border-width-resolved, var(--ui-checkbox-border-width));
border-style: var(--_border-style-resolved, var(--ui-checkbox-border-style));
border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color));
border-radius: var(--_rounded-resolved, var(--ui-checkbox-rounded));
/* centres the ::before glyph */
display: inline-grid;
place-items: center;
background-clip: padding-box;
cursor: pointer;
/* The ring is published to --_focus-shadow so a --shadow utility keeps
it; the transparent outline twin keeps focus visible in forced-colors */
--_ring-offset-width: 0px;
--_ring-width: 0px;
--_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
--_focus-shadow:
0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
var(--_ring-fill, var(--ring-shadow-color)),
inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
var(--_ring-fill, var(--ring-shadow-color));
box-shadow: var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, 0 0 #0000);
outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
outline-offset: calc(
var(--outline-offset) - var(--_focus-in) *
(2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
);
transition-property: border-color, background-color, color, opacity, box-shadow;
transition-duration: var(--default-transition-duration);
transition-timing-function: var(--default-transition-timing-function);
}
@media (hover: hover) {
:where(input[type="checkbox"]:not([role="switch"]):hover) {
border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color--hover));
}
}
:where(input[type="checkbox"]:not([role="switch"]):focus-visible) {
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
:where(
input[type="checkbox"]:not([role="switch"]):checked,
input[type="checkbox"]:not([role="switch"]):indeterminate
) {
background-color: var(--_bg-resolved, var(--ui-checkbox-bg--checked));
border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color--checked));
color: var(--ui-checkbox-foreground);
}
/* The glyph is a masked ::before painted in currentColor (pseudo-elements
are invalid inside :where(), so the :where() closes before ::before) */
:where(input[type="checkbox"]:not([role="switch"]):checked)::before,
:where(input[type="checkbox"]:not([role="switch"]):indeterminate)::before {
content: "";
position: absolute;
pointer-events: none;
background-color: currentColor;
inline-size: var(--ui-checkbox-glyph-size);
block-size: var(--ui-checkbox-glyph-size);
max-inline-size: 100%;
max-block-size: 100%;
transition-property: background-color, opacity, transform;
transition-duration: calc(var(--default-transition-duration) * 0.75);
transition-delay: calc(var(--default-transition-duration) * 0.25);
transition-timing-function: var(--default-transition-timing-function);
transform: scale(0) translateY(100%);
opacity: 0;
will-change: transform;
}
:where(input[type="checkbox"]:not([role="switch"]):checked)::before {
mask: var(--ui-checkbox-checkmark-mask);
transform: scale(1);
opacity: 1;
@starting-style {
transform: scale(0) translateY(100%);
opacity: 0;
}
}
:where(input[type="checkbox"]:not([role="switch"]):indeterminate)::before {
mask: var(--ui-checkbox-indeterminate-mask);
transform: scale(1);
opacity: 1;
@starting-style {
transform: scaleX(0);
opacity: 0;
}
}
}
/* Disabled and committed-invalid states: an invalid control remaps the
--ui-field-* family on itself. */
@layer ui.components {
input[type="checkbox"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}// primitives/checkbox/checkbox.js
"use strict";
/**
* @fileoverview Checkbox select-all groups.
* @description Derives a select-all checkbox's tri-state from its members.
* A controller declares `data-checkbox-controls="<name>"` and manages every
* checkbox sharing that `name` within the same form (or the document when
* unassociated). Checking the controller checks or unchecks all members;
* member changes roll back up as checked (all), unchecked (none), or
* indeterminate (a subset) — the mixed state is the JS-only `indeterminate`
* property (the platform has no content attribute for it), painted by
* `:indeterminate` in checkbox.css. Members inside a `<tr>` reflect their
* state as `data-table-state="selected"` on the row, lighting up the table
* primitive's selected-row styling.
*
* State follows the kit's signals division of labor (`base/signals.ts`): the
* delegated change listener is the input adapter writing member states into
* `state`; `deriveTriState` is the pure derived logic under `computed`; one
* `effect` per group writes the controller property and row attributes back
* to the DOM, batched to a microtask.
*
* @example
* <table data-ui="table">
* <thead><tr><th><input type="checkbox" name="tasks-all" data-checkbox-controls="tasks" /></th>...</tr></thead>
* <tbody><tr><td><input type="checkbox" name="tasks" /></td>...</tr></tbody>
* </table>
*/
import { computed, effect, state } from "../../base/signals.js";
import { registerRefresh } from "../../base/zazz-element.js";
const CONTROLS_ATTR = "data-checkbox-controls";
/**
* @description Derives a controller's tri-state from its members' checked
* states: `"all"` when every member is checked (and there is at least one),
* `"none"` when none are, `"some"` for a subset.
*
* @param checked - Each member's checked state.
* @returns The derived tri-state.
*/
function deriveTriState(checked) {
const count = checked.filter(Boolean).length;
if (count === 0)
return "none";
return count === checked.length ? "all" : "some";
}
/**
* @description Collects the member checkboxes a controller manages: every
* checkbox sharing the controlled `name` in the controller's form, or in the
* document when the controller has no form.
*
* @param controller - The select-all checkbox.
* @returns The managed member checkboxes (never the controller itself).
* @private
*/
function membersOf(controller) {
const name = controller.getAttribute(CONTROLS_ATTR);
if (!name)
return [];
const scope = controller.form ?? controller.ownerDocument;
const inputs = scope.querySelectorAll('input[type="checkbox"]');
return Array.from(inputs).filter((input) => input.name === name && input !== controller);
}
/**
* @description Finds the controller managing a member checkbox, if any.
*
* @param member - A checkbox that may belong to a select-all group.
* @returns The controller, or null when the checkbox is unmanaged.
* @private
*/
function controllerOf(member) {
if (!member.name)
return null;
const scope = member.form ?? member.ownerDocument;
const controllers = scope.querySelectorAll(`input[type="checkbox"][${CONTROLS_ATTR}]`);
for (const controller of controllers) {
if (controller.getAttribute(CONTROLS_ATTR) === member.name && controller !== member) {
return controller;
}
}
return null;
}
/** Live groups keyed by controller; pruned in `initCheckboxes`. */
const groups = new Map();
/**
* @description Reads members' checked states from the DOM into a group's
* signal. The DOM stays the source of truth for the element list.
*
* @param controller - The group's select-all checkbox.
* @private
*/
function recount(controller) {
groups.get(controller)?.members.set(membersOf(controller).map((member) => member.checked));
}
/**
* @description Creates the reactive group for a controller: a member-state
* signal, the pure tri-state derivation, and one effect writing the
* controller's `checked`/`indeterminate` and each member row's
* `data-table-state="selected"` back to the DOM. The effect's first run is
* immediate; re-runs batch to a microtask.
*
* @param controller - The select-all checkbox.
* @private
*/
function createGroup(controller) {
const members = state(membersOf(controller).map((member) => member.checked));
const tri = computed(() => deriveTriState(members.get()));
const dispose = effect(() => {
const derived = tri.get();
const checked = members.get();
controller.checked = derived === "all";
controller.indeterminate = derived === "some";
membersOf(controller).forEach((member, index) => {
const row = member.closest("tr");
if (!row)
return;
if (checked[index]) {
row.setAttribute("data-table-state", "selected");
}
else if (row.getAttribute("data-table-state") === "selected") {
row.removeAttribute("data-table-state");
}
});
});
groups.set(controller, { members, dispose });
}
/**
* @description Delegated change handler (input adapter): a controller change
* fans out to its members imperatively then writes the group signal once; a
* member change recounts its group.
*
* @param event - The change event.
* @private
*/
function onChange(event) {
const target = event.target;
if (!(target instanceof HTMLInputElement) || target.type !== "checkbox")
return;
if (target.hasAttribute(CONTROLS_ATTR)) {
if (!groups.has(target))
createGroup(target);
for (const member of membersOf(target))
member.checked = target.checked;
recount(target);
return;
}
const controller = controllerOf(target);
if (!controller)
return;
if (!groups.has(controller))
createGroup(controller);
recount(controller);
}
/**
* @description Initializes select-all groups in a scope: prunes groups whose
* controllers left the DOM (disposing their effects), creates groups for new
* controllers, and recounts existing ones. Idempotent: re-running is a no-op.
*
* @param scope - Root to scan; defaults to the whole document.
*/
function initCheckboxes(scope = document) {
for (const [controller, group] of groups) {
if (!controller.isConnected) {
group.dispose();
groups.delete(controller);
}
}
const controllers = scope.querySelectorAll(`input[type="checkbox"][${CONTROLS_ATTR}]`);
for (const controller of controllers) {
if (groups.has(controller)) {
recount(controller);
}
else {
createGroup(controller);
}
}
}
// Auto-initialize when DOM is ready (only in browser environment)
if (typeof window !== "undefined" && typeof document !== "undefined") {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => initCheckboxes());
}
else {
initCheckboxes();
}
// Delegate at document level so groups work wherever checkboxes appear.
document.addEventListener("change", onChange);
// After an in-page navigation swap, re-scan the new content.
registerRefresh(initCheckboxes);
}
export { deriveTriState, initCheckboxes };Tasklist
| Task | Assignee | Due | |
|---|---|---|---|
| Audit focus rings | Robin | Aug 28 | |
| Document table variants | Sasha | Aug 29 | |
| Ship checkbox groups | Quinn | Sep 01 | |
| Retire legacy tokens | Robin | Sep 03 |
<table data-ui="table">
<caption>
Sprint tasks with managed selection.
</caption>
<thead>
<tr>
<th>
<input
type="checkbox"
name="tasks-all"
data-checkbox-controls="tasks"
aria-label="Select all tasks"
/>
</th>
<th>Task</th>
<th>Assignee</th>
<th style="--text-align: end">Due</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input
type="checkbox"
name="tasks"
value="ZAZZ-101"
aria-label="Select Audit focus rings"
checked
/>
</td>
<td>Audit focus rings</td>
<td>Robin</td>
<td style="--text-align: end">Aug 28</td>
</tr>
<tr>
<td>
<input
type="checkbox"
name="tasks"
value="ZAZZ-102"
aria-label="Select Document table variants"
/>
</td>
<td>Document table variants</td>
<td>Sasha</td>
<td style="--text-align: end">Aug 29</td>
</tr>
<tr>
<td>
<input
type="checkbox"
name="tasks"
value="ZAZZ-103"
aria-label="Select Ship checkbox groups"
checked
/>
</td>
<td>Ship checkbox groups</td>
<td>Quinn</td>
<td style="--text-align: end">Sep 01</td>
</tr>
<tr>
<td>
<input
type="checkbox"
name="tasks"
value="ZAZZ-104"
aria-label="Select Retire legacy tokens"
/>
</td>
<td>Retire legacy tokens</td>
<td>Robin</td>
<td style="--text-align: end">Sep 03</td>
</tr>
</tbody>
</table>/**
* checkbox.css — Checkbox (input[type="checkbox"]:not([role="switch"]))
*
* @layer variables, reset
* @requires _layers.css, _variables.css, _properties.css (--_*-resolved, --_focus-shadow), fields.css (--ui-field-* background/border)
* @uses appearance: none — redraws the native control from theme tokens
* @uses checked/indeterminate fill with --primary and layer a glyph (never color alone)
* @tokens --ui-checkbox-* (@layer variables; named after the utilities they back;
* surface/border default to the --ui-field-* family)
* @see checkbox.ts: derives select-all group state (data-checkbox-controls)
* into the JS-only indeterminate property; styling keys off :indeterminate
*/
@layer variables {
:root {
/* surface + border follow --ui-field-* */
--ui-checkbox-size: calc(var(--spacing) * 4.5);
--ui-checkbox-bg: var(--ui-field-bg);
--ui-checkbox-foreground: var(--color-primary-foreground);
--ui-checkbox-border-width: var(--ui-field-border-width);
--ui-checkbox-border-style: var(--ui-field-border-style);
--ui-checkbox-border-color: var(--ui-field-border-color);
--ui-checkbox-border-color--hover: var(--ui-field-border-color--hover);
--ui-checkbox-bg--checked: var(--color-primary);
--ui-checkbox-border-color--checked: var(--color-primary);
--ui-checkbox-rounded: var(--radius-xs);
--ui-checkbox-glyph-size: 1em;
--ui-checkbox-checkmark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E")
center / contain no-repeat;
--ui-checkbox-indeterminate-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E")
center / contain no-repeat;
}
}
@layer reset {
:where(input[type="checkbox"]:not([role="switch"])) {
position: relative;
-webkit-appearance: none;
appearance: none;
flex-shrink: 0;
/* dual-mode: --size is a scale number or a length */
--_size-len: var(--_size-resolved, var(--ui-checkbox-size));
--_size-num: var(--_size-resolved, var(--ui-checkbox-size));
inline-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));
block-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));
background-color: var(--_bg-resolved, var(--ui-checkbox-bg));
border-width: var(--_border-width-resolved, var(--ui-checkbox-border-width));
border-style: var(--_border-style-resolved, var(--ui-checkbox-border-style));
border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color));
border-radius: var(--_rounded-resolved, var(--ui-checkbox-rounded));
/* centres the ::before glyph */
display: inline-grid;
place-items: center;
background-clip: padding-box;
cursor: pointer;
/* The ring is published to --_focus-shadow so a --shadow utility keeps
it; the transparent outline twin keeps focus visible in forced-colors */
--_ring-offset-width: 0px;
--_ring-width: 0px;
--_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
--_focus-shadow:
0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
var(--_ring-fill, var(--ring-shadow-color)),
inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
var(--_ring-fill, var(--ring-shadow-color));
box-shadow: var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, 0 0 #0000);
outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
outline-offset: calc(
var(--outline-offset) - var(--_focus-in) *
(2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
);
transition-property: border-color, background-color, color, opacity, box-shadow;
transition-duration: var(--default-transition-duration);
transition-timing-function: var(--default-transition-timing-function);
}
@media (hover: hover) {
:where(input[type="checkbox"]:not([role="switch"]):hover) {
border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color--hover));
}
}
:where(input[type="checkbox"]:not([role="switch"]):focus-visible) {
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
:where(
input[type="checkbox"]:not([role="switch"]):checked,
input[type="checkbox"]:not([role="switch"]):indeterminate
) {
background-color: var(--_bg-resolved, var(--ui-checkbox-bg--checked));
border-color: var(--_border-color-resolved, var(--ui-checkbox-border-color--checked));
color: var(--ui-checkbox-foreground);
}
/* The glyph is a masked ::before painted in currentColor (pseudo-elements
are invalid inside :where(), so the :where() closes before ::before) */
:where(input[type="checkbox"]:not([role="switch"]):checked)::before,
:where(input[type="checkbox"]:not([role="switch"]):indeterminate)::before {
content: "";
position: absolute;
pointer-events: none;
background-color: currentColor;
inline-size: var(--ui-checkbox-glyph-size);
block-size: var(--ui-checkbox-glyph-size);
max-inline-size: 100%;
max-block-size: 100%;
transition-property: background-color, opacity, transform;
transition-duration: calc(var(--default-transition-duration) * 0.75);
transition-delay: calc(var(--default-transition-duration) * 0.25);
transition-timing-function: var(--default-transition-timing-function);
transform: scale(0) translateY(100%);
opacity: 0;
will-change: transform;
}
:where(input[type="checkbox"]:not([role="switch"]):checked)::before {
mask: var(--ui-checkbox-checkmark-mask);
transform: scale(1);
opacity: 1;
@starting-style {
transform: scale(0) translateY(100%);
opacity: 0;
}
}
:where(input[type="checkbox"]:not([role="switch"]):indeterminate)::before {
mask: var(--ui-checkbox-indeterminate-mask);
transform: scale(1);
opacity: 1;
@starting-style {
transform: scaleX(0);
opacity: 0;
}
}
}
/* Disabled and committed-invalid states: an invalid control remaps the
--ui-field-* family on itself. */
@layer ui.components {
input[type="checkbox"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}// primitives/checkbox/checkbox.js
"use strict";
/**
* @fileoverview Checkbox select-all groups.
* @description Derives a select-all checkbox's tri-state from its members.
* A controller declares `data-checkbox-controls="<name>"` and manages every
* checkbox sharing that `name` within the same form (or the document when
* unassociated). Checking the controller checks or unchecks all members;
* member changes roll back up as checked (all), unchecked (none), or
* indeterminate (a subset) — the mixed state is the JS-only `indeterminate`
* property (the platform has no content attribute for it), painted by
* `:indeterminate` in checkbox.css. Members inside a `<tr>` reflect their
* state as `data-table-state="selected"` on the row, lighting up the table
* primitive's selected-row styling.
*
* State follows the kit's signals division of labor (`base/signals.ts`): the
* delegated change listener is the input adapter writing member states into
* `state`; `deriveTriState` is the pure derived logic under `computed`; one
* `effect` per group writes the controller property and row attributes back
* to the DOM, batched to a microtask.
*
* @example
* <table data-ui="table">
* <thead><tr><th><input type="checkbox" name="tasks-all" data-checkbox-controls="tasks" /></th>...</tr></thead>
* <tbody><tr><td><input type="checkbox" name="tasks" /></td>...</tr></tbody>
* </table>
*/
import { computed, effect, state } from "../../base/signals.js";
import { registerRefresh } from "../../base/zazz-element.js";
const CONTROLS_ATTR = "data-checkbox-controls";
/**
* @description Derives a controller's tri-state from its members' checked
* states: `"all"` when every member is checked (and there is at least one),
* `"none"` when none are, `"some"` for a subset.
*
* @param checked - Each member's checked state.
* @returns The derived tri-state.
*/
function deriveTriState(checked) {
const count = checked.filter(Boolean).length;
if (count === 0)
return "none";
return count === checked.length ? "all" : "some";
}
/**
* @description Collects the member checkboxes a controller manages: every
* checkbox sharing the controlled `name` in the controller's form, or in the
* document when the controller has no form.
*
* @param controller - The select-all checkbox.
* @returns The managed member checkboxes (never the controller itself).
* @private
*/
function membersOf(controller) {
const name = controller.getAttribute(CONTROLS_ATTR);
if (!name)
return [];
const scope = controller.form ?? controller.ownerDocument;
const inputs = scope.querySelectorAll('input[type="checkbox"]');
return Array.from(inputs).filter((input) => input.name === name && input !== controller);
}
/**
* @description Finds the controller managing a member checkbox, if any.
*
* @param member - A checkbox that may belong to a select-all group.
* @returns The controller, or null when the checkbox is unmanaged.
* @private
*/
function controllerOf(member) {
if (!member.name)
return null;
const scope = member.form ?? member.ownerDocument;
const controllers = scope.querySelectorAll(`input[type="checkbox"][${CONTROLS_ATTR}]`);
for (const controller of controllers) {
if (controller.getAttribute(CONTROLS_ATTR) === member.name && controller !== member) {
return controller;
}
}
return null;
}
/** Live groups keyed by controller; pruned in `initCheckboxes`. */
const groups = new Map();
/**
* @description Reads members' checked states from the DOM into a group's
* signal. The DOM stays the source of truth for the element list.
*
* @param controller - The group's select-all checkbox.
* @private
*/
function recount(controller) {
groups.get(controller)?.members.set(membersOf(controller).map((member) => member.checked));
}
/**
* @description Creates the reactive group for a controller: a member-state
* signal, the pure tri-state derivation, and one effect writing the
* controller's `checked`/`indeterminate` and each member row's
* `data-table-state="selected"` back to the DOM. The effect's first run is
* immediate; re-runs batch to a microtask.
*
* @param controller - The select-all checkbox.
* @private
*/
function createGroup(controller) {
const members = state(membersOf(controller).map((member) => member.checked));
const tri = computed(() => deriveTriState(members.get()));
const dispose = effect(() => {
const derived = tri.get();
const checked = members.get();
controller.checked = derived === "all";
controller.indeterminate = derived === "some";
membersOf(controller).forEach((member, index) => {
const row = member.closest("tr");
if (!row)
return;
if (checked[index]) {
row.setAttribute("data-table-state", "selected");
}
else if (row.getAttribute("data-table-state") === "selected") {
row.removeAttribute("data-table-state");
}
});
});
groups.set(controller, { members, dispose });
}
/**
* @description Delegated change handler (input adapter): a controller change
* fans out to its members imperatively then writes the group signal once; a
* member change recounts its group.
*
* @param event - The change event.
* @private
*/
function onChange(event) {
const target = event.target;
if (!(target instanceof HTMLInputElement) || target.type !== "checkbox")
return;
if (target.hasAttribute(CONTROLS_ATTR)) {
if (!groups.has(target))
createGroup(target);
for (const member of membersOf(target))
member.checked = target.checked;
recount(target);
return;
}
const controller = controllerOf(target);
if (!controller)
return;
if (!groups.has(controller))
createGroup(controller);
recount(controller);
}
/**
* @description Initializes select-all groups in a scope: prunes groups whose
* controllers left the DOM (disposing their effects), creates groups for new
* controllers, and recounts existing ones. Idempotent: re-running is a no-op.
*
* @param scope - Root to scan; defaults to the whole document.
*/
function initCheckboxes(scope = document) {
for (const [controller, group] of groups) {
if (!controller.isConnected) {
group.dispose();
groups.delete(controller);
}
}
const controllers = scope.querySelectorAll(`input[type="checkbox"][${CONTROLS_ATTR}]`);
for (const controller of controllers) {
if (groups.has(controller)) {
recount(controller);
}
else {
createGroup(controller);
}
}
}
// Auto-initialize when DOM is ready (only in browser environment)
if (typeof window !== "undefined" && typeof document !== "undefined") {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => initCheckboxes());
}
else {
initCheckboxes();
}
// Delegate at document level so groups work wherever checkboxes appear.
document.addEventListener("change", onChange);
// After an in-page navigation swap, re-scan the new content.
registerRefresh(initCheckboxes);
}
export { deriveTriState, initCheckboxes };Behavior
primitives/checkbox/checkbox.js
Checkbox select-all groups. Derives a select-all checkbox's tri-state from its members. A controller declares data-checkbox-controls="<name>" and manages every checkbox sharing that name within the same form (or the document when unassociated). Checking the controller checks or unchecks all members; member changes roll back up as checked (all), unchecked (none), or indeterminate (a subset) — the mixed state is the JS-only indeterminate property (the platform has no content attribute for it), painted by :indeterminate in checkbox.css. Members inside a <tr> reflect their state as data-table-state="selected" on the row, lighting up the table primitive's selected-row styling.
State follows the kit's signals division of labor (base/signals.ts): the delegated change listener is the input adapter writing member states into state; deriveTriState is the pure derived logic under computed; one effect per group writes the controller property and row attributes back to the DOM, batched to a microtask.
Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-checkbox-controls | Config | free value | Configuration the kit's script reads. |
Requires: fields.
Hooks
Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.
| Hook | Default | State |
|---|---|---|
--ui-checkbox-bg | var(--ui-field-bg) | — |
--ui-checkbox-bg--checked | var(--color-primary) | checked |
--ui-checkbox-border-color | var(--ui-field-border-color) | — |
--ui-checkbox-border-color--checked | var(--color-primary) | checked |
--ui-checkbox-border-color--hover | var(--ui-field-border-color--hover) | hover |
--ui-checkbox-border-style | var(--ui-field-border-style) | — |
--ui-checkbox-border-width | var(--ui-field-border-width) | — |
--ui-checkbox-checkmark-mask | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center / contain no-repeat | — |
--ui-checkbox-foreground | var(--color-primary-foreground) | — |
--ui-checkbox-glyph-size | 1em | — |
--ui-checkbox-indeterminate-mask | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='none' stroke='currentColor' stroke-linecap='square' stroke-linejoin='square' stroke-width='3' viewBox='0 0 24 24'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E") center / contain no-repeat | — |
--ui-checkbox-rounded | var(--radius-xs) | — |
--ui-checkbox-size | calc(var(--spacing) * 4.5) | — |