import type { JSX } from "@solidjs/web"; import { type Accessor, createContext, createMemo, createSignal, untrack, useContext, } from "solid-js"; import { type BaseUIEventReasons, REASONS, } from "../internals/change-event-details"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { mergeRootProps } from "../internals/merge-root-props"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../internals/observability/snapshot"; import { createStableCallback } from "../internals/stable-callback"; import { CheckboxGroupContext, type CheckboxGroupContextValue, } from "./checkbox-group-context.ts"; /** * Why the value array changed. CheckboxGroup carries the `"none"` reason — * children dispatch via plain clicks; the group itself doesn't distinguish * navigation/key/imperative origins in its public surface (matches React's * `CheckboxGroup.ChangeEventDetails`). */ export type CheckboxGroupChangeReason = BaseUIEventReasons["none"]; import type { CheckboxGroupChangeEventDetails } from "./checkbox-group-context"; export type { CheckboxGroupChangeEventDetails }; const EMPTY: readonly string[] = Object.freeze([]); /** * Options for {@link createCheckboxGroup}. Every reactive input is * `MaybeAccessor`; pass an accessor to make the value reactive. * * Design notes: * * - `value` is `MaybeAccessor`; the consumer always owns * the signal. Internal storage is the consumer's accessor — no defaultValue. * - `allValues` is the universe of child values for the parent-indicator * helper (all/none/mixed math). Optional; the parent helper degrades to * `false`/`false` when absent. * - `onValueChange(next, event, reason)` is **pre-change**. The consumer's * signal write is the only writer. Post-change observation is via * `createEffect(c.value, ...)` on the api. * * **Solid-native shape (2026-04-30).** No internal state. `value` is * required and the consumer always owns the signal. See `solidify.md` §6. */ export interface CreateCheckboxGroupOptions { /** Selected-value array. Consumer-owned signal. Pair with {@link onValueChange}. */ value: MaybeAccessor; /** * The group's `defaultValue` while the group is uncontrolled, exposed to * descendant `` elements through the legacy context so * they can mirror Base UI's seed derivation * (`defaultGroupValue.includes(value)`). `undefined` while the group is * controlled. Purely informational — the primitive itself never reads it. */ defaultValue?: MaybeAccessor; /** Universe of child values used by the parent-indicator helper. */ allValues?: MaybeAccessor; /** Whether the group (and every descendant checkbox) is disabled. */ disabled?: MaybeAccessor; /** Whether the group is read-only. */ readOnly?: MaybeAccessor; /** Whether the group is required for form submission. */ required?: MaybeAccessor; /** Form-control name for the group. */ name?: MaybeAccessor; /** * Pre-change callback. `event.preventDefault()` is now a no-op for * the primitive (the consumer's signal write is the only writer). */ onValueChange?: ( value: string[], event: Event, reason: CheckboxGroupChangeReason, ) => void; } /** Slot props emitted by `c.group()`. */ export interface CheckboxGroupSlotProps { role: "group"; "data-disabled": "" | undefined; "data-readonly": "" | undefined; "data-required": "" | undefined; } /** Parent-indicator bundle returned by {@link CheckboxGroupApi.parent}. */ export interface CheckboxGroupParentApi { /** True when every value in `allValues` is also in `value`. */ checked: Accessor; /** True when `value` is non-empty but doesn't cover all `allValues`. */ indeterminate: Accessor; /** * Plain `Map` (not reactive) of `childValue → disabled?`. Children push * into it during setup so the parent's flip-all math can skip disabled * entries that weren't checked. Mirrors React's `disabledStatesRef`. */ disabledStates: Map; /** * Click-the-parent flip helper. Cycles all-on / all-off / restore-mixed * on each call. The returned descriptor exposes the current `checked` / * `indeterminate` and an `onCheckedChange(next, event?, reason?)` setter * the wrapper invokes from the parent checkbox's onClick. */ getParentProps: () => { checked: boolean; indeterminate: boolean; onCheckedChange: ( next: boolean, event?: Event, reason?: CheckboxGroupChangeReason, ) => void; }; } /** Public api returned by {@link createCheckboxGroup}. */ export interface CheckboxGroupApi { value: Accessor; allValues: Accessor; disabled: Accessor; readOnly: Accessor; required: Accessor; name: Accessor; /** * Pure event-emitter: fires `onValueChange(next, event, reason)`. * The consumer's signal write (in their handler) is the only writer. * No-op while `disabled` or `readOnly`. */ setValue: ( next: readonly string[], event?: Event, reason?: CheckboxGroupChangeReason, ) => void; /** Parent-indicator helper bundle. */ parent: CheckboxGroupParentApi; /** Slot function for the group root element. */ group: ( userProps?: U, ) => CheckboxGroupSlotProps & (U extends object ? U : object); /** Context provider. Also publishes the legacy `CheckboxGroupContext`. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } const CheckboxGroupApiContext = createContext(null); /** * Read the nearest {@link CheckboxGroupApi} provided by `c.Provider`. Throws * when used outside the Provider. */ export function useCheckboxGroup(): CheckboxGroupApi { const ctx = useContext(CheckboxGroupApiContext); if (ctx == null) { throw new Error( "Base UI: useCheckboxGroup() must be called inside a .", ); } return ctx; } /** * Solid 2.0-native compound primitive for CheckboxGroup. Owns the selected * value array and the parent-indicator (all/none/mixed) bundle. Form wiring * (field registration, hidden inputs, name resolution) and rendering live in * the `` wrapper — those are integration concerns layered on * top of this primitive. * * Embeds cleanly with ``: the wrapper reads the legacy * {@link CheckboxGroupContext} (also published by `c.Provider`), routes * clicks through `groupContext.setValue(...)`, and derives each child's * `checked` from `groupContext.value().includes(childValue)`. The bypass * pattern in `checkbox-root.tsx`'s onClick keeps that interface unchanged. */ export function createCheckboxGroup( options: CreateCheckboxGroupOptions, ): CheckboxGroupApi { // `value` reads through the consumer's accessor — no internal signal, // no controlled-or-internal branching. const value = createMemo(() => readOption(options.value), { name: "checkbox-group.value", }); const allValues = createMemo( () => readOption(options.allValues), { name: "checkbox-group.allValues" }, ); const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "checkbox-group.disabled" }, ); const readOnly = createMemo( () => Boolean(readOption(options.readOnly)), { name: "checkbox-group.readOnly" }, ); const required = createMemo( () => Boolean(readOption(options.required)), { name: "checkbox-group.required" }, ); const name = createMemo(() => readOption(options.name), { name: "checkbox-group.name", }); // `setValue` is a pure event-emitter. The consumer is responsible for // updating their value-signal in response. No internal state to write. const setValue = ( next: readonly string[], event?: Event, reason: CheckboxGroupChangeReason = REASONS.none, ): void => { if (untrack(disabled) || untrack(readOnly)) return; const ev = event ?? new Event("valuechange", { cancelable: true }); const nextArr = next.slice(); options.onValueChange?.(nextArr, ev, reason); }; // --- Parent-indicator helper ----------------------------------------- // Mirrors `useCheckboxGroupParent` (the legacy hook) but lives on the // primitive so external callers get the same shape via `c.parent`. The // `disabledStates` map is a plain coordination side-channel populated by // child wrappers during setup; same convention React uses. const disabledStates = new Map(); let uncontrolledStash: readonly string[] = untrack(value); const [status, setStatus] = createSignal<"on" | "off" | "mixed">("mixed", { name: "checkbox-group.parentStatus", ownedWrite: true, }); const parentChecked = createMemo( () => { const v = value(); const all = allValues() ?? EMPTY; return all.length > 0 && v.length === all.length; }, { name: "checkbox-group.parent.checked" }, ); const parentIndeterminate = createMemo( () => { const v = value(); const all = allValues() ?? EMPTY; return v.length > 0 && v.length !== all.length; }, { name: "checkbox-group.parent.indeterminate" }, ); const handleParentFlip = createStableCallback< (_next: boolean, event?: Event, reason?: CheckboxGroupChangeReason) => void >(() => (_next, event, reason) => { const all = allValues() ?? EMPTY; const values = value(); const stash = uncontrolledStash; // "none": disabled children that happen to be currently in the value // array stay on (mirrors React's `parentContext` math). const none = all.filter((v) => disabledStates.get(v) && stash.includes(v)); const allEnabled = all.filter( (v) => !disabledStates.get(v) || (disabledStates.get(v) && stash.includes(v)), ); const allOnOrOff = stash.length === allEnabled.length || stash.length === 0; if (allOnOrOff) { if (values.length === allEnabled.length) { setValue(none, event, reason); } else { setValue(allEnabled, event, reason); } return; } const phase = status(); if (phase === "mixed") { setValue(allEnabled, event, reason); setStatus("on"); } else if (phase === "on") { setValue(none, event, reason); setStatus("off"); } else { setValue(stash.slice(), event, reason); setStatus("mixed"); } }); const parent: CheckboxGroupParentApi = { checked: parentChecked, indeterminate: parentIndeterminate, disabledStates, getParentProps: () => ({ checked: parentChecked(), indeterminate: parentIndeterminate(), onCheckedChange: handleParentFlip, }), }; // --- Slot ------------------------------------------------------------ const groupInternal = { role: "group" as const, get "data-disabled"() { return disabled() ? "" : undefined; }, get "data-readonly"() { return readOnly() ? "" : undefined; }, get "data-required"() { return required() ? "" : undefined; }, }; const group = (userProps?: U) => mergeRootProps(groupInternal, userProps) as CheckboxGroupSlotProps & (U extends object ? U : object); // --- Legacy-context bridge ------------------------------------------ // `` reads `CheckboxGroupContext` and calls // `setValue(next, details)` from its onClick. Stash the most recent // pre-array on every child-driven write so the parent flip-all math // can restore "mixed" correctly; same trick the legacy hook uses. const bridgeSetValue = createStableCallback< (next: string[], details: CheckboxGroupChangeEventDetails) => void >(() => (next, details) => { uncontrolledStash = next; // Reset the parent cycle to "mixed" on a child-driven change. Matches // the legacy `useCheckboxGroupParent.getChildProps` behaviour. setStatus("mixed"); setValue(next, details.event, details.reason); // Cancel propagation: the legacy bridge also honours // `details.cancel()`. Consumers calling `details.cancel()` should see // no internal write — `setValue` already vetoes via `defaultPrevented`, // but the details API uses its own `isCanceled` flag, so honour both. if (details.isCanceled) { return; } }); const legacyContext: CheckboxGroupContextValue = { value, defaultValue: () => readOption(options.defaultValue), allValues, setValue: (next, details) => { if (details.isCanceled) return; bridgeSetValue(next, details); }, parent: { id: "", checked: parentChecked, indeterminate: parentIndeterminate, disabledStates, getParentProps: () => ({ id: "", checked: parentChecked(), indeterminate: parentIndeterminate(), "aria-controls": "", onCheckedChange: (_next, details) => { if (details.isCanceled) return; handleParentFlip(_next, details.event, details.reason); }, }), getChildProps: (childValue: string) => ({ checked: value().includes(childValue), onCheckedChange: (nextChecked, details) => { if (details.isCanceled) return; const current = value(); const nextValue = current.slice(); if (nextChecked) { if (!nextValue.includes(childValue)) nextValue.push(childValue); } else { const idx = nextValue.indexOf(childValue); if (idx >= 0) nextValue.splice(idx, 1); } uncontrolledStash = nextValue; setStatus("mixed"); setValue(nextValue, details.event, details.reason); }, }), }, disabled, present: true, }; const api: CheckboxGroupApi = { value, allValues, disabled, readOnly, required, name, setValue, parent, group, Provider: (props) => ( {props.children} ), }; (api as CheckboxGroupApi & Snapshottable)[SNAPSHOT_KEYS] = CHECKBOX_GROUP_SNAPSHOT_KEYS; return api; } const CHECKBOX_GROUP_SNAPSHOT_KEYS = snapshotKeys( "value", "allValues", "disabled", "readOnly", "required", "name", );