Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
53167ab
wip
aresnik11 Aug 11, 2025
fff7888
Merge branch 'main' into ajr-nested-checkboxes
aresnik11 Sep 4, 2025
cef1cbf
working
aresnik11 Sep 5, 2025
3a53551
update types
aresnik11 Sep 8, 2025
b1d05a8
add aria-checked
aresnik11 Sep 9, 2025
05c00fb
update stories
aresnik11 Sep 9, 2025
ec5e503
fix connectednestedcheckbox
aresnik11 Sep 15, 2025
9562d55
dont use children as prop name
aresnik11 Sep 15, 2025
6dbaf28
Merge branch 'main' into ajr-nested-checkboxes
aresnik11 Sep 15, 2025
f8dbe30
add back in nested examples
aresnik11 Sep 15, 2025
309c8a3
fix errors
aresnik11 Sep 15, 2025
7d5ddfa
PR feedback
aresnik11 Sep 19, 2025
8f9a058
DRY up code
aresnik11 Sep 19, 2025
a34b0b5
first stab at tests
aresnik11 Sep 19, 2025
641aeb2
it works in gridform
aresnik11 Sep 23, 2025
86152dc
clean up logs and comments
aresnik11 Sep 23, 2025
5108c8c
fix defaultValue type issue
aresnik11 Sep 24, 2025
f67e05d
types refactor
aresnik11 Sep 24, 2025
fea9287
gridform default value
aresnik11 Sep 24, 2025
e2e3cc6
connectedform default value
aresnik11 Sep 24, 2025
8c65ca0
update connectedform
aresnik11 Sep 25, 2025
d6b147e
add passing tests
aresnik11 Sep 25, 2025
5694439
clean up tests
aresnik11 Sep 26, 2025
a562cc1
Merge branch 'main' into ajr-nested-checkboxes
aresnik11 Oct 8, 2025
82d2598
PR feedback
aresnik11 Oct 14, 2025
6208a48
Merge branch 'ajr-nested-checkboxes' of github.com:Codecademy/gamut i…
aresnik11 Oct 14, 2025
9a809ff
Merge branch 'main' into ajr-nested-checkboxes
aresnik11 Oct 14, 2025
b55a426
improvements
aresnik11 Oct 14, 2025
05f8fab
Merge branch 'ajr-nested-checkboxes' of github.com:Codecademy/gamut i…
aresnik11 Oct 15, 2025
18bc8fd
update gridform spacing
aresnik11 Oct 15, 2025
4a31dec
update spacing logic
aresnik11 Oct 16, 2025
90cb56a
Merge branch 'main' into ajr-nested-checkboxes
aresnik11 Oct 16, 2025
00e475b
Merge branch 'main' into ajr-nested-checkboxes
aresnik11 Oct 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ exports[`Gamut Exported Keys 1`] = `
"ConnectedForm",
"ConnectedFormGroup",
"ConnectedInput",
"ConnectedNestedCheckboxes",
"ConnectedRadio",
"ConnectedRadioGroup",
"ConnectedRadioGroupInput",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { Controller } from 'react-hook-form';

import { Box, Checkbox, CheckboxLabelUnion } from '../..';
import { useField } from '..';
import {
ConnectedNestedCheckboxesProps,
MinimalCheckboxProps,
NestedConnectedCheckboxOption,
} from './types';

type FlatCheckboxState = MinimalCheckboxProps &
CheckboxLabelUnion & {
level: number;
parentValue?: string;
options: string[];
};

export const ConnectedNestedCheckboxes: React.FC<
ConnectedNestedCheckboxesProps
> = ({ name, options, disabled, onUpdate, spacing }) => {
const { isDisabled, control, validation, isRequired } = useField({
name,
disabled,
});

// Flatten the nested structure for easier state management
const flattenOptions = useCallback(
(
opts: NestedConnectedCheckboxOption[],
level = 0,
parentValue?: string
) => {
const result: FlatCheckboxState[] = [];

opts.forEach((option) => {
// Ensure value is a string
const optionValue = String(option.value);
const options = option.options
? option.options.map((child) => String(child.value))
: [];

result.push({
...option,
spacing,
value: optionValue,
level,
parentValue,
options,
});

if (option.options) {
result.push(
...flattenOptions(option.options, level + 1, optionValue)
);
}
});

return result;
},
[spacing]
);

const flatOptions = useMemo(
() => flattenOptions(options),
[options, flattenOptions]
);

// Helper function to get all descendants of a given option
const getAllDescendants = useCallback(
(parentValue: string) => {
const descendants: string[] = [];

const collectDescendants = (currentParentValue: string) => {
flatOptions.forEach((option) => {
if (option.parentValue === currentParentValue) {
descendants.push(String(option.value));
// Recursively collect descendants of this option
collectDescendants(String(option.value));
}
});
};

collectDescendants(parentValue);
return descendants;
},
[flatOptions]
);

// Calculate checkbox states based on selected values
const calculateStates = useCallback(
(selectedValues: string[]) => {
const states = new Map<string, FlatCheckboxState>();

// Initialize all states
flatOptions.forEach((option) => {
states.set(String(option.value), {
...option,
checked: selectedValues.includes(String(option.value)),
});
});

// Calculate parent states based on all descendants (infinite levels)
flatOptions.forEach((option) => {
if (option.options.length > 0) {
const allDescendants = getAllDescendants(String(option.value));
const checkedDescendants = allDescendants.filter((descendantValue) =>
selectedValues.includes(descendantValue)
);

const state = states.get(String(option.value))!;
if (checkedDescendants.length === 0) {
state.checked = false;
state.indeterminate = false;
} else if (checkedDescendants.length === allDescendants.length) {
state.checked = true;
} else {
state.checked = false;
state.indeterminate = true;
}
}
});

return states;
},
[flatOptions, getAllDescendants]
);

const handleCheckboxChange = useCallback(
(
currentValue: string,
isChecked: boolean,
selectedValues: string[],
onChange: (values: string[]) => void
) => {
const option = flatOptions.find((opt) => opt.value === currentValue);
if (!option) return;

let newSelectedValues = [...selectedValues];

if (option.options.length > 0) {
// Parent checkbox - toggle all descendants (infinite levels)
const allDescendants = getAllDescendants(currentValue);

if (isChecked) {
// Add all descendants that aren't already selected
allDescendants.forEach((descendantValue) => {
if (!newSelectedValues.includes(descendantValue)) {
newSelectedValues.push(descendantValue);
}
});
} else {
// Remove all descendants
newSelectedValues = newSelectedValues.filter(
(value) => !allDescendants.includes(value)
);
}
}

// Handle the current checkbox itself (for leaf nodes or when toggling individual items)
if (isChecked) {
if (!newSelectedValues.includes(currentValue)) {
newSelectedValues.push(currentValue);
}
} else {
newSelectedValues = newSelectedValues.filter(
(value) => value !== currentValue
);
}

onChange(newSelectedValues);
onUpdate?.(newSelectedValues);
},
[flatOptions, onUpdate, getAllDescendants]
);

const renderCheckbox = useCallback(
(
option: FlatCheckboxState,
selectedValues: string[],
onChange: (values: string[]) => void,
onBlur: () => void,
ref: React.RefCallback<HTMLInputElement>
) => {
const states = calculateStates(selectedValues);
const state = states.get(String(option.value))!;
const checkboxId = `${name}-${option.value}`;

let checkedProps = {};
if (state.checked) {
checkedProps = {
checked: true,
'aria-checked': true,
};
} else if (state.indeterminate) {
checkedProps = {
indeterminate: true,
checked: false,
'aria-checked': 'mixed',
};
} else {
checkedProps = {
checked: false,
'aria-checked': false,
};
}

return (
<Box
as="li"
key={String(option.value)}
listStyle="none"
ml={(option.level * 24) as any}
>
<Checkbox
aria-label={
state['aria-label'] === undefined
? typeof state.label === 'string'
? state.label
: 'checkbox'
: state['aria-label']
}
aria-required={isRequired}
disabled={isDisabled || state.disabled}
htmlFor={checkboxId}
id={checkboxId}
label={state.label}
multiline={state.multiline}
name={`${name}-${option.value}`}
spacing={state.spacing}
onBlur={onBlur}
onChange={(event) => {
handleCheckboxChange(
String(option.value),
event.target.checked,
selectedValues,
onChange
);
}}
{...checkedProps}
{...ref}
/>
</Box>
);
},
[calculateStates, name, isRequired, isDisabled, handleCheckboxChange]
);

return (
<Controller
control={control}
defaultValue={[]}
name={name}
render={({ field: { value, onChange, onBlur, ref } }) => (
<Box as="ul" m={0} p={0}>
{flatOptions.map((option) =>
renderCheckbox(option, value || [], onChange, onBlur, ref)
)}
</Box>
)}
rules={validation}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './ConnectedCheckbox';
export * from './ConnectedInput';
export * from './ConnectedNestedCheckboxes';
export * from './ConnectedRadio';
export * from './ConnectedRadioGroup';
export * from './ConnectedRadioGroupInput';
Expand Down
31 changes: 21 additions & 10 deletions packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,17 @@ import {
export interface BaseConnectedFieldProps {
onUpdate?: (value: boolean) => void;
}

export interface ConnectedFieldProps extends BaseConnectedFieldProps {
name: string;
}
export interface BaseConnectedCheckboxProps

export interface MinimalCheckboxProps
extends Omit<
CheckboxProps,
| 'defaultValue'
| 'name'
| 'htmlFor'
| 'validation'
| 'label'
| 'aria-label'
>,
CheckboxProps,
'defaultValue' | 'name' | 'htmlFor' | 'validation' | 'label' | 'aria-label'
> {}
export interface BaseConnectedCheckboxProps
extends MinimalCheckboxProps,
ConnectedFieldProps {}

export type ConnectedCheckboxProps = BaseConnectedCheckboxProps &
Expand Down Expand Up @@ -70,3 +67,17 @@ export interface ConnectedSelectProps
export interface ConnectedTextAreaProps
extends Omit<TextAreaProps, 'defaultValue' | 'name' | 'validation'>,
ConnectedFieldProps {}

export type NestedConnectedCheckboxOption = Omit<
MinimalCheckboxProps,
'spacing'
> &
CheckboxLabelUnion & {
options?: NestedConnectedCheckboxOption[];
};

export interface ConnectedNestedCheckboxesProps
extends Pick<BaseConnectedCheckboxProps, 'name' | 'disabled' | 'spacing'> {
options: NestedConnectedCheckboxOption[];
onUpdate?: (values: string[]) => void;
}
2 changes: 2 additions & 0 deletions packages/gamut/src/ConnectedForm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FieldValues, FormState } from 'react-hook-form';
import {
ConnectedCheckbox,
ConnectedInput,
ConnectedNestedCheckboxes,
ConnectedRadioGroupInput,
ConnectedSelect,
ConnectedTextArea,
Expand All @@ -12,6 +13,7 @@ import { BaseConnectedFieldProps } from './ConnectedInputs/types';
export type ConnectedField =
| typeof ConnectedCheckbox
| typeof ConnectedInput
| typeof ConnectedNestedCheckboxes
| typeof ConnectedRadioGroupInput
| typeof ConnectedSelect
| typeof ConnectedTextArea;
Expand Down
Loading
Loading