'use client';
import * as React from 'react';
import { useStableCallback } from '../hooks/useStableCallback';
import type { ZestChangeEventDetails } from '../utils/createChangeEventDetails';
import type { ZestEventReasons } from '../utils/reasons';
const EMPTY_ARRAY: never[] = [];
/**
* Derives the parent checkbox's checked/indeterminate state from its children and
* implements the three-way parent toggle.
*
* Upstream's `id`/`aria-controls` wiring is omitted: it exists to point the parent
* `` at the child input ids, which have no React Native equivalent.
*/
export function useCheckboxGroupParent(
params: UseCheckboxGroupParentParameters,
): UseCheckboxGroupParentReturnValue {
const {
allValues = EMPTY_ARRAY as string[],
value = EMPTY_ARRAY as string[],
onValueChange: onValueChangeProp,
} = params;
const uncontrolledStateRef = React.useRef(value);
const disabledStatesRef = React.useRef(new Map());
const [status, setStatus] = React.useState<'on' | 'off' | 'mixed'>('mixed');
const checked = value.length === allValues.length;
const indeterminate = value.length !== allValues.length && value.length > 0;
const onValueChange = useStableCallback(onValueChangeProp);
const getParentProps: UseCheckboxGroupParentReturnValue['getParentProps'] = React.useCallback(
() => ({
indeterminate,
checked,
onCheckedChange(_, eventDetails) {
const uncontrolledState = uncontrolledStateRef.current;
// Both filters below ask "is this value checked?" once per child, so the
// membership test is built once instead of rescanning the array each time.
const checkedValues = new Set(uncontrolledState);
// None except the disabled ones that are checked, which can't be changed.
const none = allValues.filter(
(v) => disabledStatesRef.current.get(v) && checkedValues.has(v),
);
// "All" that are valid:
// - any that aren't disabled
// - disabled ones that are checked
const all = allValues.filter(
(v) => !disabledStatesRef.current.get(v) || checkedValues.has(v),
);
const allOnOrOff = uncontrolledState.length === all.length || uncontrolledState.length === 0;
if (allOnOrOff) {
if (value.length === all.length) {
onValueChange(none, eventDetails);
} else {
onValueChange(all, eventDetails);
}
return;
}
let nextStatus: 'on' | 'off' | 'mixed' = 'mixed';
let nextValue = uncontrolledState;
if (status === 'mixed') {
nextStatus = 'on';
nextValue = all;
} else if (status === 'on') {
nextStatus = 'off';
nextValue = none;
}
onValueChange(nextValue, eventDetails);
if (!eventDetails.isCanceled) {
setStatus(nextStatus);
}
},
}),
[allValues, checked, indeterminate, onValueChange, status, value.length],
);
const getChildProps: UseCheckboxGroupParentReturnValue['getChildProps'] = React.useCallback(
(childValue: string) => ({
checked: value.includes(childValue),
onCheckedChange(nextChecked, eventDetails) {
const newValue = value.slice();
if (nextChecked) {
newValue.push(childValue);
} else {
// Guard the index: `splice(-1, 1)` drops the *last* item, so an
// unchecked-but-absent child would silently remove an unrelated one.
const index = newValue.indexOf(childValue);
if (index !== -1) {
newValue.splice(index, 1);
}
}
onValueChange(newValue, eventDetails);
if (!eventDetails.isCanceled) {
uncontrolledStateRef.current = newValue;
setStatus('mixed');
}
},
}),
[onValueChange, value],
);
return React.useMemo(
() => ({
getParentProps,
getChildProps,
disabledStatesRef,
}),
[getParentProps, getChildProps],
);
}
export interface UseCheckboxGroupParentParameters {
allValues?: string[] | undefined;
value?: string[] | undefined;
onValueChange?:
| ((value: string[], eventDetails: ZestChangeEventDetails) => void)
| undefined;
}
export interface UseCheckboxGroupParentReturnValue {
disabledStatesRef: React.RefObject