import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, omit } from "solid-js"; import { useBaseUiId } from "../internals/base-ui-id"; import { type BaseUIEventReasons, createChangeEventDetails, REASONS, } from "../internals/change-event-details"; import { composeRefs, userRef } from "../internals/compose-refs"; import { createControlledState } from "../internals/create-controlled-state"; import type { FieldRootState } from "../internals/field-constants"; import { useFieldRootContext } from "../internals/field-context"; import { fieldStateAttrs } from "../internals/field-state-attrs"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { mergeRootProps } from "../internals/merge-root-props"; import { resolveClassStyle } from "../internals/resolve-class-style"; import { areArraysEqual } from "../internals/slider-utils/are-arrays-equal.ts"; import type { BaseUIComponentProps } from "../internals/types"; import { useFieldControlValidation } from "../internals/use-field-control-validation/use-field-control-validation.ts"; import { useValueChanged } from "../internals/use-value-changed.ts"; import type { CheckboxGroupChangeEventDetails } from "./checkbox-group-context.ts"; import { createCheckboxGroup } from "./create-checkbox-group.tsx"; export type CheckboxGroupChangeEventReason = BaseUIEventReasons["none"]; export type { CheckboxGroupChangeEventDetails }; /** * State projected to `data-*` attributes. Extends the field-root state โ€” * forms/fields drive the `data-*` validity surface. */ export interface CheckboxGroupState extends FieldRootState { /** Whether the group (and all its children) are disabled. */ disabled: boolean; } export interface CheckboxGroupProps extends BaseUIComponentProps<"div", CheckboxGroupState> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; /** * Controlled value array of the ticked child checkboxes. When provided * (resolved value not `undefined`), the consumer owns the state โ€” pair * with {@link onValueChange}. When omitted, the group manages its own * value (seeded from {@link defaultValue}). An empty array IS a * controlled value. */ value?: MaybeAccessor; /** * Initial value array when uncontrolled. Read once at mount. * @default [] */ defaultValue?: MaybeAccessor; /** Fires when the set of ticked values changes. */ onValueChange?: | ((value: string[], details: CheckboxGroupChangeEventDetails) => void) | undefined; /** Every value a child can contribute (parent-checkbox pattern). */ allValues?: MaybeAccessor; /** Whether the group should ignore user interaction. @default false */ disabled?: MaybeAccessor; } const EMPTY: readonly string[] = Object.freeze([]); /** * Provides a shared value array to a series of `` elements. * Renders a `
` (or `as` override). * * ยง1 wave 2: replaces `renderElement` with `dynamic(() => props.as ?? "div")`. * Field-validity attrs (`data-valid`/`data-invalid`/`data-touched`/`data-dirty`/ * `data-filled`/`data-focused`) flow via `fieldStateAttrs(state)`; the * `data-disabled` presence attr is emitted via an inline getter; aria-invalid * + aria-describedby come from `validation.getValidationProps()`. */ export function CheckboxGroup(props: CheckboxGroupProps): JSX.Element { const field = useFieldRootContext(); const id = useBaseUiId(typeof props.id === "string" ? props.id : undefined); // Controlled / uncontrolled value array (Base UI `value` / `defaultValue` // contract, mirroring React's `useControlled` in `CheckboxGroup`): the // `value` prop wins while its resolved value is defined (an empty array // IS controlled); otherwise an internal signal (seeded once from // `defaultValue`, else `[]`) backs the state. const [resolvedValue, setUncontrolledValue] = createControlledState< readonly string[] >({ controlled: () => readOption(props.value), default: () => readOption(props.defaultValue), fallback: EMPTY, name: "", state: "value", }); // Base UI exposes `defaultValue` on the group context only while // uncontrolled (`externalValue === undefined ? defaultValueProp ?? [] : // undefined`) โ€” child checkboxes derive their own seed from it. const contextDefaultValue = (): readonly string[] | undefined => readOption(props.value) === undefined ? (readOption(props.defaultValue) ?? EMPTY) : undefined; const c = createCheckboxGroup({ value: resolvedValue, defaultValue: contextDefaultValue, allValues: () => readOption(props.allValues), disabled: (): boolean => field.disabled() || readOption(props.disabled) === true, onValueChange: (next, event) => { const details = createChangeEventDetails( REASONS.none, event, ) as CheckboxGroupChangeEventDetails; // Base UI change flow: fire onValueChange first, honor // `details.cancel()`, then write the uncontrolled state (a no-op // for reads while the `value` prop is controlled). props.onValueChange?.(next, details); if (details.isCanceled) { event.preventDefault(); return; } setUncontrolledValue(next); }, }); const value = c.value; const controlRef: { current: HTMLElement | null } = { current: null }; const validation = useFieldControlValidation({ value: () => value() ?? EMPTY, id: () => id, controlRef, getValue: () => value() ?? EMPTY, enabled: () => !!field.name(), }); useValueChanged( () => value() ?? EMPTY, () => { const current = value() ?? EMPTY; const initial = field.validityData().initialValue; const initialArr: readonly string[] = Array.isArray(initial) ? (initial as readonly string[]) : EMPTY; field.setFilled(current.length > 0); field.setDirty(!areArraysEqual(current, initialArr)); if (field.shouldValidateOnChange()) { void validation.commit(current); } else { void validation.commit(current, true); } }, ); const state = createMemo( (): CheckboxGroupState => ({ ...field.state(), disabled: c.disabled(), }), ); const userStyling = resolveClassStyle(props, () => state()); const userProps = omit( props, "class", "style", "attrs", "as", "allValues", "children", "defaultValue", "disabled", "id", "onValueChange", "value", ); // Internal slot bag: id, role, data-disabled, plus aria-invalid / // aria-describedby from field-validation. Field-state validity attrs // flow through `fieldStateAttrs(state)`. const internalSlot = { id, role: "group" as const, get "data-disabled"(): "" | undefined { return c.disabled() ? "" : undefined; }, }; const validationBag = validation.getValidationProps() ?? {}; const styledUserProps = mergeRootProps(userStyling, userProps); let merged: Record = mergeRootProps( internalSlot, validationBag, ); merged = mergeRootProps(merged, fieldStateAttrs(state)); merged = mergeRootProps(merged, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ( { controlRef.current = el; }, userRef(props))} > {props.children} ); }