import { type Accessor, createMemo, createSignal } from "solid-js"; import { useBaseUiId } from "../internals/base-ui-id"; import type { BaseUIChangeEventDetails, BaseUIEventReasons, } from "../internals/change-event-details"; import { createStableCallback } from "../internals/stable-callback"; type ParentChangeEventDetails = BaseUIChangeEventDetails< BaseUIEventReasons["none"] >; export interface UseCheckboxGroupParentParameters { /** All child values that participate in the parent-indicator math. */ allValues: Accessor; /** Current group value (array of ticked child values). */ value: Accessor; /** Group setter — invoked when the parent checkbox flips. */ onValueChange: (next: string[], details: ParentChangeEventDetails) => void; } /** * Props the parent-checkbox consumer splats on ``. The * Solid port accepts them as a plain props bag (no accessors) because they're * consumed inside the checkbox-root's reactive scope — reads re-track on every * call. */ export interface UseCheckboxGroupParentParentProps { id: string; indeterminate: boolean; checked: boolean; "aria-controls": string; onCheckedChange: ( checked: boolean, details: ParentChangeEventDetails, ) => void; } /** * Props the per-child consumer splats on each non-parent ``. */ export interface UseCheckboxGroupParentChildProps { checked: boolean; onCheckedChange: ( checked: boolean, details: ParentChangeEventDetails, ) => void; } export interface UseCheckboxGroupParentReturnValue { /** Stable id for the parent checkbox (used by `aria-controls`). */ id: string; /** Whether the parent is currently indeterminate (mixed). */ indeterminate: Accessor; /** Whether the parent is fully checked (`value.length === allValues.length`). */ checked: Accessor; /** * Plain mutable map of child value → disabled state. Populated by children * during setup so the parent's `onCheckedChange` math can skip disabled * entries (mirrors React's `disabledStatesRef`). */ disabledStates: Map; /** Props factory for the parent checkbox. Call inside a reactive scope. */ getParentProps: () => UseCheckboxGroupParentParentProps; /** Props factory for a child checkbox keyed by its `value`. */ getChildProps: (childValue: string) => UseCheckboxGroupParentChildProps; } const EMPTY: readonly string[] = Object.freeze([]); /** * "Parent checkbox" helper. Matches Base UI React's `useCheckboxGroupParent`. * * The parent checkbox reflects the aggregate of its children: * * - all children checked → parent is checked * - no children checked → parent is unchecked * - mixed → parent is indeterminate * * Clicking the parent cycles through all-on / all-off (with an uncontrolled * stash so the user's mixed state can be "remembered" when cycling back). * Clicking a child flips just that child and resets the parent cycle back to * `"mixed"`. * * Solid 2 adaptations: * * - `id` is generated once via `useBaseUiId`. Mirrors React's single-render * id and stays stable for the component's lifetime. * - `indeterminate` / `checked` are derived via `createMemo` over the group * value + `allValues` — no manual recomputation, no stale closures. * - `onValueChange` is wrapped in `createStableCallback` so the child/parent * prop factories can be captured into closures without re-wiring. * - `disabledStates` is a plain `Map` (not a store / signal). Children push * into it during setup — it's a coordination side-channel, not reactive * state; the React port does the same via `disabledStatesRef`. * - `status` tracks the cycle phase (`"on" | "off" | "mixed"`). Declared * with `ownedWrite: true` because the parent's `onCheckedChange` runs in * owned scope (it's invoked from the checkbox-root's event handler chain * which is itself called from inside a reactive merge proxy read — Solid * doesn't mark event callbacks as unowned by default). * - `uncontrolledState` is a plain `let` binding (not a signal). It's a * sticky last-seen "mixed" snapshot used for the cycle-back-to-mixed * branch — no reactivity needed. */ export function useCheckboxGroupParent( params: UseCheckboxGroupParentParameters, ): UseCheckboxGroupParentReturnValue { const id = useBaseUiId(); const disabledStates = new Map(); // Snapshot of the value at the time of the last child-driven change. // The parent's cycle uses this when returning to the "mixed" phase so the // user's custom mix is restored instead of losing it to the on/off cycle. let uncontrolledState: readonly string[] = params.value() ?? EMPTY; const [status, setStatus] = createSignal<"on" | "off" | "mixed">("mixed", { ownedWrite: true, }); const onValueChange = createStableCallback< (next: string[], details: ParentChangeEventDetails) => void >(() => params.onValueChange); const checked = createMemo((): boolean => { const values = params.value() ?? EMPTY; const all = params.allValues() ?? EMPTY; return values.length === all.length && all.length > 0; }); const indeterminate = createMemo((): boolean => { const values = params.value() ?? EMPTY; const all = params.allValues() ?? EMPTY; return values.length > 0 && values.length !== all.length; }); function getParentProps(): UseCheckboxGroupParentParentProps { const values = params.value() ?? EMPTY; const all = params.allValues() ?? EMPTY; return { id, indeterminate: indeterminate(), checked: checked(), // TODO: custom `id` on child checkboxes breaks this — matches the // React port's known limitation (base-ui#2691). "aria-controls": all.map((v) => `${id}-${v}`).join(" "), onCheckedChange(_next, details) { const currentUncontrolled = uncontrolledState; // None: disabled children that happen to be checked stay on. const none = all.filter( (v) => disabledStates.get(v) && currentUncontrolled.includes(v), ); // All: enabled children + disabled-but-checked children. const allEnabled = all.filter( (v) => !disabledStates.get(v) || (disabledStates.get(v) && currentUncontrolled.includes(v)), ); const allOnOrOff = currentUncontrolled.length === allEnabled.length || currentUncontrolled.length === 0; if (allOnOrOff) { if (values.length === allEnabled.length) { onValueChange(none, details); } else { onValueChange(allEnabled, details); } return; } const phase = status(); if (phase === "mixed") { onValueChange(allEnabled, details); setStatus("on"); } else if (phase === "on") { onValueChange(none, details); setStatus("off"); } else { // "off" → restore the remembered mix. onValueChange([...currentUncontrolled], details); setStatus("mixed"); } }, }; } function getChildProps(childValue: string): UseCheckboxGroupParentChildProps { const values = params.value() ?? EMPTY; return { checked: values.includes(childValue), onCheckedChange(nextChecked, details) { const nextValue = values.slice(); if (nextChecked) { nextValue.push(childValue); } else { const idx = nextValue.indexOf(childValue); if (idx >= 0) nextValue.splice(idx, 1); } uncontrolledState = nextValue; onValueChange(nextValue, details); setStatus("mixed"); }, }; } return { id, indeterminate, checked, disabledStates, getParentProps, getChildProps, }; }