From 53167ab30b0659727c3de9fbb8d31ace38aaedb2 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 11 Aug 2025 14:03:32 -0400 Subject: [PATCH 01/44] wip --- .../ConnectedNestedCheckboxes.tsx | 264 ++++++++++++++++++ .../ConnectedForm/ConnectedInputs/index.ts | 1 + .../ConnectedForm/ConnectedInputs/types.tsx | 50 ++++ packages/gamut/src/ConnectedForm/types.ts | 2 + 4 files changed, 317 insertions(+) create mode 100644 packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx new file mode 100644 index 00000000000..837c741db26 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -0,0 +1,264 @@ +import * as React from 'react'; +import { useCallback, useMemo } from 'react'; +import { Controller } from 'react-hook-form'; + +import { Checkbox } from '../..'; +import { useField } from '..'; +import { ConnectedNestedCheckboxesProps, NestedCheckboxOption } from './types'; + +/** + * ConnectedNestedCheckboxes - A form component that provides nested checkbox functionality + * with parent-child relationships and indeterminate states. + * + * @example + * ```tsx + * const options = [ + * { + * value: 'frontend', + * label: 'Frontend Technologies', + * children: [ + * { value: 'react', label: 'React' }, + * { value: 'vue', label: 'Vue.js' }, + * { value: 'angular', label: 'Angular' } + * ] + * }, + * { + * value: 'backend', + * label: 'Backend Technologies', + * children: [ + * { value: 'node', label: 'Node.js' }, + * { value: 'python', label: 'Python' }, + * { value: 'java', label: 'Java' } + * ] + * } + * ]; + * + * console.log(data.technologies)}> + * console.log('Selected:', selectedValues)} + * /> + * Submit + * + * ``` + * + * Features: + * - Hierarchical checkbox structure with unlimited nesting levels + * - Parent checkboxes show indeterminate state when some children are selected + * - Clicking a parent checkbox toggles all its children + * - Individual child checkboxes can be toggled independently + * - Returns array of selected leaf values (only actual selectable items, not parent categories) + * - Integrates with react-hook-form validation and form state + * - Supports disabled states at both parent and child levels + * - Proper accessibility attributes and keyboard navigation + */ + +interface FlatCheckboxState { + value: string; + checked: boolean; + indeterminate: boolean; + disabled: boolean; + label: React.ReactNode; + level: number; + parentValue?: string; + children: string[]; + checkboxProps?: any; +} + +export const ConnectedNestedCheckboxes: React.FC< + ConnectedNestedCheckboxesProps +> = ({ name, options, disabled, className, onUpdate }) => { + const { isDisabled, control, validation, isRequired } = useField({ + name, + disabled, + }); + + // Flatten the nested structure for easier state management + const flattenOptions = useCallback( + ( + opts: NestedCheckboxOption[], + level = 0, + parentValue?: string + ): FlatCheckboxState[] => { + const result: FlatCheckboxState[] = []; + + opts.forEach((option) => { + const children = option.children + ? option.children.map((child) => child.value) + : []; + + result.push({ + value: option.value, + checked: false, + indeterminate: false, + disabled: option.disabled || false, + label: option.label, + level, + parentValue, + children, + checkboxProps: option.checkboxProps, + }); + + if (option.children) { + result.push( + ...flattenOptions(option.children, level + 1, option.value) + ); + } + }); + + return result; + }, + [] + ); + + const flatOptions = useMemo( + () => flattenOptions(options), + [options, flattenOptions] + ); + + // Calculate checkbox states based on selected values + const calculateStates = useCallback( + (selectedValues: string[]) => { + const states = new Map(); + + // Initialize all states + flatOptions.forEach((option) => { + states.set(option.value, { + ...option, + checked: selectedValues.includes(option.value), + indeterminate: false, + }); + }); + + // Calculate parent states based on children + flatOptions.forEach((option) => { + if (option.children.length > 0) { + const checkedChildren = option.children.filter((childValue) => + selectedValues.includes(childValue) + ); + + const state = states.get(option.value)!; + if (checkedChildren.length === 0) { + state.checked = false; + state.indeterminate = false; + } else if (checkedChildren.length === option.children.length) { + state.checked = true; + state.indeterminate = false; + } else { + state.checked = false; + state.indeterminate = true; + } + } + }); + + return states; + }, + [flatOptions] + ); + + 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.children.length > 0) { + // Parent checkbox - toggle all children + if (isChecked) { + // Add all children that aren't already selected + option.children.forEach((childValue) => { + if (!newSelectedValues.includes(childValue)) { + newSelectedValues.push(childValue); + } + }); + } else { + // Remove all children + newSelectedValues = newSelectedValues.filter( + (value) => !option.children.includes(value) + ); + } + } else { + // Child checkbox + if (isChecked) { + if (!newSelectedValues.includes(currentValue)) { + newSelectedValues.push(currentValue); + } + } else { + newSelectedValues = newSelectedValues.filter( + (value) => value !== currentValue + ); + } + } + + onChange(newSelectedValues); + onUpdate?.(newSelectedValues); + }, + [flatOptions, onUpdate] + ); + + const renderCheckbox = useCallback( + ( + option: FlatCheckboxState, + selectedValues: string[], + onChange: (values: string[]) => void + ) => { + const states = calculateStates(selectedValues); + const state = states.get(option.value)!; + const checkboxId = `${name}-${option.value}`; + + return ( +
+ { + handleCheckboxChange( + option.value, + event.target.checked, + selectedValues, + onChange + ); + }} + {...state.checkboxProps} + /> +
+ ); + }, + [calculateStates, name, isRequired, isDisabled, handleCheckboxChange] + ); + + return ( + ( +
+ {flatOptions.map((option) => + renderCheckbox(option, value || [], onChange) + )} +
+ )} + rules={validation} + /> + ); +}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/index.ts b/packages/gamut/src/ConnectedForm/ConnectedInputs/index.ts index e70600b912d..d60ebab0558 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/index.ts +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/index.ts @@ -1,5 +1,6 @@ export * from './ConnectedCheckbox'; export * from './ConnectedInput'; +export * from './ConnectedNestedCheckboxes'; export * from './ConnectedRadio'; export * from './ConnectedRadioGroup'; export * from './ConnectedRadioGroupInput'; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index 11e4dd0f35e..9182783a00e 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -70,3 +70,53 @@ export interface ConnectedSelectProps export interface ConnectedTextAreaProps extends Omit, ConnectedFieldProps {} + +export interface NestedCheckboxOption { + /** + * Unique identifier for this checkbox option + */ + value: string; + /** + * Display label for the checkbox + */ + label: ReactNode; + /** + * Whether this option should be disabled + */ + disabled?: boolean; + /** + * Child options that are nested under this option + */ + children?: NestedCheckboxOption[]; + /** + * Additional props to pass to the individual Checkbox component + */ + checkboxProps?: Omit< + CheckboxProps, + 'checked' | 'onChange' | 'name' | 'htmlFor' | 'label' | 'disabled' + >; +} + +export interface ConnectedNestedCheckboxesProps { + /** + * Field name for form registration + */ + name: string; + /** + * Hierarchical structure of checkbox options + */ + options: NestedCheckboxOption[]; + /** + * Whether all checkboxes should be disabled + */ + disabled?: boolean; + /** + * CSS class name for the container + */ + className?: string; + /** + * Callback fired when the selection changes + * @param selectedValues Array of selected option values + */ + onUpdate?: (selectedValues: string[]) => void; +} diff --git a/packages/gamut/src/ConnectedForm/types.ts b/packages/gamut/src/ConnectedForm/types.ts index e0ead4f2faf..324697f9848 100644 --- a/packages/gamut/src/ConnectedForm/types.ts +++ b/packages/gamut/src/ConnectedForm/types.ts @@ -3,6 +3,7 @@ import { FieldValues, FormState } from 'react-hook-form'; import { ConnectedCheckbox, ConnectedInput, + ConnectedNestedCheckboxes, ConnectedRadioGroupInput, ConnectedSelect, ConnectedTextArea, @@ -12,6 +13,7 @@ import { BaseConnectedFieldProps } from './ConnectedInputs/types'; export type ConnectedField = | typeof ConnectedCheckbox | typeof ConnectedInput + | typeof ConnectedNestedCheckboxes | typeof ConnectedRadioGroupInput | typeof ConnectedSelect | typeof ConnectedTextArea; From cef1cbffeb3afc8c999470953fd39d4b7bc442b9 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 5 Sep 2025 10:07:43 -0400 Subject: [PATCH 02/44] working --- .../ConnectedNestedCheckboxes.tsx | 102 +++++++++++------- .../ConnectedForm/ConnectedInputs/types.tsx | 47 ++------ .../ConnectedForm/ConnectedForm.stories.tsx | 32 ++++++ 3 files changed, 102 insertions(+), 79 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index 837c741db26..b6388200638 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useCallback, useMemo } from 'react'; import { Controller } from 'react-hook-form'; -import { Checkbox } from '../..'; +import { Box, Checkbox } from '../..'; import { useField } from '..'; import { ConnectedNestedCheckboxesProps, NestedCheckboxOption } from './types'; @@ -68,7 +68,7 @@ interface FlatCheckboxState { export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps -> = ({ name, options, disabled, className, onUpdate }) => { +> = ({ name, options, disabled, onUpdate }) => { const { isDisabled, control, validation, isRequired } = useField({ name, disabled, @@ -84,12 +84,14 @@ export const ConnectedNestedCheckboxes: React.FC< const result: FlatCheckboxState[] = []; opts.forEach((option) => { + // Ensure value is a string + const optionValue = String(option.value || ''); const children = option.children - ? option.children.map((child) => child.value) + ? option.children.map((child) => String(child.value || '')) : []; result.push({ - value: option.value, + value: optionValue, checked: false, indeterminate: false, disabled: option.disabled || false, @@ -97,12 +99,12 @@ export const ConnectedNestedCheckboxes: React.FC< level, parentValue, children, - checkboxProps: option.checkboxProps, + checkboxProps: {}, }); if (option.children) { result.push( - ...flattenOptions(option.children, level + 1, option.value) + ...flattenOptions(option.children, level + 1, optionValue) ); } }); @@ -117,6 +119,27 @@ export const ConnectedNestedCheckboxes: React.FC< [options, flattenOptions] ); + // Helper function to get all descendants of a given option + const getAllDescendants = useCallback( + (parentValue: string): string[] => { + const descendants: string[] = []; + + const collectDescendants = (currentParentValue: string) => { + flatOptions.forEach((option) => { + if (option.parentValue === currentParentValue) { + descendants.push(option.value); + // Recursively collect descendants of this option + collectDescendants(option.value); + } + }); + }; + + collectDescendants(parentValue); + return descendants; + }, + [flatOptions] + ); + // Calculate checkbox states based on selected values const calculateStates = useCallback( (selectedValues: string[]) => { @@ -131,18 +154,19 @@ export const ConnectedNestedCheckboxes: React.FC< }); }); - // Calculate parent states based on children + // Calculate parent states based on all descendants (infinite levels) flatOptions.forEach((option) => { if (option.children.length > 0) { - const checkedChildren = option.children.filter((childValue) => - selectedValues.includes(childValue) + const allDescendants = getAllDescendants(option.value); + const checkedDescendants = allDescendants.filter((descendantValue) => + selectedValues.includes(descendantValue) ); const state = states.get(option.value)!; - if (checkedChildren.length === 0) { + if (checkedDescendants.length === 0) { state.checked = false; state.indeterminate = false; - } else if (checkedChildren.length === option.children.length) { + } else if (checkedDescendants.length === allDescendants.length) { state.checked = true; state.indeterminate = false; } else { @@ -154,7 +178,7 @@ export const ConnectedNestedCheckboxes: React.FC< return states; }, - [flatOptions] + [flatOptions, getAllDescendants] ); const handleCheckboxChange = useCallback( @@ -170,37 +194,39 @@ export const ConnectedNestedCheckboxes: React.FC< let newSelectedValues = [...selectedValues]; if (option.children.length > 0) { - // Parent checkbox - toggle all children + // Parent checkbox - toggle all descendants (infinite levels) + const allDescendants = getAllDescendants(currentValue); + if (isChecked) { - // Add all children that aren't already selected - option.children.forEach((childValue) => { - if (!newSelectedValues.includes(childValue)) { - newSelectedValues.push(childValue); + // Add all descendants that aren't already selected + allDescendants.forEach((descendantValue) => { + if (!newSelectedValues.includes(descendantValue)) { + newSelectedValues.push(descendantValue); } }); } else { - // Remove all children + // Remove all descendants newSelectedValues = newSelectedValues.filter( - (value) => !option.children.includes(value) + (value) => !allDescendants.includes(value) ); } - } else { - // Child checkbox - if (isChecked) { - if (!newSelectedValues.includes(currentValue)) { - newSelectedValues.push(currentValue); - } - } else { - newSelectedValues = newSelectedValues.filter( - (value) => value !== currentValue - ); + } + + // 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] + [flatOptions, onUpdate, getAllDescendants] ); const renderCheckbox = useCallback( @@ -214,20 +240,14 @@ export const ConnectedNestedCheckboxes: React.FC< const checkboxId = `${name}-${option.value}`; return ( -
+ { @@ -240,7 +260,7 @@ export const ConnectedNestedCheckboxes: React.FC< }} {...state.checkboxProps} /> -
+ ); }, [calculateStates, name, isRequired, isDisabled, handleCheckboxChange] @@ -252,11 +272,11 @@ export const ConnectedNestedCheckboxes: React.FC< defaultValue={[]} name={name} render={({ field: { value, onChange } }) => ( -
+ {flatOptions.map((option) => renderCheckbox(option, value || [], onChange) )} -
+ )} rules={validation} /> diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index 9182783a00e..f6e4b82725f 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -14,6 +14,10 @@ export interface BaseConnectedFieldProps { onUpdate?: (value: boolean) => void; } +export interface BaseConnectedNestedCheckboxFieldProps { + onUpdate?: (values: string[]) => void; +} + export interface ConnectedFieldProps extends BaseConnectedFieldProps { name: string; } @@ -71,52 +75,19 @@ export interface ConnectedTextAreaProps extends Omit, ConnectedFieldProps {} -export interface NestedCheckboxOption { - /** - * Unique identifier for this checkbox option - */ - value: string; - /** - * Display label for the checkbox - */ - label: ReactNode; - /** - * Whether this option should be disabled - */ - disabled?: boolean; - /** - * Child options that are nested under this option - */ +export type NestedCheckboxOption = ConnectedCheckboxProps & { children?: NestedCheckboxOption[]; - /** - * Additional props to pass to the individual Checkbox component - */ - checkboxProps?: Omit< - CheckboxProps, - 'checked' | 'onChange' | 'name' | 'htmlFor' | 'label' | 'disabled' - >; -} +}; -export interface ConnectedNestedCheckboxesProps { - /** - * Field name for form registration - */ +export interface ConnectedNestedCheckboxesProps + extends BaseConnectedNestedCheckboxFieldProps { name: string; /** * Hierarchical structure of checkbox options */ options: NestedCheckboxOption[]; /** - * Whether all checkboxes should be disabled + * Disable all checkboxes */ disabled?: boolean; - /** - * CSS class name for the container - */ - className?: string; - /** - * Callback fired when the selection changes - * @param selectedValues Array of selected option values - */ - onUpdate?: (selectedValues: string[]) => void; } diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index 6634c022899..2d013d51ae2 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -4,6 +4,7 @@ import { ConnectedFormGroupProps, ConnectedFormProps, ConnectedInput, + ConnectedNestedCheckboxes, ConnectedRadioGroupInput, ConnectedSelect, ConnectedTextArea, @@ -238,6 +239,37 @@ const ConnectedFormPlayground: React.FC = ({ name="textAreaField" {...connectedFormGroup} /> + console.log('Selected:', selectedValues)} + /> ); }; From 3a53551f0209285526f2b4f51c80565baded6f00 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 8 Sep 2025 13:55:33 -0400 Subject: [PATCH 03/44] update types --- .../ConnectedNestedCheckboxes.tsx | 125 ++++++------------ .../ConnectedForm/ConnectedInputs/types.tsx | 16 +-- .../ConnectedForm/ConnectedForm.stories.tsx | 1 + 3 files changed, 44 insertions(+), 98 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index b6388200638..a4f52a24ba2 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -4,67 +4,17 @@ import { Controller } from 'react-hook-form'; import { Box, Checkbox } from '../..'; import { useField } from '..'; -import { ConnectedNestedCheckboxesProps, NestedCheckboxOption } from './types'; +import { + ConnectedCheckboxProps, + ConnectedNestedCheckboxesProps, + NestedCheckboxOption, +} from './types'; -/** - * ConnectedNestedCheckboxes - A form component that provides nested checkbox functionality - * with parent-child relationships and indeterminate states. - * - * @example - * ```tsx - * const options = [ - * { - * value: 'frontend', - * label: 'Frontend Technologies', - * children: [ - * { value: 'react', label: 'React' }, - * { value: 'vue', label: 'Vue.js' }, - * { value: 'angular', label: 'Angular' } - * ] - * }, - * { - * value: 'backend', - * label: 'Backend Technologies', - * children: [ - * { value: 'node', label: 'Node.js' }, - * { value: 'python', label: 'Python' }, - * { value: 'java', label: 'Java' } - * ] - * } - * ]; - * - * console.log(data.technologies)}> - * console.log('Selected:', selectedValues)} - * /> - * Submit - * - * ``` - * - * Features: - * - Hierarchical checkbox structure with unlimited nesting levels - * - Parent checkboxes show indeterminate state when some children are selected - * - Clicking a parent checkbox toggles all its children - * - Individual child checkboxes can be toggled independently - * - Returns array of selected leaf values (only actual selectable items, not parent categories) - * - Integrates with react-hook-form validation and form state - * - Supports disabled states at both parent and child levels - * - Proper accessibility attributes and keyboard navigation - */ - -interface FlatCheckboxState { - value: string; - checked: boolean; - indeterminate: boolean; - disabled: boolean; - label: React.ReactNode; +type FlatCheckboxState = ConnectedCheckboxProps & { level: number; parentValue?: string; children: string[]; - checkboxProps?: any; -} +}; export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps @@ -76,30 +26,22 @@ export const ConnectedNestedCheckboxes: React.FC< // Flatten the nested structure for easier state management const flattenOptions = useCallback( - ( - opts: NestedCheckboxOption[], - level = 0, - parentValue?: string - ): FlatCheckboxState[] => { + (opts: NestedCheckboxOption[], level = 0, parentValue?: string) => { const result: FlatCheckboxState[] = []; opts.forEach((option) => { // Ensure value is a string - const optionValue = String(option.value || ''); + const optionValue = String(option.value); const children = option.children - ? option.children.map((child) => String(child.value || '')) + ? option.children.map((child) => String(child.value)) : []; result.push({ + ...option, value: optionValue, - checked: false, - indeterminate: false, - disabled: option.disabled || false, - label: option.label, level, parentValue, children, - checkboxProps: {}, }); if (option.children) { @@ -121,15 +63,15 @@ export const ConnectedNestedCheckboxes: React.FC< // Helper function to get all descendants of a given option const getAllDescendants = useCallback( - (parentValue: string): string[] => { + (parentValue: string) => { const descendants: string[] = []; const collectDescendants = (currentParentValue: string) => { flatOptions.forEach((option) => { if (option.parentValue === currentParentValue) { - descendants.push(option.value); + descendants.push(String(option.value)); // Recursively collect descendants of this option - collectDescendants(option.value); + collectDescendants(String(option.value)); } }); }; @@ -147,28 +89,26 @@ export const ConnectedNestedCheckboxes: React.FC< // Initialize all states flatOptions.forEach((option) => { - states.set(option.value, { + states.set(String(option.value), { ...option, - checked: selectedValues.includes(option.value), - indeterminate: false, + checked: selectedValues.includes(String(option.value)), }); }); // Calculate parent states based on all descendants (infinite levels) flatOptions.forEach((option) => { if (option.children.length > 0) { - const allDescendants = getAllDescendants(option.value); + const allDescendants = getAllDescendants(String(option.value)); const checkedDescendants = allDescendants.filter((descendantValue) => selectedValues.includes(descendantValue) ); - const state = states.get(option.value)!; + 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; - state.indeterminate = false; } else { state.checked = false; state.indeterminate = true; @@ -236,29 +176,46 @@ export const ConnectedNestedCheckboxes: React.FC< onChange: (values: string[]) => void ) => { const states = calculateStates(selectedValues); - const state = states.get(option.value)!; + const state = states.get(String(option.value))!; const checkboxId = `${name}-${option.value}`; + let checkedProps = {}; + if (state.checked) { + checkedProps = { + checked: true, + }; + } else if (state.indeterminate) { + checkedProps = { + indeterminate: true, + checked: false, + }; + } + return ( - + { handleCheckboxChange( - option.value, + String(option.value), event.target.checked, selectedValues, onChange ); }} - {...state.checkboxProps} + {...checkedProps} /> ); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index f6e4b82725f..33fbfb044ce 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -13,11 +13,6 @@ import { export interface BaseConnectedFieldProps { onUpdate?: (value: boolean) => void; } - -export interface BaseConnectedNestedCheckboxFieldProps { - onUpdate?: (values: string[]) => void; -} - export interface ConnectedFieldProps extends BaseConnectedFieldProps { name: string; } @@ -80,14 +75,7 @@ export type NestedCheckboxOption = ConnectedCheckboxProps & { }; export interface ConnectedNestedCheckboxesProps - extends BaseConnectedNestedCheckboxFieldProps { - name: string; - /** - * Hierarchical structure of checkbox options - */ + extends Pick { options: NestedCheckboxOption[]; - /** - * Disable all checkboxes - */ - disabled?: boolean; + onUpdate?: (values: string[]) => void; } diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index 2d013d51ae2..16b9568f747 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -153,6 +153,7 @@ const ConnectedFormPlayground: React.FC = ({ justifyContent="space-between" minHeight="50rem" onSubmit={(values) => { + console.log('values', values); action('Form Submitted')(values); }} {...connectedFormProps} From b1d05a8e289b9d95e826f27562f7dae646143e5a Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 9 Sep 2025 13:37:13 -0400 Subject: [PATCH 04/44] add aria-checked --- .../ConnectedInputs/ConnectedNestedCheckboxes.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index a4f52a24ba2..1eb1ebc6005 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -183,16 +183,23 @@ export const ConnectedNestedCheckboxes: React.FC< 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 ( - + Date: Tue, 9 Sep 2025 13:37:25 -0400 Subject: [PATCH 05/44] update stories --- .../ConnectedForm/ConnectedForm.stories.tsx | 66 ++++++++++--------- .../ConnectedFormInputs.mdx | 44 ++++++++++++- 2 files changed, 79 insertions(+), 31 deletions(-) diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index 16b9568f747..be03774bd3f 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -128,6 +128,7 @@ const ConnectedFormPlayground: React.FC = ({ inputField: '', radioGroupField: undefined, textAreaField: '', + nestedCheckboxesField: [], }, validationRules: { checkboxField: { required: 'you need to check this.' }, @@ -240,36 +241,41 @@ const ConnectedFormPlayground: React.FC = ({ name="textAreaField" {...connectedFormGroup} /> - console.log('Selected:', selectedValues)} + + console.log('Selected:', selectedValues), + }} + label="nested checkboxes field" + name="nestedCheckboxesField" /> ); diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index e6a1b610adc..2236e3d417e 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -28,12 +28,54 @@ We have a selection of `ConnectedInput` components that are visually identical t ### ConnectedRadioGroupInput -`ConnectedRadioGroupInput` is the exception to the rule, and has some props that differ, particularly `options` — which takes an array of `ConnectedBaseRadioInputProps` components. +`ConnectedRadioGroupInput` is an exception to the rule, and has some props that differ, particularly `options` — which takes an array of `ConnectedBaseRadioInputProps` components. For further styling configurations, check out RadioGroup. `ConnectedRadioGroup` and `ConnectedRadio` should rarely, if ever, be used outside of `ConnectedRadioGroupInput`. +### ConnectedNestedCheckboxes + +`ConnectedNestedCheckboxes` is a component that allows you to create a nested checkbox group. It is a wrapper around the `ConnectedCheckbox` component and takes in an array `NestedCheckboxOption`s. Infinite levels of nesting are supported. Clicking a parent checkbox toggles all its children accordingly. Individual child checkboxes can be toggled independently. Checkbox states are `checked` if all children are checked, `indeterminate` if some children are checked, or `unchecked` if no children are checked. + +```tsx + +``` + + ## Usage The components are engineered to be passed into the component prop of ConnectedFormGroup, like so: From ec5e50308e67006970849f5aa9c2c7e7c884a497 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 15 Sep 2025 13:43:31 -0400 Subject: [PATCH 06/44] fix connectednestedcheckbox --- .../ConnectedNestedCheckboxes.tsx | 17 ++++++++++++----- .../src/ConnectedForm/ConnectedInputs/types.tsx | 9 +++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index 1eb1ebc6005..e8dee522638 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -18,7 +18,7 @@ type FlatCheckboxState = ConnectedCheckboxProps & { export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps -> = ({ name, options, disabled, onUpdate }) => { +> = ({ name, options, disabled, onUpdate, spacing }) => { const { isDisabled, control, validation, isRequired } = useField({ name, disabled, @@ -38,6 +38,7 @@ export const ConnectedNestedCheckboxes: React.FC< result.push({ ...option, + spacing, value: optionValue, level, parentValue, @@ -53,7 +54,7 @@ export const ConnectedNestedCheckboxes: React.FC< return result; }, - [] + [spacing] ); const flatOptions = useMemo( @@ -173,7 +174,9 @@ export const ConnectedNestedCheckboxes: React.FC< ( option: FlatCheckboxState, selectedValues: string[], - onChange: (values: string[]) => void + onChange: (values: string[]) => void, + onBlur: () => void, + ref: React.RefCallback ) => { const states = calculateStates(selectedValues); const state = states.get(String(option.value))!; @@ -213,7 +216,10 @@ export const ConnectedNestedCheckboxes: React.FC< 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), @@ -223,6 +229,7 @@ export const ConnectedNestedCheckboxes: React.FC< ); }} {...checkedProps} + {...ref} /> ); @@ -235,10 +242,10 @@ export const ConnectedNestedCheckboxes: React.FC< control={control} defaultValue={[]} name={name} - render={({ field: { value, onChange } }) => ( + render={({ field: { value, onChange, onBlur, ref } }) => ( {flatOptions.map((option) => - renderCheckbox(option, value || [], onChange) + renderCheckbox(option, value || [], onChange, onBlur, ref) )} )} diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index 33fbfb044ce..ad01bc9b7a1 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -70,12 +70,13 @@ export interface ConnectedTextAreaProps extends Omit, ConnectedFieldProps {} -export type NestedCheckboxOption = ConnectedCheckboxProps & { - children?: NestedCheckboxOption[]; -}; +export type NestedCheckboxOption = Omit & + CheckboxLabelUnion & { + children?: NestedCheckboxOption[]; + }; export interface ConnectedNestedCheckboxesProps - extends Pick { + extends Pick { options: NestedCheckboxOption[]; onUpdate?: (values: string[]) => void; } From 9562d554904302d7cb44d252b858e8ccb3efe3f3 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 15 Sep 2025 15:45:18 -0400 Subject: [PATCH 07/44] dont use children as prop name --- .../ConnectedNestedCheckboxes.tsx | 46 +-- .../ConnectedForm/ConnectedInputs/types.tsx | 25 +- .../GridFormNestedCheckboxInput/index.tsx | 267 ++++++++++++++++++ .../src/GridForm/GridFormInputGroup/index.tsx | 10 + packages/gamut/src/GridForm/types.ts | 20 +- .../ConnectedForm/ConnectedForm.stories.tsx | 6 +- .../Organisms/GridForm/GridForm.stories.tsx | 34 +++ 7 files changed, 374 insertions(+), 34 deletions(-) create mode 100644 packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx index e8dee522638..8855ea4aeb2 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes.tsx @@ -2,19 +2,20 @@ import * as React from 'react'; import { useCallback, useMemo } from 'react'; import { Controller } from 'react-hook-form'; -import { Box, Checkbox } from '../..'; +import { Box, Checkbox, CheckboxLabelUnion } from '../..'; import { useField } from '..'; import { - ConnectedCheckboxProps, ConnectedNestedCheckboxesProps, - NestedCheckboxOption, + MinimalCheckboxProps, + NestedConnectedCheckboxOption, } from './types'; -type FlatCheckboxState = ConnectedCheckboxProps & { - level: number; - parentValue?: string; - children: string[]; -}; +type FlatCheckboxState = MinimalCheckboxProps & + CheckboxLabelUnion & { + level: number; + parentValue?: string; + options: string[]; + }; export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps @@ -26,14 +27,18 @@ export const ConnectedNestedCheckboxes: React.FC< // Flatten the nested structure for easier state management const flattenOptions = useCallback( - (opts: NestedCheckboxOption[], level = 0, parentValue?: string) => { + ( + opts: NestedConnectedCheckboxOption[], + level = 0, + parentValue?: string + ) => { const result: FlatCheckboxState[] = []; opts.forEach((option) => { // Ensure value is a string const optionValue = String(option.value); - const children = option.children - ? option.children.map((child) => String(child.value)) + const options = option.options + ? option.options.map((child) => String(child.value)) : []; result.push({ @@ -42,12 +47,12 @@ export const ConnectedNestedCheckboxes: React.FC< value: optionValue, level, parentValue, - children, + options, }); - if (option.children) { + if (option.options) { result.push( - ...flattenOptions(option.children, level + 1, optionValue) + ...flattenOptions(option.options, level + 1, optionValue) ); } }); @@ -98,7 +103,7 @@ export const ConnectedNestedCheckboxes: React.FC< // Calculate parent states based on all descendants (infinite levels) flatOptions.forEach((option) => { - if (option.children.length > 0) { + if (option.options.length > 0) { const allDescendants = getAllDescendants(String(option.value)); const checkedDescendants = allDescendants.filter((descendantValue) => selectedValues.includes(descendantValue) @@ -134,7 +139,7 @@ export const ConnectedNestedCheckboxes: React.FC< let newSelectedValues = [...selectedValues]; - if (option.children.length > 0) { + if (option.options.length > 0) { // Parent checkbox - toggle all descendants (infinite levels) const allDescendants = getAllDescendants(currentValue); @@ -202,7 +207,12 @@ export const ConnectedNestedCheckboxes: React.FC< } return ( - + ( - + {flatOptions.map((option) => renderCheckbox(option, value || [], onChange, onBlur, ref) )} diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index ad01bc9b7a1..11fd80a251d 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -16,16 +16,14 @@ export interface BaseConnectedFieldProps { 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 & @@ -70,13 +68,16 @@ export interface ConnectedTextAreaProps extends Omit, ConnectedFieldProps {} -export type NestedCheckboxOption = Omit & +export type NestedConnectedCheckboxOption = Omit< + MinimalCheckboxProps, + 'spacing' +> & CheckboxLabelUnion & { - children?: NestedCheckboxOption[]; + options?: NestedConnectedCheckboxOption[]; }; export interface ConnectedNestedCheckboxesProps extends Pick { - options: NestedCheckboxOption[]; + options: NestedConnectedCheckboxOption[]; onUpdate?: (values: string[]) => void; } diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx new file mode 100644 index 00000000000..c24582e2bfb --- /dev/null +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -0,0 +1,267 @@ +import * as React from 'react'; +import { useCallback, useMemo } from 'react'; +import { Controller } from 'react-hook-form'; + +import { Box, Checkbox, CheckboxPaddingProps } from '../../..'; +import { + BaseFormInputProps, + GridFormNestedCheckboxField, + NestedGridFormCheckboxOption, +} from '../../types'; + +export interface GridFormNestedCheckboxInputProps extends BaseFormInputProps { + field: GridFormNestedCheckboxField; +} + +type FlatCheckboxState = Omit & + CheckboxPaddingProps & { + level: number; + parentValue?: string; + options: string[]; + }; + +export const GridFormNestedCheckboxInput: React.FC< + GridFormNestedCheckboxInputProps +> = ({ field, required, disabled, error }) => { + const isDisabled = disabled || field.disabled; + + // Flatten the nested structure for easier state management + const flattenOptions = useCallback( + (opts: NestedGridFormCheckboxOption[], 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: field.spacing, + value: optionValue, + level, + parentValue, + options, + }); + + if (option.options) { + result.push( + ...flattenOptions(option.options, level + 1, optionValue) + ); + } + }); + + return result; + }, + [field.spacing] + ); + + const flatOptions = useMemo( + () => flattenOptions(field.options), + [field.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(); + + // 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); + field.onUpdate?.(newSelectedValues); + }, + [flatOptions, field, getAllDescendants] + ); + + const renderCheckbox = useCallback( + ( + option: FlatCheckboxState, + selectedValues: string[], + onChange: (values: string[]) => void, + onBlur: () => void + ) => { + const states = calculateStates(selectedValues); + const state = states.get(String(option.value))!; + const checkboxId = field.id || `${field.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 ( + + { + handleCheckboxChange( + String(option.value), + event.target.checked, + selectedValues, + onChange + ); + }} + {...checkedProps} + /> + + ); + }, + [ + calculateStates, + field.name, + field.id, + required, + isDisabled, + handleCheckboxChange, + error, + ] + ); + + return ( + ( + + {flatOptions.map((option) => + renderCheckbox(option, value || [], onChange, onBlur) + )} + + )} + rules={field.validation} + /> + ); +}; diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/index.tsx index 7fa3b5a9f00..6564f7248e3 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/index.tsx @@ -17,6 +17,7 @@ import { GridFormCheckboxInput } from './GridFormCheckboxInput'; import { GridFormCustomInput } from './GridFormCustomInput'; import { GridFormFileInput } from './GridFormFileInput'; import { GridFormHiddenInput } from './GridFormHiddenInput'; +import { GridFormNestedCheckboxInput } from './GridFormNestedCheckboxInput'; import { GridFormRadioGroupInput } from './GridFormRadioGroupInput'; import { GridFormSelectInput } from './GridFormSelectInput'; import { GridFormSweetContainerInput } from './GridFormSweetContainerInput'; @@ -58,6 +59,15 @@ export const GridFormInputGroup: React.FC = ({ case 'checkbox': return ; + case 'nested-checkboxes': + return ( + + ); + case 'custom': case 'custom-group': return ( diff --git a/packages/gamut/src/GridForm/types.ts b/packages/gamut/src/GridForm/types.ts index 27df2d12707..4ac4a39b498 100644 --- a/packages/gamut/src/GridForm/types.ts +++ b/packages/gamut/src/GridForm/types.ts @@ -2,7 +2,8 @@ import { ReactNode } from 'react'; import { RegisterOptions, UseFormReturn } from 'react-hook-form'; import { BoxProps } from '../Box'; -import { TextAreaProps } from '../Form'; +import { MinimalCheckboxProps } from '../ConnectedForm'; +import { CheckboxLabelUnion, TextAreaProps } from '../Form'; import { CheckboxPaddingProps } from '../Form/types'; import { ColumnProps } from '../Layout'; import { InfoTipProps } from '../Tip/InfoTip'; @@ -48,6 +49,22 @@ export type GridFormCheckboxField = BaseFormField & type: 'checkbox'; }; +export type NestedGridFormCheckboxOption = Omit< + MinimalCheckboxProps, + 'spacing' +> & + CheckboxLabelUnion & { + options?: NestedGridFormCheckboxOption[]; + }; + +export type GridFormNestedCheckboxField = BaseFormField & + CheckboxPaddingProps & { + label?: React.ReactNode; + options: NestedGridFormCheckboxOption[]; + validation?: RegisterOptions; + type: 'nested-checkboxes'; + }; + export type GridFormCustomFieldProps = { className?: string; error?: string; @@ -140,6 +157,7 @@ export type GridFormField = | GridFormCheckboxField | GridFormCustomField | GridFormCustomGroupField + | GridFormNestedCheckboxField | GridFormRadioGroupField | GridFormTextField | GridFormSelectField diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index be03774bd3f..1e66fd9dc90 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -248,12 +248,12 @@ const ConnectedFormPlayground: React.FC = ({ { value: 'frontend', label: 'Frontend Technologies', - children: [ + options: [ { value: 'react', label: 'React' }, { value: 'vue', label: 'Vue.js', - children: [ + options: [ { value: 'test', label: 'Test' }, { value: 'test2', label: 'Test2' }, ], @@ -264,7 +264,7 @@ const ConnectedFormPlayground: React.FC = ({ { value: 'backend', label: 'Backend Technologies', - children: [ + options: [ { value: 'node', label: 'Node.js' }, { value: 'python', label: 'Python' }, { value: 'java', label: 'Java' }, diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx index 07ef3698255..9e1c2c0a5cb 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx @@ -135,6 +135,40 @@ const DefaultExample = (args: DefaultExampleProps) => { }, size: 4, }, + { + label: 'Nested checkboxes', + name: 'nested-checkboxes', + type: 'nested-checkboxes', + spacing: 'tight', + options: [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { + value: 'vue', + label: 'Vue.js', + options: [ + { value: 'test', label: 'Test' }, + { value: 'test2', label: 'Test2' }, + ], + }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + ], + }, + ], + size: 4, + }, ]} submit={{ contents: 'Submit Me!?', From f8dbe3003424449725abd77bdf4469a12ac616dc Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 15 Sep 2025 15:50:13 -0400 Subject: [PATCH 08/44] add back in nested examples --- .../ConnectedForm/ConnectedForm.stories.tsx | 37 +++++++++++++++++++ .../Organisms/GridForm/GridForm.stories.tsx | 33 +++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index 89199372428..34943802627 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -32,6 +32,7 @@ export const Default = () => { inputField: '', radioGroupField: undefined, textAreaField: '', + nestedCheckboxesField: [], }, validationRules: { checkboxField: { required: 'you need to check this.' }, @@ -137,6 +138,42 @@ export const Default = () => { label="text area field" name="textAreaField" /> + + console.log('Selected:', selectedValues), + }} + label="nested checkboxes field" + name="nestedCheckboxesField" + /> ); }; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx index 67108aa8e3d..8e9d50580e8 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx @@ -116,6 +116,39 @@ const meta: Meta = { }, size: 4, }, + { + label: 'Nested checkboxes', + name: 'nested-checkboxes', + type: 'nested-checkboxes', + options: [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { + value: 'vue', + label: 'Vue.js', + options: [ + { value: 'test', label: 'Test' }, + { value: 'test2', label: 'Test2' }, + ], + }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + ], + }, + ], + size: 12, + }, ], submit: { contents: 'Submit Me!?', From 309c8a391cd44d4671f4de8f35c52ff0eb216bc3 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 15 Sep 2025 15:59:34 -0400 Subject: [PATCH 09/44] fix errors --- packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap | 1 + .../ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx | 1 + .../ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index fea4d396478..0795c55b29a 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -21,6 +21,7 @@ exports[`Gamut Exported Keys 1`] = ` "ConnectedForm", "ConnectedFormGroup", "ConnectedInput", + "ConnectedNestedCheckboxes", "ConnectedRadio", "ConnectedRadioGroup", "ConnectedRadioGroupInput", diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index 34943802627..bf2fe36c399 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -169,6 +169,7 @@ export const Default = () => { }, ], onUpdate: (selectedValues) => + // eslint-disable-next-line no-console console.log('Selected:', selectedValues), }} label="nested checkboxes field" diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index 2236e3d417e..6101c2bc05f 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -75,7 +75,6 @@ For further styling configurations, check out ConnectedFormGroup, like so: From 7d5ddfa13652052f065a55b1ecf2b84d1e602970 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 19 Sep 2025 10:50:49 -0400 Subject: [PATCH 10/44] PR feedback --- .../ConnectedForm/ConnectedForm.stories.tsx | 13 ++++++++----- .../ConnectedFormInputs/ConnectedFormInputs.mdx | 13 ++++++++----- .../src/lib/Organisms/GridForm/GridForm.stories.tsx | 13 ++++++++----- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index bf2fe36c399..64d3d36797b 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -146,15 +146,18 @@ export const Default = () => { value: 'frontend', label: 'Frontend Technologies', options: [ - { value: 'react', label: 'React' }, { - value: 'vue', - label: 'Vue.js', + value: 'react', + label: 'React', options: [ - { value: 'test', label: 'Test' }, - { value: 'test2', label: 'Test2' }, + { value: 'nextjs', label: 'Next.js' }, + { value: 'typescript', label: 'TypeScript' }, ], }, + { + value: 'vue', + label: 'Vue.js', + }, { value: 'angular', label: 'Angular' }, ], }, diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index 6101c2bc05f..6ae3c4a2189 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -49,14 +49,17 @@ For further styling configurations, check out - { - handleCheckboxChange( - String(option.value), - event.target.checked, - selectedValues, - onChange - ); - }} - {...checkedProps} - {...ref} - /> - - ); - }, - [calculateStates, name, isRequired, isDisabled, handleCheckboxChange] - ); - - return ( - ( - - {flatOptions.map((option) => - renderCheckbox(option, value || [], onChange, onBlur, ref) - )} - - )} - rules={validation} - /> - ); -}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx new file mode 100644 index 00000000000..a077d47be49 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { useMemo } from 'react'; +import { Controller } from 'react-hook-form'; + +import { Box } from '../../..'; +import { useField } from '../..'; +import { ConnectedNestedCheckboxesProps } from '../types'; +import { + calculateStates, + flattenOptions, + handleCheckboxChange, + renderCheckbox, +} from './utils'; + +export const ConnectedNestedCheckboxes: React.FC< + ConnectedNestedCheckboxesProps +> = ({ name, options, disabled, onUpdate, spacing }) => { + const { isDisabled, control, validation, isRequired } = useField({ + name, + disabled, + }); + + const optionsWithSpacing = options.map((option) => ({ + ...option, + spacing, + })); + + const flatOptions = useMemo( + () => flattenOptions(optionsWithSpacing), + [optionsWithSpacing] + ); + + return ( + ( + + {flatOptions.map((option) => { + const states = calculateStates(value, flatOptions); + const state = states.get(option.value)!; + return renderCheckbox( + option, + state, + `${name}-${option.value}`, + isRequired, + isDisabled, + onBlur, + (event) => { + handleCheckboxChange( + option, + event.target.checked, + value, + flatOptions, + onChange, + onUpdate + ); + }, + ref + ); + })} + + )} + rules={validation} + /> + ); +}; diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx new file mode 100644 index 00000000000..96acb0371e0 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx @@ -0,0 +1,207 @@ +import { Box, Checkbox, CheckboxLabelUnion } from '../../..'; +import { NestedGridFormCheckboxOption } from '../../../GridForm/types'; +import { MinimalCheckboxProps, NestedConnectedCheckboxOption } from '../types'; + +type FlatCheckbox = Omit & + CheckboxLabelUnion & { + level: number; + parentValue?: string; + options: string[]; + value: string; + }; + +type FlatCheckboxState = Pick; + +export const flattenOptions = ( + opts: NestedConnectedCheckboxOption[] | NestedGridFormCheckboxOption[], + level = 0, + parentValue?: string +) => { + const result: FlatCheckbox[] = []; + + opts.forEach((option) => { + const optionValue = String(option.value); + const options = option.options + ? option.options.map((child) => String(child.value)) + : []; + + result.push({ + ...option, + value: optionValue, + level, + parentValue, + options, + }); + + if (option.options) { + result.push(...flattenOptions(option.options, level + 1, optionValue)); + } + }); + + return result; +}; + +export const getAllDescendants = ( + parentValue: string, + flatOptions: FlatCheckbox[] +) => { + const descendants: string[] = []; + + const collectDescendants = (currentParentValue: string) => { + flatOptions.forEach((option) => { + if (option.parentValue === currentParentValue) { + descendants.push(option.value); + collectDescendants(option.value); + } + }); + }; + + collectDescendants(parentValue); + return descendants; +}; + +export const calculateStates = ( + selectedValues: string[], + flatOptions: FlatCheckbox[] +) => { + const states = new Map(); + + // Initialize all states + flatOptions.forEach((option) => { + states.set(option.value, { + checked: selectedValues.includes(option.value), + }); + }); + + // Calculate parent states based on all descendants (infinite levels) + flatOptions.forEach((option) => { + if (option.options.length > 0) { + const allDescendants = getAllDescendants(option.value, flatOptions); + const checkedDescendants = allDescendants.filter((descendantValue) => + selectedValues.includes(descendantValue) + ); + + const state = states.get(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; +}; + +export const handleCheckboxChange = ( + option: FlatCheckbox, + isChecked: boolean, + selectedValues: string[], + flatOptions: FlatCheckbox[], + onChange: (values: string[]) => void, + onUpdate?: (values: string[]) => void +) => { + const currentValue = option.value; + + let newSelectedValues = [...selectedValues]; + + if (option.options.length > 0) { + // Parent checkbox - get all descendants (infinite levels) + const allDescendants = getAllDescendants(currentValue, flatOptions); + + 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); +}; + +export const renderCheckbox = ( + option: FlatCheckbox, + state: FlatCheckboxState, + checkboxId: string, + isRequired: boolean, + isDisabled: boolean, + onBlur: () => void, + onChange: (event: React.ChangeEvent) => void, + ref: React.RefCallback, + error?: boolean +) => { + 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 ( + + + + ); +}; diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index c24582e2bfb..655ec04e3c8 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -1,264 +1,65 @@ import * as React from 'react'; -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import { Controller } from 'react-hook-form'; -import { Box, Checkbox, CheckboxPaddingProps } from '../../..'; +import { Box } from '../../..'; import { - BaseFormInputProps, - GridFormNestedCheckboxField, - NestedGridFormCheckboxOption, -} from '../../types'; + calculateStates, + flattenOptions, + handleCheckboxChange, + renderCheckbox, +} from '../../../ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils'; +import { BaseFormInputProps, GridFormNestedCheckboxField } from '../../types'; export interface GridFormNestedCheckboxInputProps extends BaseFormInputProps { field: GridFormNestedCheckboxField; } -type FlatCheckboxState = Omit & - CheckboxPaddingProps & { - level: number; - parentValue?: string; - options: string[]; - }; - export const GridFormNestedCheckboxInput: React.FC< GridFormNestedCheckboxInputProps > = ({ field, required, disabled, error }) => { const isDisabled = disabled || field.disabled; - // Flatten the nested structure for easier state management - const flattenOptions = useCallback( - (opts: NestedGridFormCheckboxOption[], 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: field.spacing, - value: optionValue, - level, - parentValue, - options, - }); - - if (option.options) { - result.push( - ...flattenOptions(option.options, level + 1, optionValue) - ); - } - }); - - return result; - }, - [field.spacing] - ); + const optionsWithSpacing = field.options.map((option) => ({ + ...option, + spacing: field.spacing, + })); const flatOptions = useMemo( - () => flattenOptions(field.options), - [field.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(); - - // 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); - field.onUpdate?.(newSelectedValues); - }, - [flatOptions, field, getAllDescendants] - ); - - const renderCheckbox = useCallback( - ( - option: FlatCheckboxState, - selectedValues: string[], - onChange: (values: string[]) => void, - onBlur: () => void - ) => { - const states = calculateStates(selectedValues); - const state = states.get(String(option.value))!; - const checkboxId = field.id || `${field.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 ( - - { - handleCheckboxChange( - String(option.value), - event.target.checked, - selectedValues, - onChange - ); - }} - {...checkedProps} - /> - - ); - }, - [ - calculateStates, - field.name, - field.id, - required, - isDisabled, - handleCheckboxChange, - error, - ] + () => flattenOptions(optionsWithSpacing), + [optionsWithSpacing] ); return ( ( + render={({ field: { value = [], onChange, onBlur, ref } }) => ( - {flatOptions.map((option) => - renderCheckbox(option, value || [], onChange, onBlur) - )} + {flatOptions.map((option) => { + const states = calculateStates(value, flatOptions); + const state = states.get(option.value)!; + return renderCheckbox( + option, + state, + `${field.name}-${option.value}`, + !!required, + !!isDisabled, + onBlur, + (event) => { + handleCheckboxChange( + option, + event.target.checked, + value, + flatOptions, + onChange, + field.onUpdate + ); + }, + ref, + error + ); + })} )} rules={field.validation} From a34b0b5a5b78e97770ac41ab51bc9a5a709164f8 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 19 Sep 2025 16:06:47 -0400 Subject: [PATCH 12/44] first stab at tests --- .../ConnectedNestedCheckboxes.test.tsx | 479 +++++++++++++ .../__tests__/utils.test.tsx | 669 ++++++++++++++++++ 2 files changed, 1148 insertions(+) create mode 100644 packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx create mode 100644 packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx new file mode 100644 index 00000000000..6016822aa34 --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -0,0 +1,479 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent } from '@testing-library/dom'; +import { act } from '@testing-library/react'; + +import { ConnectedForm, ConnectedFormGroup, SubmitButton } from '../../..'; +import { NestedConnectedCheckboxOption } from '../../types'; +import { ConnectedNestedCheckboxes } from '../index'; + +const mockOptions: NestedConnectedCheckboxOption[] = [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue' }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { + value: 'node', + label: 'Node.js', + options: [ + { value: 'express', label: 'Express.js' }, + { value: 'fastify', label: 'Fastify' }, + ], + }, + { value: 'python', label: 'Python' }, + { value: 'ruby', label: 'Ruby', disabled: true }, + ], + }, + { + value: 'databases', + label: 'Databases', + }, +]; + +const mockOnUpdate = jest.fn(); +const TestForm: React.FC<{ + defaultValues?: { skills?: string[] }; + validationRules?: any; + disabled?: boolean; +}> = ({ defaultValues = {}, validationRules, disabled }) => ( + + + submit this form + +); + +const renderView = setupRtl(TestForm, {}); + +describe('ConnectedNestedCheckboxes', () => { + describe('rendering', () => { + it('should render all checkbox options in a flat list', () => { + const { view } = renderView(); + + // Top-level options + view.getByLabelText('Frontend Technologies'); + view.getByLabelText('Backend Technologies'); + view.getByLabelText('Databases'); + + // Frontend children + view.getByLabelText('React'); + view.getByLabelText('Vue'); + view.getByLabelText('Angular'); + + // Backend children + view.getByLabelText('Node.js'); + view.getByLabelText('Python'); + + // Deeply nested options + view.getByLabelText('Express.js'); + view.getByLabelText('Fastify'); + }); + + it('should render checkboxes with proper indentation levels', () => { + const { view } = renderView(); + + const frontendCheckbox = view + .getByLabelText('Frontend Technologies') + .closest('li'); + const reactCheckbox = view.getByLabelText('React').closest('li'); + const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); + const expressCheckbox = view.getByLabelText('Express.js').closest('li'); + + // Check margin-left styles for indentation + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0px' }); // level 0 + expect(reactCheckbox).toHaveStyle({ marginLeft: '24px' }); // level 1 + expect(nodeCheckbox).toHaveStyle({ marginLeft: '24px' }); // level 1 + expect(expressCheckbox).toHaveStyle({ marginLeft: '48px' }); // level 2 + }); + + it('should render with unique IDs for each checkbox', () => { + const { view } = renderView(); + + expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( + 'id', + 'skills-frontend' + ); + expect(view.getByLabelText('React')).toHaveAttribute( + 'id', + 'skills-react' + ); + expect(view.getByLabelText('Express.js')).toHaveAttribute( + 'id', + 'skills-express' + ); + }); + }); + + describe('default values', () => { + it('should render with default values checked', () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'python'] }, + }); + + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Vue')).not.toBeChecked(); + }); + + it('should render parent as indeterminate when some children are selected', () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'vue'] }, // only some frontend + }); + + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + + expect(frontendCheckbox.indeterminate).toBe(true); + expect(frontendCheckbox).not.toBeChecked(); + }); + + it('should render parent as checked when all children are selected', () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'vue', 'angular'] }, // all frontend + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + expect(frontendCheckbox).toBeChecked(); + }); + + it('should render deeply nested parent states correctly', () => { + const { view } = renderView({ + defaultValues: { skills: ['express', 'fastify'] }, // all node children + }); + + const nodeCheckbox = view.getByLabelText('Node.js'); + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + + expect(nodeCheckbox).toBeChecked(); // all children selected + expect(backendCheckbox.indeterminate).toBe(true); // only some children selected + }); + }); + + describe('user interactions', () => { + it('should update form value when leaf checkbox is clicked', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).toBeChecked(); + + // Verify parent state updates + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + expect(frontendCheckbox.indeterminate).toBe(true); + }); + + it('should select all children when parent checkbox is clicked', async () => { + const { view } = renderView({}); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(frontendCheckbox).toBeChecked(); + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Vue')).toBeChecked(); + expect(view.getByLabelText('Angular')).toBeChecked(); + }); + + it('should deselect all children when checked parent is clicked', async () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'vue', 'angular'] }, + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + expect(frontendCheckbox).toBeChecked(); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(frontendCheckbox).not.toBeChecked(); + expect(view.getByLabelText('React')).not.toBeChecked(); + expect(view.getByLabelText('Vue')).not.toBeChecked(); + expect(view.getByLabelText('Angular')).not.toBeChecked(); + }); + + it('should handle deeply nested selections correctly', async () => { + const { view } = renderView(); + + // Click Node.js parent (should select all its children) + const nodeCheckbox = view.getByLabelText('Node.js'); + + await act(async () => { + fireEvent.click(nodeCheckbox); + }); + + expect(nodeCheckbox).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + + // Backend should be indeterminate (only Node.js selected, not Python) + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + expect(backendCheckbox.indeterminate).toBe(true); + }); + + it('should handle individual child deselection affecting parent state', async () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'vue', 'angular'] }, + }); + + // Frontend should be fully checked + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + expect(frontendCheckbox).toBeChecked(); + + // Deselect one child + const reactCheckbox = view.getByLabelText('React'); + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).not.toBeChecked(); + expect(frontendCheckbox.indeterminate).toBe(true); + expect(frontendCheckbox).not.toBeChecked(); + }); + }); + + describe('onUpdate callback', () => { + it('should call onUpdate when checkbox values change', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith(['react']); + }); + + it('should call onUpdate with correct values when parent is selected', async () => { + const { view } = renderView(); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); + }); + + it('should call onUpdate with empty array when all items are deselected', async () => { + const { view } = renderView({ + defaultValues: { skills: ['react'] }, + }); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith([]); + }); + }); + + describe('disabled state', () => { + it('should render all checkboxes as disabled when disabled prop is true', () => { + const { view } = renderView({ disabled: true }); + + expect(view.getByLabelText('Frontend Technologies')).toBeDisabled(); + expect(view.getByLabelText('React')).toBeDisabled(); + expect(view.getByLabelText('Databases')).toBeDisabled(); + }); + + it('should not respond to clicks when disabled', async () => { + const { view } = renderView({ disabled: true }); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).not.toBeChecked(); + expect(mockOnUpdate).not.toHaveBeenCalled(); + }); + + it('should handle individual option disabled state', () => { + const { view } = renderView(); + + expect(view.getByLabelText('Ruby')).toBeDisabled(); + expect(view.getByLabelText('Vue')).not.toBeDisabled(); + }); + }); + + describe('validation', () => { + it('should handle required validation', async () => { + const validationRules = { + skills: { required: 'At least one skill is required' }, + }; + + const { view } = renderView({ validationRules }); + + await act(async () => { + fireEvent.click(view.getByRole('button')); + }); + + // Check if checkboxes have required attribute + expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( + 'aria-required', + 'true' + ); + }); + + it('should pass validation when items are selected', async () => { + const validationRules = { + skills: { required: 'At least one skill is required' }, + }; + + const { view } = renderView({ validationRules }); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).toBeChecked(); + expect(reactCheckbox).toHaveAttribute('aria-required', 'true'); + }); + }); + + describe('edge cases', () => { + it('should handle empty options array', () => { + const TestFormEmpty = () => ( + + + + ); + + const { view } = setupRtl(TestFormEmpty, {})({}); + + // Should render empty list + const list = view.container.querySelector('ul'); + expect(list).toBeInTheDocument(); + expect(list?.children).toHaveLength(0); + }); + + it('should handle options without nested children', () => { + const flatOptions: NestedConnectedCheckboxOption[] = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + const TestFormFlat = () => ( + + + + ); + + const { view } = setupRtl(TestFormFlat, {})({}); + + expect(view.getByLabelText('Option 1')).toBeInTheDocument(); + expect(view.getByLabelText('Option 2')).toBeInTheDocument(); + }); + + it('should handle numeric values correctly', () => { + const numericOptions: NestedConnectedCheckboxOption[] = [ + { + value: 1, + label: 'Parent Option', + options: [{ value: 2, label: 'Child Option' }], + } as any, // Type assertion for testing numeric values + ]; + + const TestFormNumeric = () => ( + + + + ); + + const { view } = setupRtl(TestFormNumeric, {})({}); + + expect(view.getByLabelText('Parent Option')).toHaveAttribute( + 'id', + 'skills-1' + ); + expect(view.getByLabelText('Child Option')).toHaveAttribute( + 'id', + 'skills-2' + ); + }); + }); + + describe('accessibility', () => { + it('should have proper aria attributes', () => { + const { view } = renderView({}); + + const checkbox = view.getByLabelText('Frontend Technologies'); + + expect(checkbox).toHaveAttribute('aria-label', 'Frontend Technologies'); + expect(checkbox).toHaveAttribute('aria-checked', 'false'); + }); + + it('should have proper aria-checked states for indeterminate checkboxes', () => { + const { view } = renderView({ + defaultValues: { skills: ['react'] }, // partial selection + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + expect(frontendCheckbox).toHaveAttribute('aria-checked', 'mixed'); + }); + + it('should use proper list semantics', () => { + const { view } = renderView({}); + + const list = view.container.querySelector('ul'); + const listItems = view.container.querySelectorAll('li'); + + expect(list).toHaveAttribute('role', 'list'); + expect(listItems).toHaveLength(8); // Total flattened options + + listItems.forEach((item) => { + expect(item).toHaveStyle({ listStyle: 'none' }); + }); + }); + }); +}); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx new file mode 100644 index 00000000000..e6b9ff13beb --- /dev/null +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx @@ -0,0 +1,669 @@ +import { render } from '@testing-library/react'; + +import { + calculateStates, + flattenOptions, + getAllDescendants, + handleCheckboxChange, + renderCheckbox, +} from '../utils'; + +describe('ConnectedNestedCheckboxes utils', () => { + describe('flattenOptions', () => { + it('should flatten a single level of options', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + const result = flattenOptions(options); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + value: 'option1', + label: 'Option 1', + level: 0, + parentValue: undefined, + options: [], + }); + expect(result[1]).toMatchObject({ + value: 'option2', + label: 'Option 2', + level: 0, + parentValue: undefined, + options: [], + }); + }); + + it('should flatten nested options with correct levels and parent values', () => { + const options = [ + { + value: 'parent1', + label: 'Parent 1', + options: [ + { value: 'child1', label: 'Child 1' }, + { value: 'child2', label: 'Child 2' }, + ], + }, + { value: 'parent2', label: 'Parent 2' }, + ]; + + const result = flattenOptions(options); + + expect(result).toHaveLength(4); + + // Parent 1 + expect(result[0]).toMatchObject({ + value: 'parent1', + label: 'Parent 1', + level: 0, + parentValue: undefined, + options: ['child1', 'child2'], + }); + + // Child 1 + expect(result[1]).toMatchObject({ + value: 'child1', + label: 'Child 1', + level: 1, + parentValue: 'parent1', + options: [], + }); + + // Child 2 + expect(result[2]).toMatchObject({ + value: 'child2', + label: 'Child 2', + level: 1, + parentValue: 'parent1', + options: [], + }); + + // Parent 2 + expect(result[3]).toMatchObject({ + value: 'parent2', + label: 'Parent 2', + level: 0, + parentValue: undefined, + options: [], + }); + }); + + it('should handle deeply nested options', () => { + const options = [ + { + value: 'level1', + label: 'Level 1', + options: [ + { + value: 'level2', + label: 'Level 2', + options: [{ value: 'level3', label: 'Level 3' }], + }, + ], + }, + ]; + + const result = flattenOptions(options); + + expect(result).toHaveLength(3); + expect(result[0].level).toBe(0); + expect(result[1].level).toBe(1); + expect(result[2].level).toBe(2); + expect(result[2].parentValue).toBe('level2'); + }); + + it('should handle empty options array', () => { + const result = flattenOptions([]); + expect(result).toEqual([]); + }); + + it('should convert numeric values to strings', () => { + const options = [{ value: 123, label: 'Numeric Option' }]; + + const result = flattenOptions(options as any); + + expect(result[0].value).toBe('123'); + }); + + it('should handle custom level and parentValue parameters', () => { + const options = [{ value: 'test', label: 'Test' }]; + + const result = flattenOptions(options, 2, 'customParent'); + + expect(result[0].level).toBe(2); + expect(result[0].parentValue).toBe('customParent'); + }); + }); + + describe('getAllDescendants', () => { + const flatOptions = [ + { + value: 'parent', + level: 0, + parentValue: undefined, + options: ['child1', 'child2'], + label: 'Parent', + }, + { + value: 'child1', + level: 1, + parentValue: 'parent', + options: ['grandchild1'], + label: 'Child 1', + }, + { + value: 'child2', + level: 1, + parentValue: 'parent', + options: [], + label: 'Child 2', + }, + { + value: 'grandchild1', + level: 2, + parentValue: 'child1', + options: [], + label: 'Grandchild 1', + }, + { + value: 'orphan', + level: 0, + parentValue: undefined, + options: [], + label: 'Orphan', + }, + ]; + + it('should get all direct and indirect descendants', () => { + const result = getAllDescendants('parent', flatOptions); + expect(result).toEqual(['child1', 'child2', 'grandchild1']); + }); + + it('should get only direct descendants when no grandchildren exist', () => { + const result = getAllDescendants('child2', flatOptions); + expect(result).toEqual([]); + }); + + it('should get descendants for intermediate level nodes', () => { + const result = getAllDescendants('child1', flatOptions); + expect(result).toEqual(['grandchild1']); + }); + + it('should return empty array for leaf nodes', () => { + const result = getAllDescendants('grandchild1', flatOptions); + expect(result).toEqual([]); + }); + + it('should return empty array for non-existent parent', () => { + const result = getAllDescendants('nonexistent', flatOptions); + expect(result).toEqual([]); + }); + + it('should handle empty flatOptions array', () => { + const result = getAllDescendants('parent', []); + expect(result).toEqual([]); + }); + }); + + describe('calculateStates', () => { + const flatOptions = [ + { + value: 'parent1', + level: 0, + parentValue: undefined, + options: ['child1', 'child2'], + label: 'Parent 1', + }, + { + value: 'child1', + level: 1, + parentValue: 'parent1', + options: ['grandchild1'], + label: 'Child 1', + }, + { + value: 'child2', + level: 1, + parentValue: 'parent1', + options: [], + label: 'Child 2', + }, + { + value: 'grandchild1', + level: 2, + parentValue: 'child1', + options: [], + label: 'Grandchild 1', + }, + { + value: 'parent2', + level: 0, + parentValue: undefined, + options: ['child3'], + label: 'Parent 2', + }, + { + value: 'child3', + level: 1, + parentValue: 'parent2', + options: [], + label: 'Child 3', + }, + ]; + + it('should set parent as checked when all descendants are selected', () => { + const selectedValues = ['child1', 'child2', 'grandchild1']; + const states = calculateStates(selectedValues, flatOptions); + + const parent1State = states.get('parent1'); + expect(parent1State).toEqual({ checked: true }); + }); + + it('should set parent as indeterminate when some descendants are selected', () => { + const selectedValues = ['child1', 'grandchild1']; + const states = calculateStates(selectedValues, flatOptions); + + const parent1State = states.get('parent1'); + expect(parent1State).toEqual({ checked: false, indeterminate: true }); + }); + + it('should set parent as unchecked when no descendants are selected', () => { + const selectedValues: string[] = []; + const states = calculateStates(selectedValues, flatOptions); + + const parent1State = states.get('parent1'); + expect(parent1State).toEqual({ checked: false, indeterminate: false }); + }); + + it('should set leaf nodes based on selection', () => { + const selectedValues = ['child2', 'grandchild1']; + const states = calculateStates(selectedValues, flatOptions); + + expect(states.get('child2')).toEqual({ checked: true }); + expect(states.get('grandchild1')).toEqual({ checked: true }); + expect(states.get('child3')).toEqual({ checked: false }); + }); + + it('should handle intermediate parent states correctly', () => { + const selectedValues = ['grandchild1']; + const states = calculateStates(selectedValues, flatOptions); + + const child1State = states.get('child1'); + expect(child1State).toEqual({ checked: true }); // all descendants (grandchild1) are selected + + const parent1State = states.get('parent1'); + expect(parent1State).toEqual({ checked: false, indeterminate: true }); // only some descendants selected + }); + + it('should handle empty selected values', () => { + const states = calculateStates([], flatOptions); + + flatOptions.forEach((option) => { + const state = states.get(option.value); + if (option.options.length > 0) { + expect(state).toEqual({ checked: false, indeterminate: false }); + } else { + expect(state).toEqual({ checked: false }); + } + }); + }); + + it('should handle all values selected', () => { + const allValues = flatOptions.map((opt) => opt.value); + const states = calculateStates(allValues, flatOptions); + + flatOptions.forEach((option) => { + const state = states.get(option.value); + expect(state?.checked).toBe(true); + }); + }); + }); + + describe('handleCheckboxChange', () => { + const flatOptions = [ + { + value: 'parent', + level: 0, + parentValue: undefined, + options: ['child1', 'child2'], + label: 'Parent', + }, + { + value: 'child1', + level: 1, + parentValue: 'parent', + options: [], + label: 'Child 1', + }, + { + value: 'child2', + level: 1, + parentValue: 'parent', + options: [], + label: 'Child 2', + }, + { + value: 'standalone', + level: 0, + parentValue: undefined, + options: [], + label: 'Standalone', + }, + ]; + + it('should add all descendants when parent is checked', () => { + const onChange = jest.fn(); + const onUpdate = jest.fn(); + const parentOption = flatOptions[0]; + + handleCheckboxChange( + parentOption, + true, + [], + flatOptions, + onChange, + onUpdate + ); + + expect(onChange).toHaveBeenCalledWith(['child1', 'child2', 'parent']); + expect(onUpdate).toHaveBeenCalledWith(['child1', 'child2', 'parent']); + }); + + it('should remove all descendants when parent is unchecked', () => { + const onChange = jest.fn(); + const onUpdate = jest.fn(); + const parentOption = flatOptions[0]; + const initialValues = ['parent', 'child1', 'child2', 'standalone']; + + handleCheckboxChange( + parentOption, + false, + initialValues, + flatOptions, + onChange, + onUpdate + ); + + expect(onChange).toHaveBeenCalledWith(['standalone']); + expect(onUpdate).toHaveBeenCalledWith(['standalone']); + }); + + it('should add individual child when checked', () => { + const onChange = jest.fn(); + const childOption = flatOptions[1]; + + handleCheckboxChange(childOption, true, [], flatOptions, onChange); + + expect(onChange).toHaveBeenCalledWith(['child1']); + }); + + it('should remove individual child when unchecked', () => { + const onChange = jest.fn(); + const childOption = flatOptions[1]; + const initialValues = ['child1', 'child2']; + + handleCheckboxChange( + childOption, + false, + initialValues, + flatOptions, + onChange + ); + + expect(onChange).toHaveBeenCalledWith(['child2']); + }); + + it('should not duplicate values when adding already selected items', () => { + const onChange = jest.fn(); + const parentOption = flatOptions[0]; + const initialValues = ['child1']; + + handleCheckboxChange( + parentOption, + true, + initialValues, + flatOptions, + onChange + ); + + expect(onChange).toHaveBeenCalledWith(['child1', 'child2', 'parent']); + }); + + it('should handle onUpdate being undefined', () => { + const onChange = jest.fn(); + const childOption = flatOptions[1]; + + expect(() => { + handleCheckboxChange(childOption, true, [], flatOptions, onChange); + }).not.toThrow(); + + expect(onChange).toHaveBeenCalledWith(['child1']); + }); + + it('should handle leaf node selection without affecting other nodes', () => { + const onChange = jest.fn(); + const standaloneOption = flatOptions[3]; + + handleCheckboxChange( + standaloneOption, + true, + ['child1'], + flatOptions, + onChange + ); + + expect(onChange).toHaveBeenCalledWith(['child1', 'standalone']); + }); + }); + + describe('renderCheckbox', () => { + const mockOption = { + value: 'test', + label: 'Test Label', + level: 1, + parentValue: 'parent', + options: [], + }; + + const mockRef = jest.fn(); + const mockOnChange = jest.fn(); + const mockOnBlur = jest.fn(); + + it('should render a checked checkbox with correct props', () => { + const state = { checked: true }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + true, // isRequired + false, // isDisabled + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toBeChecked(); + expect(checkbox).toHaveAttribute('aria-checked', 'true'); + expect(checkbox).toHaveAttribute('aria-required', 'true'); + }); + + it('should render an indeterminate checkbox with correct props', () => { + const state = { checked: false, indeterminate: true }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + false, // isRequired + false, // isDisabled + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + expect(checkbox).toHaveAttribute('aria-checked', 'mixed'); + }); + + it('should render an unchecked checkbox with correct props', () => { + const state = { checked: false }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + false, // isRequired + false, // isDisabled + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + expect(checkbox).toHaveAttribute('aria-checked', 'false'); + }); + + it('should apply correct margin based on level', () => { + const state = { checked: false }; + + const result = renderCheckbox( + { ...mockOption, level: 2 }, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const listItem = container.querySelector('li'); + + expect(listItem).toHaveStyle({ marginLeft: '48px' }); // 2 * 24px + }); + + it('should handle disabled state', () => { + const state = { checked: false }; + + const result = renderCheckbox( + { ...mockOption, disabled: true }, + state, + 'test-id', + false, + true, // isDisabled + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toBeDisabled(); + }); + + it('should handle error state', () => { + const state = { checked: false }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef, + true // error + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toHaveAttribute('aria-invalid', 'true'); + }); + + it('should use custom aria-label when provided', () => { + const state = { checked: false }; + const optionWithAriaLabel = { + ...mockOption, + 'aria-label': 'Custom aria label', + }; + + const result = renderCheckbox( + optionWithAriaLabel, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toHaveAttribute('aria-label', 'Custom aria label'); + }); + + it('should fallback to label text for aria-label when label is string', () => { + const state = { checked: false }; + + const result = renderCheckbox( + mockOption, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toHaveAttribute('aria-label', 'Test Label'); + }); + + it('should use default aria-label when label is not string', () => { + const state = { checked: false }; + const optionWithElementLabel = { + ...mockOption, + label: Element Label, + }; + + const result = renderCheckbox( + optionWithElementLabel, + state, + 'test-id', + false, + false, + mockOnBlur, + mockOnChange, + mockRef + ); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).toHaveAttribute('aria-label', 'checkbox'); + }); + }); +}); From 641aeb20bca0b7b2127ce0883e580d64b474bbb2 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 23 Sep 2025 15:31:02 -0400 Subject: [PATCH 13/44] it works in gridform --- .../ConnectedNestedCheckboxes.test.tsx | 50 ++++++ .../ConnectedNestedCheckboxes/utils.tsx | 21 +++ .../GridFormNestedCheckboxInput.test.tsx | 133 ++++++++++++++ .../GridFormNestedCheckboxInput/index.tsx | 164 ++++++++++++++---- .../ConnectedForm/ConnectedForm.stories.tsx | 2 +- .../Organisms/GridForm/GridForm.stories.tsx | 1 + 6 files changed, 336 insertions(+), 35 deletions(-) create mode 100644 packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx index 6016822aa34..d27c226a604 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -170,6 +170,56 @@ describe('ConnectedNestedCheckboxes', () => { expect(nodeCheckbox).toBeChecked(); // all children selected expect(backendCheckbox.indeterminate).toBe(true); // only some children selected }); + + it('should automatically check all children when parent is in default values', () => { + const { view } = renderView({ + defaultValues: { skills: ['backend'] }, // parent selected by default + }); + + // Parent should be checked + const backendCheckbox = view.getByLabelText('Backend Technologies'); + expect(backendCheckbox).toBeChecked(); + + // All children should be automatically checked + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + + // Deeply nested children should also be checked + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + }); + + it('should allow unchecking children that were auto-checked by default parent selection', async () => { + const { view } = renderView({ + defaultValues: { skills: ['backend'] }, // parent selected by default + }); + + // Initially all should be checked due to parent selection + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + const pythonCheckbox = view.getByLabelText('Python'); + + expect(backendCheckbox).toBeChecked(); + expect(pythonCheckbox).toBeChecked(); + + // User should be able to uncheck a child + await act(async () => { + fireEvent.click(pythonCheckbox); + }); + + // Python should now be unchecked + expect(pythonCheckbox).not.toBeChecked(); + + // Parent should now be indeterminate since not all children are checked + expect(backendCheckbox.indeterminate).toBe(true); + expect(backendCheckbox).not.toBeChecked(); + + // Other children should remain checked + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + }); }); describe('user interactions', () => { diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx index 96acb0371e0..bc79a8d9713 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx @@ -66,6 +66,21 @@ export const calculateStates = ( ) => { const states = new Map(); + // const expandedValues = [...selectedValues]; + + // // For each selected value, if it's a parent, add all its descendants + // selectedValues.forEach((selectedValue: string) => { + // const option = flatOptions.find((opt) => opt.value === selectedValue); + // if (option && option.options.length > 0) { + // const allDescendants = getAllDescendants(selectedValue, flatOptions); + // allDescendants.forEach((descendantValue) => { + // if (!expandedValues.includes(descendantValue)) { + // expandedValues.push(descendantValue); + // } + // }); + // } + // }); + // Initialize all states flatOptions.forEach((option) => { states.set(option.value, { @@ -94,6 +109,7 @@ export const calculateStates = ( } }); + console.log(states); return states; }; @@ -106,6 +122,7 @@ export const handleCheckboxChange = ( onUpdate?: (values: string[]) => void ) => { const currentValue = option.value; + // console.log(currentValue); let newSelectedValues = [...selectedValues]; @@ -128,6 +145,9 @@ export const handleCheckboxChange = ( } } + console.log(newSelectedValues); + console.log(currentValue); + console.log(isChecked); // Handle the current checkbox itself (for leaf nodes or when toggling individual items) if (isChecked) { if (!newSelectedValues.includes(currentValue)) { @@ -137,6 +157,7 @@ export const handleCheckboxChange = ( newSelectedValues = newSelectedValues.filter( (value) => value !== currentValue ); + console.log(newSelectedValues); } onChange(newSelectedValues); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx new file mode 100644 index 00000000000..71ed3813605 --- /dev/null +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx @@ -0,0 +1,133 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { act } from '@testing-library/react'; + +import { GridFormNestedCheckboxInput } from '../index'; + +const mockNestedCheckboxField = { + component: 'nested-checkboxes' as const, + name: 'technologies', + label: 'Technologies', + options: [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue.js' }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + ], + }, + ], +}; + +const renderComponent = setupRtl(GridFormNestedCheckboxInput, { + field: mockNestedCheckboxField, +}); + +describe('GridFormNestedCheckboxInput', () => { + describe('default values', () => { + it('should render with basic options unchecked by default', () => { + const { view } = renderComponent(); + + expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); + expect(view.getByLabelText('Backend Technologies')).not.toBeChecked(); + expect(view.getByLabelText('React')).not.toBeChecked(); + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + expect(view.getByLabelText('Node.js')).not.toBeChecked(); + expect(view.getByLabelText('Python')).not.toBeChecked(); + }); + + it('should automatically check all children when parent is in default values', async () => { + const fieldWithDefaults = { + ...mockNestedCheckboxField, + defaultValue: ['backend'], // Parent selected by default + }; + + const { view } = renderComponent({ field: fieldWithDefaults }); + + // Wait for the expansion to happen (setTimeout in the component) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + // Parent should be checked + const backendCheckbox = view.getByLabelText('Backend Technologies'); + expect(backendCheckbox).toBeChecked(); + + // All children should be automatically checked + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Java')).toBeChecked(); + + // Frontend should remain unchecked + expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); + expect(view.getByLabelText('React')).not.toBeChecked(); + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + }); + + it('should handle multiple parent defaults correctly', async () => { + const fieldWithDefaults = { + ...mockNestedCheckboxField, + defaultValue: ['frontend', 'backend'], // Both parents selected by default + }; + + const { view } = renderComponent({ field: fieldWithDefaults }); + + // Wait for the expansion to happen + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + // Both parents should be checked + expect(view.getByLabelText('Frontend Technologies')).toBeChecked(); + expect(view.getByLabelText('Backend Technologies')).toBeChecked(); + + // All children should be automatically checked + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Vue.js')).toBeChecked(); + expect(view.getByLabelText('Angular')).toBeChecked(); + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Java')).toBeChecked(); + }); + + it('should preserve individual child selections in default values', () => { + const fieldWithDefaults = { + ...mockNestedCheckboxField, + defaultValue: ['react', 'python'], // Individual children selected + }; + + const { view } = renderComponent({ field: fieldWithDefaults }); + + // Selected children should be checked + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + + // Parents should be indeterminate since not all children are selected + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + + expect(frontendCheckbox.indeterminate).toBe(true); + expect(backendCheckbox.indeterminate).toBe(true); + + // Other children should remain unchecked + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + expect(view.getByLabelText('Angular')).not.toBeChecked(); + expect(view.getByLabelText('Node.js')).not.toBeChecked(); + expect(view.getByLabelText('Java')).not.toBeChecked(); + }); + }); +}); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index 655ec04e3c8..fb65015047e 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import { useMemo } from 'react'; -import { Controller } from 'react-hook-form'; +import { useEffect, useMemo, useRef } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { Box } from '../../..'; import { calculateStates, flattenOptions, + getAllDescendants, handleCheckboxChange, renderCheckbox, } from '../../../ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils'; @@ -25,43 +26,138 @@ export const GridFormNestedCheckboxInput: React.FC< spacing: field.spacing, })); - const flatOptions = useMemo( - () => flattenOptions(optionsWithSpacing), - [optionsWithSpacing] + const flatOptions = useMemo(() => { + const flattened = flattenOptions(optionsWithSpacing); + console.log('flatOptions computed:', flattened); + return flattened; + }, [optionsWithSpacing]); + + // Helper function to expand values to include descendants of selected parents + const expandValues = React.useCallback( + (values: string[]): string[] => { + console.log('expandValues called with:', values); + const expandedValues = [...values]; + + // For each selected value, if it's a parent, add all its descendants + values.forEach((selectedValue: string) => { + const option = flatOptions.find((opt) => opt.value === selectedValue); + console.log(`Checking value ${selectedValue}:`, option); + if (option && option.options.length > 0) { + const allDescendants = getAllDescendants(selectedValue, flatOptions); + console.log(`Descendants for ${selectedValue}:`, allDescendants); + allDescendants.forEach((descendantValue) => { + if (!expandedValues.includes(descendantValue)) { + expandedValues.push(descendantValue); + console.log(`Added descendant: ${descendantValue}`); + } + }); + } + }); + + console.log('expandValues result:', expandedValues); + return expandedValues; + }, + [flatOptions] ); + // Track if we've done initial expansion + const hasExpandedInitially = useRef(false); + const { setValue } = useFormContext(); + + // Extract field properties for stable dependencies + const fieldName = field.name; + const fieldDefaultValue = field.defaultValue; + const fieldOnUpdate = field.onUpdate; + + // Handle expansion in useEffect instead of render function + useEffect(() => { + if (hasExpandedInitially.current) return; + + // Get current form value + const currentFormValue = fieldDefaultValue || []; + console.log('useEffect expansion check:', { + fieldName, + currentFormValue, + flatOptionsLength: flatOptions.length, + }); + + if (currentFormValue.length > 0) { + const needsExpansion = currentFormValue.some((selectedValue: string) => { + const option = flatOptions.find((opt) => opt.value === selectedValue); + if (option && option.options.length > 0) { + const allDescendants = getAllDescendants(selectedValue, flatOptions); + const hasAllDescendants = allDescendants.every((descendant) => + currentFormValue.includes(descendant) + ); + console.log( + `Value ${selectedValue} needs expansion:`, + !hasAllDescendants + ); + return !hasAllDescendants; + } + return false; + }); + + if (needsExpansion) { + const expandedValues = expandValues(currentFormValue); + console.log('useEffect EXPANDING:', { + original: currentFormValue, + expanded: expandedValues, + }); + + // Use setValue to update the form state + setValue(fieldName, expandedValues); + fieldOnUpdate?.(expandedValues); + hasExpandedInitially.current = true; + } + } + }, [ + fieldName, + fieldDefaultValue, + fieldOnUpdate, + flatOptions, + expandValues, + setValue, + ]); + return ( ( - - {flatOptions.map((option) => { - const states = calculateStates(value, flatOptions); - const state = states.get(option.value)!; - return renderCheckbox( - option, - state, - `${field.name}-${option.value}`, - !!required, - !!isDisabled, - onBlur, - (event) => { - handleCheckboxChange( - option, - event.target.checked, - value, - flatOptions, - onChange, - field.onUpdate - ); - }, - ref, - error - ); - })} - - )} + render={({ field: { value = [], onChange, onBlur, ref } }) => { + console.log('GridForm render:', { + fieldName: field.name, + value, + }); + + const states = calculateStates(value, flatOptions); + return ( + + {flatOptions.map((option) => { + const state = states.get(option.value)!; + return renderCheckbox( + option, + state, + `${field.name}-${option.value}`, + !!required, + !!isDisabled, + onBlur, + (event) => { + handleCheckboxChange( + option, + event.target.checked, + value, + flatOptions, + onChange, + field.onUpdate + ); + }, + ref, + error + ); + })} + + ); + }} rules={field.validation} /> ); diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index 64d3d36797b..43e9fe0bdb0 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -32,7 +32,7 @@ export const Default = () => { inputField: '', radioGroupField: undefined, textAreaField: '', - nestedCheckboxesField: [], + nestedCheckboxesField: ['react', 'typescript', 'backend'], }, validationRules: { checkboxField: { required: 'you need to check this.' }, diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx index 1b616dfab7b..4bd283f0cf5 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx @@ -120,6 +120,7 @@ const meta: Meta = { label: 'Nested checkboxes', name: 'nested-checkboxes', type: 'nested-checkboxes', + defaultValue: ['backend', 'react', 'vue'], options: [ { value: 'frontend', From 86152dce513a53d9d669ea8530ca9551b453bc25 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 23 Sep 2025 15:55:02 -0400 Subject: [PATCH 14/44] clean up logs and comments --- .../ConnectedNestedCheckboxes/utils.tsx | 22 ----------------- .../GridFormNestedCheckboxInput/index.tsx | 24 ------------------- 2 files changed, 46 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx index bc79a8d9713..2b3b4aa96ba 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx @@ -66,21 +66,6 @@ export const calculateStates = ( ) => { const states = new Map(); - // const expandedValues = [...selectedValues]; - - // // For each selected value, if it's a parent, add all its descendants - // selectedValues.forEach((selectedValue: string) => { - // const option = flatOptions.find((opt) => opt.value === selectedValue); - // if (option && option.options.length > 0) { - // const allDescendants = getAllDescendants(selectedValue, flatOptions); - // allDescendants.forEach((descendantValue) => { - // if (!expandedValues.includes(descendantValue)) { - // expandedValues.push(descendantValue); - // } - // }); - // } - // }); - // Initialize all states flatOptions.forEach((option) => { states.set(option.value, { @@ -109,7 +94,6 @@ export const calculateStates = ( } }); - console.log(states); return states; }; @@ -122,8 +106,6 @@ export const handleCheckboxChange = ( onUpdate?: (values: string[]) => void ) => { const currentValue = option.value; - // console.log(currentValue); - let newSelectedValues = [...selectedValues]; if (option.options.length > 0) { @@ -145,9 +127,6 @@ export const handleCheckboxChange = ( } } - console.log(newSelectedValues); - console.log(currentValue); - console.log(isChecked); // Handle the current checkbox itself (for leaf nodes or when toggling individual items) if (isChecked) { if (!newSelectedValues.includes(currentValue)) { @@ -157,7 +136,6 @@ export const handleCheckboxChange = ( newSelectedValues = newSelectedValues.filter( (value) => value !== currentValue ); - console.log(newSelectedValues); } onChange(newSelectedValues); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index fb65015047e..693c661a895 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -28,33 +28,27 @@ export const GridFormNestedCheckboxInput: React.FC< const flatOptions = useMemo(() => { const flattened = flattenOptions(optionsWithSpacing); - console.log('flatOptions computed:', flattened); return flattened; }, [optionsWithSpacing]); // Helper function to expand values to include descendants of selected parents const expandValues = React.useCallback( (values: string[]): string[] => { - console.log('expandValues called with:', values); const expandedValues = [...values]; // For each selected value, if it's a parent, add all its descendants values.forEach((selectedValue: string) => { const option = flatOptions.find((opt) => opt.value === selectedValue); - console.log(`Checking value ${selectedValue}:`, option); if (option && option.options.length > 0) { const allDescendants = getAllDescendants(selectedValue, flatOptions); - console.log(`Descendants for ${selectedValue}:`, allDescendants); allDescendants.forEach((descendantValue) => { if (!expandedValues.includes(descendantValue)) { expandedValues.push(descendantValue); - console.log(`Added descendant: ${descendantValue}`); } }); } }); - console.log('expandValues result:', expandedValues); return expandedValues; }, [flatOptions] @@ -75,11 +69,6 @@ export const GridFormNestedCheckboxInput: React.FC< // Get current form value const currentFormValue = fieldDefaultValue || []; - console.log('useEffect expansion check:', { - fieldName, - currentFormValue, - flatOptionsLength: flatOptions.length, - }); if (currentFormValue.length > 0) { const needsExpansion = currentFormValue.some((selectedValue: string) => { @@ -89,10 +78,6 @@ export const GridFormNestedCheckboxInput: React.FC< const hasAllDescendants = allDescendants.every((descendant) => currentFormValue.includes(descendant) ); - console.log( - `Value ${selectedValue} needs expansion:`, - !hasAllDescendants - ); return !hasAllDescendants; } return false; @@ -100,10 +85,6 @@ export const GridFormNestedCheckboxInput: React.FC< if (needsExpansion) { const expandedValues = expandValues(currentFormValue); - console.log('useEffect EXPANDING:', { - original: currentFormValue, - expanded: expandedValues, - }); // Use setValue to update the form state setValue(fieldName, expandedValues); @@ -124,11 +105,6 @@ export const GridFormNestedCheckboxInput: React.FC< { - console.log('GridForm render:', { - fieldName: field.name, - value, - }); - const states = calculateStates(value, flatOptions); return ( From 5108c8cc4c510b9f25a4d8b239496dba82b6d9f8 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 24 Sep 2025 17:29:47 -0400 Subject: [PATCH 15/44] fix defaultValue type issue --- packages/gamut/src/ConnectedForm/utils.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/utils.tsx b/packages/gamut/src/ConnectedForm/utils.tsx index 75d52876dca..95560991c7f 100644 --- a/packages/gamut/src/ConnectedForm/utils.tsx +++ b/packages/gamut/src/ConnectedForm/utils.tsx @@ -14,6 +14,7 @@ import { FieldError, FieldErrorsImpl, Merge, + Path, RegisterOptions, useFieldArray, useFormContext, @@ -44,8 +45,8 @@ interface UseConnectedFormProps< defaultValues: Values; validationRules: Partial; watchedFields?: { - fields: (keyof Values)[]; - watchHandler: (arg0: (keyof Values)[]) => void; + fields: Path[]; + watchHandler: (arg0: Path[]) => void; }; } @@ -159,9 +160,10 @@ export const useField = ({ name, disabled, loading }: useFieldProps) => { const { control, errors, - register, + getValues, isDisabled: formStateDisabled, isSoloField, + register, setError, setValue, validationRules, @@ -184,6 +186,7 @@ export const useField = ({ name, disabled, loading }: useFieldProps) => { return { control, error, + getValues, isDisabled, /** * Keep track of the first error in this form. From f67e05d33262eda54ea7188bf3a76760efc28c4e Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 24 Sep 2025 17:29:57 -0400 Subject: [PATCH 16/44] types refactor --- .../gamut/src/ConnectedForm/ConnectedInputs/types.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx index 11fd80a251d..87bf6f02138 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/types.tsx @@ -42,10 +42,7 @@ export interface ConnectedRadioProps export interface ConnectedBaseRadioGroupProps extends FieldComponent {} -export type ConnectedBaseRadioInputProps = Omit< - RadioProps, - 'defaultValue' | 'name' | 'validation' -> & { +export type ConnectedBaseRadioInputProps = FieldComponent & { label: ReactNode; value: string | number; }; @@ -61,11 +58,11 @@ export interface ConnectedRadioGroupInputProps } export interface ConnectedSelectProps - extends Omit, + extends FieldComponent, ConnectedFieldProps {} export interface ConnectedTextAreaProps - extends Omit, + extends FieldComponent, ConnectedFieldProps {} export type NestedConnectedCheckboxOption = Omit< From fea9287d78d5dc532a2ff675759aca1e91bf7353 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 24 Sep 2025 17:30:42 -0400 Subject: [PATCH 17/44] gridform default value --- .../GridFormNestedCheckboxInput/index.tsx | 92 +++++-------------- packages/gamut/src/GridForm/utils.ts | 2 + 2 files changed, 24 insertions(+), 70 deletions(-) diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index 693c661a895..aab0adf07dc 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { useEffect, useMemo, useRef } from 'react'; -import { Controller, useFormContext } from 'react-hook-form'; +import { useEffect, useMemo, useState } from 'react'; +import { Controller } from 'react-hook-form'; import { Box } from '../../..'; import { @@ -11,14 +11,16 @@ import { renderCheckbox, } from '../../../ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils'; import { BaseFormInputProps, GridFormNestedCheckboxField } from '../../types'; +import { GridFormInputGroupProps } from '..'; export interface GridFormNestedCheckboxInputProps extends BaseFormInputProps { field: GridFormNestedCheckboxField; + setValue: GridFormInputGroupProps['setValue']; } export const GridFormNestedCheckboxInput: React.FC< GridFormNestedCheckboxInputProps -> = ({ field, required, disabled, error }) => { +> = ({ field, required, disabled, error, setValue }) => { const isDisabled = disabled || field.disabled; const optionsWithSpacing = field.options.map((option) => ({ @@ -31,75 +33,25 @@ export const GridFormNestedCheckboxInput: React.FC< return flattened; }, [optionsWithSpacing]); - // Helper function to expand values to include descendants of selected parents - const expandValues = React.useCallback( - (values: string[]): string[] => { - const expandedValues = [...values]; + const [hasExpandedInitially, setHasExpandedInitially] = useState(false); - // For each selected value, if it's a parent, add all its descendants - values.forEach((selectedValue: string) => { - const option = flatOptions.find((opt) => opt.value === selectedValue); - if (option && option.options.length > 0) { - const allDescendants = getAllDescendants(selectedValue, flatOptions); - allDescendants.forEach((descendantValue) => { - if (!expandedValues.includes(descendantValue)) { - expandedValues.push(descendantValue); - } - }); - } - }); - - return expandedValues; - }, - [flatOptions] - ); - - // Track if we've done initial expansion - const hasExpandedInitially = useRef(false); - const { setValue } = useFormContext(); - - // Extract field properties for stable dependencies - const fieldName = field.name; - const fieldDefaultValue = field.defaultValue; - const fieldOnUpdate = field.onUpdate; - - // Handle expansion in useEffect instead of render function useEffect(() => { - if (hasExpandedInitially.current) return; - - // Get current form value - const currentFormValue = fieldDefaultValue || []; - - if (currentFormValue.length > 0) { - const needsExpansion = currentFormValue.some((selectedValue: string) => { - const option = flatOptions.find((opt) => opt.value === selectedValue); - if (option && option.options.length > 0) { - const allDescendants = getAllDescendants(selectedValue, flatOptions); - const hasAllDescendants = allDescendants.every((descendant) => - currentFormValue.includes(descendant) - ); - return !hasAllDescendants; - } - return false; - }); - - if (needsExpansion) { - const expandedValues = expandValues(currentFormValue); - - // Use setValue to update the form state - setValue(fieldName, expandedValues); - fieldOnUpdate?.(expandedValues); - hasExpandedInitially.current = true; - } - } - }, [ - fieldName, - fieldDefaultValue, - fieldOnUpdate, - flatOptions, - expandValues, - setValue, - ]); + if ( + hasExpandedInitially || + !field.defaultValue || + field.defaultValue.length === 0 + ) + return; + + const expandedValues = [...field.defaultValue]; + field.defaultValue.forEach((value) => + expandedValues.push(...getAllDescendants(value, flatOptions)) + ); + + setValue(field.name, expandedValues); + field.onUpdate?.(expandedValues); // do we want to do this? + setHasExpandedInitially(true); + }, [hasExpandedInitially, field, flatOptions, setValue]); return ( { case 'text': case 'textarea': return ''; + case 'nested-checkboxes': + return []; default: break; } From e2e3cc62d1b3a75de0ef15bf96a61b999bbe46b0 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 24 Sep 2025 17:31:01 -0400 Subject: [PATCH 18/44] connectedform default value --- .../ConnectedNestedCheckboxes/index.tsx | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx index a077d47be49..4192f5fcb0f 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Controller } from 'react-hook-form'; import { Box } from '../../..'; @@ -8,6 +8,7 @@ import { ConnectedNestedCheckboxesProps } from '../types'; import { calculateStates, flattenOptions, + getAllDescendants, handleCheckboxChange, renderCheckbox, } from './utils'; @@ -15,10 +16,13 @@ import { export const ConnectedNestedCheckboxes: React.FC< ConnectedNestedCheckboxesProps > = ({ name, options, disabled, onUpdate, spacing }) => { - const { isDisabled, control, validation, isRequired } = useField({ - name, - disabled, - }); + const { isDisabled, control, validation, isRequired, getValues, setValue } = + useField({ + name, + disabled, + }); + + const defaultValue: string[] = getValues()[name]; const optionsWithSpacing = options.map((option) => ({ ...option, @@ -30,10 +34,32 @@ export const ConnectedNestedCheckboxes: React.FC< [optionsWithSpacing] ); + const [hasExpandedInitially, setHasExpandedInitially] = useState(false); + + useEffect(() => { + if (hasExpandedInitially || !defaultValue || defaultValue.length === 0) + return; + + const expandedValues = [...defaultValue]; + defaultValue.forEach((value) => + expandedValues.push(...getAllDescendants(value, flatOptions)) + ); + + setValue(name, expandedValues); + onUpdate?.(expandedValues); // do we want to do this? + setHasExpandedInitially(true); + }, [ + hasExpandedInitially, + flatOptions, + setValue, + defaultValue, + name, + onUpdate, + ]); + return ( ( From 8c65ca0a5bcc5c4092f7e3a3573f8286fa5877c2 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 25 Sep 2025 15:44:11 -0400 Subject: [PATCH 19/44] update connectedform --- .../ConnectedNestedCheckboxes/index.tsx | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx index 4192f5fcb0f..187a406fa33 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -61,33 +61,35 @@ export const ConnectedNestedCheckboxes: React.FC< ( - - {flatOptions.map((option) => { - const states = calculateStates(value, flatOptions); - const state = states.get(option.value)!; - return renderCheckbox( - option, - state, - `${name}-${option.value}`, - isRequired, - isDisabled, - onBlur, - (event) => { - handleCheckboxChange( - option, - event.target.checked, - value, - flatOptions, - onChange, - onUpdate - ); - }, - ref - ); - })} - - )} + render={({ field: { value = [], onChange, onBlur, ref } }) => { + const states = calculateStates(value, flatOptions); + return ( + + {flatOptions.map((option) => { + const state = states.get(option.value)!; + return renderCheckbox( + option, + state, + `${name}-${option.value}`, + isRequired, + isDisabled, + onBlur, + (event) => { + handleCheckboxChange( + option, + event.target.checked, + value, + flatOptions, + onChange, + onUpdate + ); + }, + ref + ); + })} + + ); + }} rules={validation} /> ); From d6b147e5c56080cbf62acb60627bd018e822b463 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 25 Sep 2025 15:44:16 -0400 Subject: [PATCH 20/44] add passing tests --- .../ConnectedNestedCheckboxes.test.tsx | 97 +-- .../__tests__/utils.test.tsx | 16 +- .../GridFormNestedCheckboxInput.test.tsx | 579 ++++++++++++++++-- 3 files changed, 573 insertions(+), 119 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx index d27c226a604..2eb8c9ff26b 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -1,6 +1,7 @@ import { setupRtl } from '@codecademy/gamut-tests'; import { fireEvent } from '@testing-library/dom'; import { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ConnectedForm, ConnectedFormGroup, SubmitButton } from '../../..'; import { NestedConnectedCheckboxOption } from '../../types'; @@ -43,7 +44,13 @@ const TestForm: React.FC<{ defaultValues?: { skills?: string[] }; validationRules?: any; disabled?: boolean; -}> = ({ defaultValues = {}, validationRules, disabled }) => ( + options?: NestedConnectedCheckboxOption[]; +}> = ({ + defaultValues = {}, + validationRules, + disabled, + options = mockOptions, +}) => ( { const expressCheckbox = view.getByLabelText('Express.js').closest('li'); // Check margin-left styles for indentation - expect(frontendCheckbox).toHaveStyle({ marginLeft: '0px' }); // level 0 - expect(reactCheckbox).toHaveStyle({ marginLeft: '24px' }); // level 1 - expect(nodeCheckbox).toHaveStyle({ marginLeft: '24px' }); // level 1 - expect(expressCheckbox).toHaveStyle({ marginLeft: '48px' }); // level 2 + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); // level 0 + expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 + expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 + expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); // level 2 }); it('should render with unique IDs for each checkbox', () => { @@ -137,7 +144,7 @@ describe('ConnectedNestedCheckboxes', () => { it('should render parent as indeterminate when some children are selected', () => { const { view } = renderView({ - defaultValues: { skills: ['react', 'vue'] }, // only some frontend + defaultValues: { skills: ['react', 'vue'] }, }); const frontendCheckbox = view.getByLabelText( @@ -159,7 +166,7 @@ describe('ConnectedNestedCheckboxes', () => { it('should render deeply nested parent states correctly', () => { const { view } = renderView({ - defaultValues: { skills: ['express', 'fastify'] }, // all node children + defaultValues: { skills: ['express', 'fastify'] }, }); const nodeCheckbox = view.getByLabelText('Node.js'); @@ -187,6 +194,16 @@ describe('ConnectedNestedCheckboxes', () => { // Deeply nested children should also be checked expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); + + // onUpdate should have been called with all expanded values during initialization + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'backend', + 'node', + 'express', + 'fastify', + 'python', + 'ruby', + ]); }); it('should allow unchecking children that were auto-checked by default parent selection', async () => { @@ -373,13 +390,15 @@ describe('ConnectedNestedCheckboxes', () => { }); it('should not respond to clicks when disabled', async () => { - const { view } = renderView({ disabled: true }); + const { view } = renderView({ + disabled: true, + }); const reactCheckbox = view.getByLabelText('React'); + expect(reactCheckbox).toBeDisabled(); + expect(reactCheckbox).not.toBeChecked(); - await act(async () => { - fireEvent.click(reactCheckbox); - }); + await userEvent.click(reactCheckbox); expect(reactCheckbox).not.toBeChecked(); expect(mockOnUpdate).not.toHaveBeenCalled(); @@ -432,13 +451,7 @@ describe('ConnectedNestedCheckboxes', () => { describe('edge cases', () => { it('should handle empty options array', () => { - const TestFormEmpty = () => ( - - - - ); - - const { view } = setupRtl(TestFormEmpty, {})({}); + const { view } = renderView({ options: [] }); // Should render empty list const list = view.container.querySelector('ul'); @@ -447,39 +460,27 @@ describe('ConnectedNestedCheckboxes', () => { }); it('should handle options without nested children', () => { - const flatOptions: NestedConnectedCheckboxOption[] = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, - ]; - - const TestFormFlat = () => ( - - - - ); - - const { view } = setupRtl(TestFormFlat, {})({}); + const { view } = renderView({ + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ], + }); expect(view.getByLabelText('Option 1')).toBeInTheDocument(); expect(view.getByLabelText('Option 2')).toBeInTheDocument(); }); it('should handle numeric values correctly', () => { - const numericOptions: NestedConnectedCheckboxOption[] = [ - { - value: 1, - label: 'Parent Option', - options: [{ value: 2, label: 'Child Option' }], - } as any, // Type assertion for testing numeric values - ]; - - const TestFormNumeric = () => ( - - - - ); - - const { view } = setupRtl(TestFormNumeric, {})({}); + const { view } = renderView({ + options: [ + { + value: 1, + label: 'Parent Option', + options: [{ value: 2, label: 'Child Option' }], + } as any, // Type assertion for testing numeric values + ], + }); expect(view.getByLabelText('Parent Option')).toHaveAttribute( 'id', @@ -518,8 +519,8 @@ describe('ConnectedNestedCheckboxes', () => { const list = view.container.querySelector('ul'); const listItems = view.container.querySelectorAll('li'); - expect(list).toHaveAttribute('role', 'list'); - expect(listItems).toHaveLength(8); // Total flattened options + expect(list).toBeInTheDocument(); + expect(listItems).toHaveLength(11); // Total flattened options listItems.forEach((item) => { expect(item).toHaveStyle({ listStyle: 'none' }); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx index e6b9ff13beb..573c620e02c 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx @@ -51,8 +51,6 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = flattenOptions(options); expect(result).toHaveLength(4); - - // Parent 1 expect(result[0]).toMatchObject({ value: 'parent1', label: 'Parent 1', @@ -60,8 +58,6 @@ describe('ConnectedNestedCheckboxes utils', () => { parentValue: undefined, options: ['child1', 'child2'], }); - - // Child 1 expect(result[1]).toMatchObject({ value: 'child1', label: 'Child 1', @@ -69,8 +65,6 @@ describe('ConnectedNestedCheckboxes utils', () => { parentValue: 'parent1', options: [], }); - - // Child 2 expect(result[2]).toMatchObject({ value: 'child2', label: 'Child 2', @@ -78,8 +72,6 @@ describe('ConnectedNestedCheckboxes utils', () => { parentValue: 'parent1', options: [], }); - - // Parent 2 expect(result[3]).toMatchObject({ value: 'parent2', label: 'Parent 2', @@ -177,7 +169,7 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should get all direct and indirect descendants', () => { const result = getAllDescendants('parent', flatOptions); - expect(result).toEqual(['child1', 'child2', 'grandchild1']); + expect(result).toEqual(['child1', 'grandchild1', 'child2']); }); it('should get only direct descendants when no grandchildren exist', () => { @@ -290,10 +282,10 @@ describe('ConnectedNestedCheckboxes utils', () => { const states = calculateStates(selectedValues, flatOptions); const child1State = states.get('child1'); - expect(child1State).toEqual({ checked: true }); // all descendants (grandchild1) are selected + expect(child1State).toEqual({ checked: true }); const parent1State = states.get('parent1'); - expect(parent1State).toEqual({ checked: false, indeterminate: true }); // only some descendants selected + expect(parent1State).toEqual({ checked: false, indeterminate: true }); }); it('should handle empty selected values', () => { @@ -650,7 +642,7 @@ describe('ConnectedNestedCheckboxes utils', () => { }; const result = renderCheckbox( - optionWithElementLabel, + optionWithElementLabel as any, // ts should prevent this from ever happening but we have a default just in case state, 'test-id', false, diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx index 71ed3813605..0aff9879f3e 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx @@ -1,42 +1,138 @@ import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent } from '@testing-library/dom'; import { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; -import { GridFormNestedCheckboxInput } from '../index'; - -const mockNestedCheckboxField = { - component: 'nested-checkboxes' as const, - name: 'technologies', - label: 'Technologies', - options: [ - { - value: 'frontend', - label: 'Frontend Technologies', - options: [ - { value: 'react', label: 'React' }, - { value: 'vue', label: 'Vue.js' }, - { value: 'angular', label: 'Angular' }, - ], - }, - { - value: 'backend', - label: 'Backend Technologies', - options: [ - { value: 'node', label: 'Node.js' }, - { value: 'python', label: 'Python' }, - { value: 'java', label: 'Java' }, - ], - }, - ], -}; - -const renderComponent = setupRtl(GridFormNestedCheckboxInput, { - field: mockNestedCheckboxField, -}); +import { GridForm } from '../../../GridForm'; +import type { NestedGridFormCheckboxOption } from '../../../types'; + +const mockOptions: NestedGridFormCheckboxOption[] = [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue.js' }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { + value: 'node', + label: 'Node.js', + options: [ + { value: 'express', label: 'Express.js' }, + { value: 'fastify', label: 'Fastify' }, + ], + }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java', disabled: true }, + ], + }, + { + value: 'databases', + label: 'Databases', + }, +]; + +const mockOnUpdate = jest.fn(); +const TestForm: React.FC<{ + defaultValue?: string[]; + disabled?: boolean; + customError?: string; + options?: NestedGridFormCheckboxOption[]; +}> = ({ defaultValue = [], disabled, customError, options = mockOptions }) => ( + +); + +const renderView = setupRtl(TestForm, {}); describe('GridFormNestedCheckboxInput', () => { + describe('rendering', () => { + it('should render all checkbox options in a flat list', () => { + const { view } = renderView(); + + // Top-level options + view.getByLabelText('Frontend Technologies'); + view.getByLabelText('Backend Technologies'); + view.getByLabelText('Databases'); + + // Frontend children + view.getByLabelText('React'); + view.getByLabelText('Vue.js'); + view.getByLabelText('Angular'); + + // Backend children + view.getByLabelText('Node.js'); + view.getByLabelText('Python'); + + // Deeply nested options + view.getByLabelText('Express.js'); + view.getByLabelText('Fastify'); + }); + + it('should render checkboxes with proper indentation levels', () => { + const { view } = renderView(); + + const frontendCheckbox = view + .getByLabelText('Frontend Technologies') + .closest('li'); + const reactCheckbox = view.getByLabelText('React').closest('li'); + const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); + const expressCheckbox = view.getByLabelText('Express.js').closest('li'); + + // Check margin-left styles for indentation + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); // level 0 + expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 + expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 + expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); // level 2 + }); + + it('should render with unique IDs for each checkbox', () => { + const { view } = renderView(); + + expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( + 'id', + 'technologies-frontend' + ); + expect(view.getByLabelText('React')).toHaveAttribute( + 'id', + 'technologies-react' + ); + expect(view.getByLabelText('Express.js')).toHaveAttribute( + 'id', + 'technologies-express' + ); + }); + }); + describe('default values', () => { - it('should render with basic options unchecked by default', () => { - const { view } = renderComponent(); + it('should render with options unchecked by default', () => { + const { view } = renderView(); expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); expect(view.getByLabelText('Backend Technologies')).not.toBeChecked(); @@ -46,17 +142,54 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Python')).not.toBeChecked(); }); - it('should automatically check all children when parent is in default values', async () => { - const fieldWithDefaults = { - ...mockNestedCheckboxField, - defaultValue: ['backend'], // Parent selected by default - }; + it('should render with default values checked', () => { + const { view } = renderView({ + defaultValue: ['react', 'python'], + }); - const { view } = renderComponent({ field: fieldWithDefaults }); + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + }); - // Wait for the expansion to happen (setTimeout in the component) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); + it('should render parent as indeterminate when some children are selected', () => { + const { view } = renderView({ + defaultValue: ['react', 'vue'], + }); + + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + + expect(frontendCheckbox.indeterminate).toBe(true); + expect(frontendCheckbox).not.toBeChecked(); + }); + + it('should render parent as checked when all children are selected', () => { + const { view } = renderView({ + defaultValue: ['react', 'vue', 'angular'], + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + expect(frontendCheckbox).toBeChecked(); + }); + + it('should render deeply nested parent states correctly', () => { + const { view } = renderView({ + defaultValue: ['express', 'fastify'], + }); + const nodeCheckbox = view.getByLabelText('Node.js'); + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + + expect(nodeCheckbox).toBeChecked(); // all children selected + expect(backendCheckbox.indeterminate).toBe(true); // only some children selected + }); + + it('should automatically check all children when parent is in default values', async () => { + const { view } = renderView({ + defaultValue: ['backend'], }); // Parent should be checked @@ -68,23 +201,29 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Python')).toBeChecked(); expect(view.getByLabelText('Java')).toBeChecked(); + // Deeply nested children should also be checked + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + // Frontend should remain unchecked expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); expect(view.getByLabelText('React')).not.toBeChecked(); expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + + // onUpdate should also have been called with all expanded values + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'backend', + 'node', + 'express', + 'fastify', + 'python', + 'java', + ]); }); it('should handle multiple parent defaults correctly', async () => { - const fieldWithDefaults = { - ...mockNestedCheckboxField, - defaultValue: ['frontend', 'backend'], // Both parents selected by default - }; - - const { view } = renderComponent({ field: fieldWithDefaults }); - - // Wait for the expansion to happen - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); + const { view } = renderView({ + defaultValue: ['frontend', 'backend'], }); // Both parents should be checked @@ -98,16 +237,14 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); expect(view.getByLabelText('Java')).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); }); it('should preserve individual child selections in default values', () => { - const fieldWithDefaults = { - ...mockNestedCheckboxField, - defaultValue: ['react', 'python'], // Individual children selected - }; - - const { view } = renderComponent({ field: fieldWithDefaults }); - + const { view } = renderView({ + defaultValue: ['react', 'python'], + }); // Selected children should be checked expect(view.getByLabelText('React')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); @@ -128,6 +265,330 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Angular')).not.toBeChecked(); expect(view.getByLabelText('Node.js')).not.toBeChecked(); expect(view.getByLabelText('Java')).not.toBeChecked(); + expect(view.getByLabelText('Express.js')).not.toBeChecked(); + expect(view.getByLabelText('Fastify')).not.toBeChecked(); + }); + + it('should allow unchecking children that were auto-checked by default parent selection', async () => { + const { view } = renderView({ + defaultValue: ['backend'], + }); + + // Initially all should be checked due to parent selection + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + const pythonCheckbox = view.getByLabelText('Python'); + + expect(backendCheckbox).toBeChecked(); + expect(pythonCheckbox).toBeChecked(); + + // User should be able to uncheck a child + await act(async () => { + fireEvent.click(pythonCheckbox); + }); + + // Python should now be unchecked + expect(pythonCheckbox).not.toBeChecked(); + + // Parent should now be indeterminate since not all children are checked + expect(backendCheckbox.indeterminate).toBe(true); + expect(backendCheckbox).not.toBeChecked(); + + // Other children should remain checked + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + }); + }); + + describe('user interactions', () => { + it('should update form value when leaf checkbox is clicked', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).toBeChecked(); + + // Verify parent state updates + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + expect(frontendCheckbox.indeterminate).toBe(true); + }); + + it('should select all children when parent checkbox is clicked', async () => { + const { view } = renderView(); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(frontendCheckbox).toBeChecked(); + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Vue.js')).toBeChecked(); + expect(view.getByLabelText('Angular')).toBeChecked(); + + // Verify onUpdate was called with correct values + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); + }); + + it('should deselect all children when checked parent is clicked', async () => { + const { view } = renderView({ + defaultValue: ['react', 'vue', 'angular'], + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + expect(frontendCheckbox).toBeChecked(); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(frontendCheckbox).not.toBeChecked(); + expect(view.getByLabelText('React')).not.toBeChecked(); + expect(view.getByLabelText('Vue.js')).not.toBeChecked(); + expect(view.getByLabelText('Angular')).not.toBeChecked(); + }); + + it('should handle deeply nested selections correctly', async () => { + const { view } = renderView(); + + // Click Node.js parent (should select all its children) + const nodeCheckbox = view.getByLabelText('Node.js'); + + await act(async () => { + fireEvent.click(nodeCheckbox); + }); + + expect(nodeCheckbox).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + + // Backend should be indeterminate (only Node.js selected, not Python) + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + expect(backendCheckbox.indeterminate).toBe(true); + + // Verify onUpdate was called with correct values + expect(mockOnUpdate).toHaveBeenCalledWith(['express', 'fastify', 'node']); + }); + + it('should handle individual child deselection affecting parent state', async () => { + const { view } = renderView({ + defaultValue: ['react', 'vue', 'angular'], + }); + + // Frontend should be fully checked + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + expect(frontendCheckbox).toBeChecked(); + + // Deselect one child + const reactCheckbox = view.getByLabelText('React'); + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(reactCheckbox).not.toBeChecked(); + expect(frontendCheckbox.indeterminate).toBe(true); + expect(frontendCheckbox).not.toBeChecked(); + }); + }); + + describe('onUpdate callback', () => { + it('should call onUpdate when checkbox values change', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith(['react']); + }); + + it('should call onUpdate with correct values when parent is selected', async () => { + const { view } = renderView(); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + await act(async () => { + fireEvent.click(frontendCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); + }); + + it('should call onUpdate with empty array when all items are deselected', async () => { + const { view } = renderView({ + defaultValue: ['react'], + }); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + expect(mockOnUpdate).toHaveBeenCalledWith([]); + }); + }); + + describe('disabled state', () => { + it('should render all checkboxes as disabled when disabled prop is true', () => { + const { view } = renderView({ disabled: true }); + + expect(view.getByLabelText('Frontend Technologies')).toBeDisabled(); + expect(view.getByLabelText('React')).toBeDisabled(); + expect(view.getByLabelText('Databases')).toBeDisabled(); + }); + + it('should not respond to clicks when disabled', async () => { + const { view } = renderView({ disabled: true }); + + const reactCheckbox = view.getByLabelText('React'); + expect(reactCheckbox).toBeDisabled(); + expect(reactCheckbox).not.toBeChecked(); + + await userEvent.click(reactCheckbox); + + expect(reactCheckbox).not.toBeChecked(); + expect(mockOnUpdate).not.toHaveBeenCalled(); + }); + + it('should handle individual option disabled state', () => { + const { view } = renderView(); + + expect(view.getByLabelText('Java')).toBeDisabled(); + expect(view.getByLabelText('Vue.js')).not.toBeDisabled(); + }); + }); + + describe('validation', () => { + it('should handle required validation', async () => { + const { view } = renderView(); + + // Check if checkboxes have required attribute + expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( + 'aria-required', + 'true' + ); + expect(view.getByLabelText('React')).toHaveAttribute( + 'aria-required', + 'true' + ); + }); + + it('should display error state when error prop is passed', () => { + const { view } = renderView({ + customError: 'Please select at least one option', + }); + + const checkboxes = view.container.querySelectorAll( + 'input[type="checkbox"]' + ); + checkboxes.forEach((checkbox) => { + expect(checkbox).toHaveAttribute('aria-invalid', 'true'); + }); + }); + }); + + describe('accessibility', () => { + it('should have proper aria attributes', () => { + const { view } = renderView(); + + const checkbox = view.getByLabelText('Frontend Technologies'); + + expect(checkbox).toHaveAttribute('aria-label', 'Frontend Technologies'); + expect(checkbox).toHaveAttribute('aria-checked', 'false'); + }); + + it('should have proper aria-checked states for indeterminate checkboxes', () => { + const { view } = renderView({ + defaultValue: ['react'], + }); + + const frontendCheckbox = view.getByLabelText('Frontend Technologies'); + + expect(frontendCheckbox).toHaveAttribute('aria-checked', 'mixed'); + }); + + it('should use proper list semantics', () => { + const { view } = renderView(); + + const list = view.container.querySelector('ul'); + const listItems = view.container.querySelectorAll('li'); + + expect(list).toBeInTheDocument(); + expect(listItems).toHaveLength(11); // Total flattened options + + listItems.forEach((item) => { + expect(item).toHaveStyle({ listStyle: 'none' }); + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty options array', () => { + const { view } = renderView({ options: [] }); + + // Should render empty list + const list = view.container.querySelector('ul'); + expect(list).toBeInTheDocument(); + expect(list?.children).toHaveLength(0); + }); + + it('should handle options without nested children', () => { + const { view } = renderView({ + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ], + }); + + expect(view.getByLabelText('Option 1')).toBeInTheDocument(); + expect(view.getByLabelText('Option 2')).toBeInTheDocument(); + }); + + it('should handle numeric values correctly', () => { + const { view } = renderView({ + options: [ + { + value: 1, + label: 'Parent Option', + options: [{ value: 2, label: 'Child Option' }], + } as any, // Type assertion for testing numeric values + ], + }); + + expect(view.getByLabelText('Parent Option')).toHaveAttribute( + 'id', + 'technologies-1' + ); + expect(view.getByLabelText('Child Option')).toHaveAttribute( + 'id', + 'technologies-2' + ); }); }); }); From 569443903ff2ac8554dcb811ca77964eec5303c4 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 26 Sep 2025 14:39:30 -0400 Subject: [PATCH 21/44] clean up tests --- .../ConnectedNestedCheckboxes.test.tsx | 114 +++++++++++------- .../GridFormNestedCheckboxInput.test.tsx | 54 ++------- 2 files changed, 82 insertions(+), 86 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx index 2eb8c9ff26b..ea3d7c90438 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -77,21 +77,14 @@ describe('ConnectedNestedCheckboxes', () => { it('should render all checkbox options in a flat list', () => { const { view } = renderView(); - // Top-level options view.getByLabelText('Frontend Technologies'); view.getByLabelText('Backend Technologies'); view.getByLabelText('Databases'); - - // Frontend children view.getByLabelText('React'); view.getByLabelText('Vue'); view.getByLabelText('Angular'); - - // Backend children view.getByLabelText('Node.js'); view.getByLabelText('Python'); - - // Deeply nested options view.getByLabelText('Express.js'); view.getByLabelText('Fastify'); }); @@ -106,11 +99,10 @@ describe('ConnectedNestedCheckboxes', () => { const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); const expressCheckbox = view.getByLabelText('Express.js').closest('li'); - // Check margin-left styles for indentation - expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); // level 0 - expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 - expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 - expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); // level 2 + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); + expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); + expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); + expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); }); it('should render with unique IDs for each checkbox', () => { @@ -157,7 +149,7 @@ describe('ConnectedNestedCheckboxes', () => { it('should render parent as checked when all children are selected', () => { const { view } = renderView({ - defaultValues: { skills: ['react', 'vue', 'angular'] }, // all frontend + defaultValues: { skills: ['react', 'vue', 'angular'] }, }); const frontendCheckbox = view.getByLabelText('Frontend Technologies'); @@ -169,33 +161,30 @@ describe('ConnectedNestedCheckboxes', () => { defaultValues: { skills: ['express', 'fastify'] }, }); - const nodeCheckbox = view.getByLabelText('Node.js'); + const nodeCheckbox = view.getByLabelText('Node.js') as HTMLInputElement; const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; - expect(nodeCheckbox).toBeChecked(); // all children selected - expect(backendCheckbox.indeterminate).toBe(true); // only some children selected + expect(nodeCheckbox).toBeChecked(); + expect(nodeCheckbox.indeterminate).toBe(false); + expect(backendCheckbox).not.toBeChecked(); + expect(backendCheckbox.indeterminate).toBe(true); }); it('should automatically check all children when parent is in default values', () => { const { view } = renderView({ - defaultValues: { skills: ['backend'] }, // parent selected by default + defaultValues: { skills: ['backend'] }, }); - // Parent should be checked const backendCheckbox = view.getByLabelText('Backend Technologies'); expect(backendCheckbox).toBeChecked(); - // All children should be automatically checked expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); - - // Deeply nested children should also be checked expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - // onUpdate should have been called with all expanded values during initialization expect(mockOnUpdate).toHaveBeenCalledWith([ 'backend', 'node', @@ -206,12 +195,55 @@ describe('ConnectedNestedCheckboxes', () => { ]); }); + it('should handle multiple parent defaults correctly', async () => { + const { view } = renderView({ + defaultValues: { skills: ['frontend', 'backend'] }, + }); + + expect(view.getByLabelText('Frontend Technologies')).toBeChecked(); + expect(view.getByLabelText('Backend Technologies')).toBeChecked(); + + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Vue')).toBeChecked(); + expect(view.getByLabelText('Angular')).toBeChecked(); + expect(view.getByLabelText('Node.js')).toBeChecked(); + expect(view.getByLabelText('Express.js')).toBeChecked(); + expect(view.getByLabelText('Fastify')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + expect(view.getByLabelText('Ruby')).toBeChecked(); + }); + + it('should preserve individual child selections in default values', () => { + const { view } = renderView({ + defaultValues: { skills: ['react', 'python'] }, + }); + + expect(view.getByLabelText('React')).toBeChecked(); + expect(view.getByLabelText('Python')).toBeChecked(); + + const frontendCheckbox = view.getByLabelText( + 'Frontend Technologies' + ) as HTMLInputElement; + const backendCheckbox = view.getByLabelText( + 'Backend Technologies' + ) as HTMLInputElement; + + expect(frontendCheckbox.indeterminate).toBe(true); + expect(backendCheckbox.indeterminate).toBe(true); + + expect(view.getByLabelText('Vue')).not.toBeChecked(); + expect(view.getByLabelText('Angular')).not.toBeChecked(); + expect(view.getByLabelText('Node.js')).not.toBeChecked(); + expect(view.getByLabelText('Express.js')).not.toBeChecked(); + expect(view.getByLabelText('Fastify')).not.toBeChecked(); + expect(view.getByLabelText('Ruby')).not.toBeChecked(); + }); + it('should allow unchecking children that were auto-checked by default parent selection', async () => { const { view } = renderView({ - defaultValues: { skills: ['backend'] }, // parent selected by default + defaultValues: { skills: ['backend'] }, }); - // Initially all should be checked due to parent selection const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; @@ -220,19 +252,15 @@ describe('ConnectedNestedCheckboxes', () => { expect(backendCheckbox).toBeChecked(); expect(pythonCheckbox).toBeChecked(); - // User should be able to uncheck a child await act(async () => { fireEvent.click(pythonCheckbox); }); - // Python should now be unchecked expect(pythonCheckbox).not.toBeChecked(); - // Parent should now be indeterminate since not all children are checked expect(backendCheckbox.indeterminate).toBe(true); expect(backendCheckbox).not.toBeChecked(); - // Other children should remain checked expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); @@ -251,7 +279,6 @@ describe('ConnectedNestedCheckboxes', () => { expect(reactCheckbox).toBeChecked(); - // Verify parent state updates const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; @@ -259,7 +286,7 @@ describe('ConnectedNestedCheckboxes', () => { }); it('should select all children when parent checkbox is clicked', async () => { - const { view } = renderView({}); + const { view } = renderView(); const frontendCheckbox = view.getByLabelText('Frontend Technologies'); @@ -271,6 +298,13 @@ describe('ConnectedNestedCheckboxes', () => { expect(view.getByLabelText('React')).toBeChecked(); expect(view.getByLabelText('Vue')).toBeChecked(); expect(view.getByLabelText('Angular')).toBeChecked(); + + expect(mockOnUpdate).toHaveBeenCalledWith([ + 'react', + 'vue', + 'angular', + 'frontend', + ]); }); it('should deselect all children when checked parent is clicked', async () => { @@ -294,7 +328,6 @@ describe('ConnectedNestedCheckboxes', () => { it('should handle deeply nested selections correctly', async () => { const { view } = renderView(); - // Click Node.js parent (should select all its children) const nodeCheckbox = view.getByLabelText('Node.js'); await act(async () => { @@ -305,11 +338,12 @@ describe('ConnectedNestedCheckboxes', () => { expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - // Backend should be indeterminate (only Node.js selected, not Python) const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; expect(backendCheckbox.indeterminate).toBe(true); + + expect(mockOnUpdate).toHaveBeenCalledWith(['express', 'fastify', 'node']); }); it('should handle individual child deselection affecting parent state', async () => { @@ -317,13 +351,11 @@ describe('ConnectedNestedCheckboxes', () => { defaultValues: { skills: ['react', 'vue', 'angular'] }, }); - // Frontend should be fully checked const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; expect(frontendCheckbox).toBeChecked(); - // Deselect one child const reactCheckbox = view.getByLabelText('React'); await act(async () => { fireEvent.click(reactCheckbox); @@ -420,11 +452,6 @@ describe('ConnectedNestedCheckboxes', () => { const { view } = renderView({ validationRules }); - await act(async () => { - fireEvent.click(view.getByRole('button')); - }); - - // Check if checkboxes have required attribute expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( 'aria-required', 'true' @@ -453,7 +480,6 @@ describe('ConnectedNestedCheckboxes', () => { it('should handle empty options array', () => { const { view } = renderView({ options: [] }); - // Should render empty list const list = view.container.querySelector('ul'); expect(list).toBeInTheDocument(); expect(list?.children).toHaveLength(0); @@ -467,8 +493,8 @@ describe('ConnectedNestedCheckboxes', () => { ], }); - expect(view.getByLabelText('Option 1')).toBeInTheDocument(); - expect(view.getByLabelText('Option 2')).toBeInTheDocument(); + view.getByLabelText('Option 1'); + view.getByLabelText('Option 2'); }); it('should handle numeric values correctly', () => { @@ -495,7 +521,7 @@ describe('ConnectedNestedCheckboxes', () => { describe('accessibility', () => { it('should have proper aria attributes', () => { - const { view } = renderView({}); + const { view } = renderView(); const checkbox = view.getByLabelText('Frontend Technologies'); @@ -505,7 +531,7 @@ describe('ConnectedNestedCheckboxes', () => { it('should have proper aria-checked states for indeterminate checkboxes', () => { const { view } = renderView({ - defaultValues: { skills: ['react'] }, // partial selection + defaultValues: { skills: ['react'] }, }); const frontendCheckbox = view.getByLabelText('Frontend Technologies'); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx index 0aff9879f3e..7963a725886 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx @@ -76,21 +76,14 @@ describe('GridFormNestedCheckboxInput', () => { it('should render all checkbox options in a flat list', () => { const { view } = renderView(); - // Top-level options view.getByLabelText('Frontend Technologies'); view.getByLabelText('Backend Technologies'); view.getByLabelText('Databases'); - - // Frontend children view.getByLabelText('React'); view.getByLabelText('Vue.js'); view.getByLabelText('Angular'); - - // Backend children view.getByLabelText('Node.js'); view.getByLabelText('Python'); - - // Deeply nested options view.getByLabelText('Express.js'); view.getByLabelText('Fastify'); }); @@ -105,11 +98,10 @@ describe('GridFormNestedCheckboxInput', () => { const nodeCheckbox = view.getByLabelText('Node.js').closest('li'); const expressCheckbox = view.getByLabelText('Express.js').closest('li'); - // Check margin-left styles for indentation - expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); // level 0 - expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 - expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); // level 1 - expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); // level 2 + expect(frontendCheckbox).toHaveStyle({ marginLeft: '0' }); + expect(reactCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); + expect(nodeCheckbox).toHaveStyle({ marginLeft: '1.5rem' }); + expect(expressCheckbox).toHaveStyle({ marginLeft: '3rem' }); }); it('should render with unique IDs for each checkbox', () => { @@ -178,13 +170,15 @@ describe('GridFormNestedCheckboxInput', () => { const { view } = renderView({ defaultValue: ['express', 'fastify'], }); - const nodeCheckbox = view.getByLabelText('Node.js'); + const nodeCheckbox = view.getByLabelText('Node.js') as HTMLInputElement; const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; - expect(nodeCheckbox).toBeChecked(); // all children selected - expect(backendCheckbox.indeterminate).toBe(true); // only some children selected + expect(nodeCheckbox).toBeChecked(); + expect(nodeCheckbox.indeterminate).toBe(false); + expect(backendCheckbox).not.toBeChecked(); + expect(backendCheckbox.indeterminate).toBe(true); }); it('should automatically check all children when parent is in default values', async () => { @@ -192,25 +186,19 @@ describe('GridFormNestedCheckboxInput', () => { defaultValue: ['backend'], }); - // Parent should be checked const backendCheckbox = view.getByLabelText('Backend Technologies'); expect(backendCheckbox).toBeChecked(); - // All children should be automatically checked expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); expect(view.getByLabelText('Java')).toBeChecked(); - - // Deeply nested children should also be checked expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - // Frontend should remain unchecked expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); expect(view.getByLabelText('React')).not.toBeChecked(); expect(view.getByLabelText('Vue.js')).not.toBeChecked(); - // onUpdate should also have been called with all expanded values expect(mockOnUpdate).toHaveBeenCalledWith([ 'backend', 'node', @@ -226,11 +214,9 @@ describe('GridFormNestedCheckboxInput', () => { defaultValue: ['frontend', 'backend'], }); - // Both parents should be checked expect(view.getByLabelText('Frontend Technologies')).toBeChecked(); expect(view.getByLabelText('Backend Technologies')).toBeChecked(); - // All children should be automatically checked expect(view.getByLabelText('React')).toBeChecked(); expect(view.getByLabelText('Vue.js')).toBeChecked(); expect(view.getByLabelText('Angular')).toBeChecked(); @@ -245,11 +231,10 @@ describe('GridFormNestedCheckboxInput', () => { const { view } = renderView({ defaultValue: ['react', 'python'], }); - // Selected children should be checked + expect(view.getByLabelText('React')).toBeChecked(); expect(view.getByLabelText('Python')).toBeChecked(); - // Parents should be indeterminate since not all children are selected const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; @@ -260,7 +245,6 @@ describe('GridFormNestedCheckboxInput', () => { expect(frontendCheckbox.indeterminate).toBe(true); expect(backendCheckbox.indeterminate).toBe(true); - // Other children should remain unchecked expect(view.getByLabelText('Vue.js')).not.toBeChecked(); expect(view.getByLabelText('Angular')).not.toBeChecked(); expect(view.getByLabelText('Node.js')).not.toBeChecked(); @@ -274,7 +258,6 @@ describe('GridFormNestedCheckboxInput', () => { defaultValue: ['backend'], }); - // Initially all should be checked due to parent selection const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; @@ -283,19 +266,15 @@ describe('GridFormNestedCheckboxInput', () => { expect(backendCheckbox).toBeChecked(); expect(pythonCheckbox).toBeChecked(); - // User should be able to uncheck a child await act(async () => { fireEvent.click(pythonCheckbox); }); - // Python should now be unchecked expect(pythonCheckbox).not.toBeChecked(); - // Parent should now be indeterminate since not all children are checked expect(backendCheckbox.indeterminate).toBe(true); expect(backendCheckbox).not.toBeChecked(); - // Other children should remain checked expect(view.getByLabelText('Node.js')).toBeChecked(); expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); @@ -314,7 +293,6 @@ describe('GridFormNestedCheckboxInput', () => { expect(reactCheckbox).toBeChecked(); - // Verify parent state updates const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; @@ -335,7 +313,6 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Vue.js')).toBeChecked(); expect(view.getByLabelText('Angular')).toBeChecked(); - // Verify onUpdate was called with correct values expect(mockOnUpdate).toHaveBeenCalledWith([ 'react', 'vue', @@ -365,7 +342,6 @@ describe('GridFormNestedCheckboxInput', () => { it('should handle deeply nested selections correctly', async () => { const { view } = renderView(); - // Click Node.js parent (should select all its children) const nodeCheckbox = view.getByLabelText('Node.js'); await act(async () => { @@ -376,13 +352,11 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - // Backend should be indeterminate (only Node.js selected, not Python) const backendCheckbox = view.getByLabelText( 'Backend Technologies' ) as HTMLInputElement; expect(backendCheckbox.indeterminate).toBe(true); - // Verify onUpdate was called with correct values expect(mockOnUpdate).toHaveBeenCalledWith(['express', 'fastify', 'node']); }); @@ -391,13 +365,11 @@ describe('GridFormNestedCheckboxInput', () => { defaultValue: ['react', 'vue', 'angular'], }); - // Frontend should be fully checked const frontendCheckbox = view.getByLabelText( 'Frontend Technologies' ) as HTMLInputElement; expect(frontendCheckbox).toBeChecked(); - // Deselect one child const reactCheckbox = view.getByLabelText('React'); await act(async () => { fireEvent.click(reactCheckbox); @@ -488,7 +460,6 @@ describe('GridFormNestedCheckboxInput', () => { it('should handle required validation', async () => { const { view } = renderView(); - // Check if checkboxes have required attribute expect(view.getByLabelText('Frontend Technologies')).toHaveAttribute( 'aria-required', 'true' @@ -552,7 +523,6 @@ describe('GridFormNestedCheckboxInput', () => { it('should handle empty options array', () => { const { view } = renderView({ options: [] }); - // Should render empty list const list = view.container.querySelector('ul'); expect(list).toBeInTheDocument(); expect(list?.children).toHaveLength(0); @@ -566,8 +536,8 @@ describe('GridFormNestedCheckboxInput', () => { ], }); - expect(view.getByLabelText('Option 1')).toBeInTheDocument(); - expect(view.getByLabelText('Option 2')).toBeInTheDocument(); + view.getByLabelText('Option 1'); + view.getByLabelText('Option 2'); }); it('should handle numeric values correctly', () => { From 82d259835e15c283204396b178d785269dc0d316 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 14 Oct 2025 14:58:28 -0400 Subject: [PATCH 22/44] PR feedback --- .../ConnectedNestedCheckboxes.test.tsx | 50 ++-- .../__tests__/utils.test.tsx | 240 +++++++++--------- .../ConnectedNestedCheckboxes/index.tsx | 21 +- .../ConnectedNestedCheckboxes/utils.tsx | 59 +++-- .../GridFormNestedCheckboxInput.test.tsx | 55 ++-- .../GridFormNestedCheckboxInput/index.tsx | 25 +- 6 files changed, 259 insertions(+), 191 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx index ea3d7c90438..95301bddba8 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/ConnectedNestedCheckboxes.test.tsx @@ -40,6 +40,7 @@ const mockOptions: NestedConnectedCheckboxOption[] = [ ]; const mockOnUpdate = jest.fn(); +const mockOnSubmit = jest.fn(); const TestForm: React.FC<{ defaultValues?: { skills?: string[] }; validationRules?: any; @@ -54,7 +55,7 @@ const TestForm: React.FC<{ { expect(view.getByLabelText('Python')).toBeChecked(); expect(view.getByLabelText('Express.js')).toBeChecked(); expect(view.getByLabelText('Fastify')).toBeChecked(); - - expect(mockOnUpdate).toHaveBeenCalledWith([ - 'backend', - 'node', - 'express', - 'fastify', - 'python', - 'ruby', - ]); }); it('should handle multiple parent defaults correctly', async () => { @@ -458,7 +450,7 @@ describe('ConnectedNestedCheckboxes', () => { ); }); - it('should pass validation when items are selected', async () => { + it('should submit successfully when validation passes', async () => { const validationRules = { skills: { required: 'At least one skill is required' }, }; @@ -471,8 +463,31 @@ describe('ConnectedNestedCheckboxes', () => { fireEvent.click(reactCheckbox); }); - expect(reactCheckbox).toBeChecked(); - expect(reactCheckbox).toHaveAttribute('aria-required', 'true'); + const submitButton = view.getByRole('button'); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(mockOnSubmit).toHaveBeenCalledWith( + { skills: ['react'] }, + expect.any(Object) + ); + }); + + it('should show validation errors and not submit when validation fails', async () => { + const validationRules = { + skills: { required: 'At least one skill is required' }, + }; + + const { view } = renderView({ validationRules }); + + const submitButton = view.getByRole('button'); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + view.getByText('At least one skill is required'); }); }); @@ -543,13 +558,12 @@ describe('ConnectedNestedCheckboxes', () => { const { view } = renderView({}); const list = view.container.querySelector('ul'); - const listItems = view.container.querySelectorAll('li'); - expect(list).toBeInTheDocument(); - expect(listItems).toHaveLength(11); // Total flattened options - listItems.forEach((item) => { - expect(item).toHaveStyle({ listStyle: 'none' }); + expect(list?.children).toHaveLength(11); // Total flattened options + Array.from(list?.children || []).forEach((item) => { + // each child of the ul should be an li + expect(item).toBeInstanceOf(HTMLLIElement); }); }); }); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx index 573c620e02c..79dc050a681 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx @@ -349,14 +349,14 @@ describe('ConnectedNestedCheckboxes utils', () => { const onUpdate = jest.fn(); const parentOption = flatOptions[0]; - handleCheckboxChange( - parentOption, - true, - [], + handleCheckboxChange({ + option: parentOption, + isChecked: true, + selectedValues: [], flatOptions, onChange, - onUpdate - ); + onUpdate, + }); expect(onChange).toHaveBeenCalledWith(['child1', 'child2', 'parent']); expect(onUpdate).toHaveBeenCalledWith(['child1', 'child2', 'parent']); @@ -368,14 +368,14 @@ describe('ConnectedNestedCheckboxes utils', () => { const parentOption = flatOptions[0]; const initialValues = ['parent', 'child1', 'child2', 'standalone']; - handleCheckboxChange( - parentOption, - false, - initialValues, + handleCheckboxChange({ + option: parentOption, + isChecked: false, + selectedValues: initialValues, flatOptions, onChange, - onUpdate - ); + onUpdate, + }); expect(onChange).toHaveBeenCalledWith(['standalone']); expect(onUpdate).toHaveBeenCalledWith(['standalone']); @@ -385,7 +385,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const onChange = jest.fn(); const childOption = flatOptions[1]; - handleCheckboxChange(childOption, true, [], flatOptions, onChange); + handleCheckboxChange({ + option: childOption, + isChecked: true, + selectedValues: [], + flatOptions, + onChange, + }); expect(onChange).toHaveBeenCalledWith(['child1']); }); @@ -395,13 +401,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const childOption = flatOptions[1]; const initialValues = ['child1', 'child2']; - handleCheckboxChange( - childOption, - false, - initialValues, + handleCheckboxChange({ + option: childOption, + isChecked: false, + selectedValues: initialValues, flatOptions, - onChange - ); + onChange, + }); expect(onChange).toHaveBeenCalledWith(['child2']); }); @@ -411,13 +417,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const parentOption = flatOptions[0]; const initialValues = ['child1']; - handleCheckboxChange( - parentOption, - true, - initialValues, + handleCheckboxChange({ + option: parentOption, + isChecked: true, + selectedValues: initialValues, flatOptions, - onChange - ); + onChange, + }); expect(onChange).toHaveBeenCalledWith(['child1', 'child2', 'parent']); }); @@ -427,7 +433,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const childOption = flatOptions[1]; expect(() => { - handleCheckboxChange(childOption, true, [], flatOptions, onChange); + handleCheckboxChange({ + option: childOption, + isChecked: true, + selectedValues: [], + flatOptions, + onChange, + }); }).not.toThrow(); expect(onChange).toHaveBeenCalledWith(['child1']); @@ -437,13 +449,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const onChange = jest.fn(); const standaloneOption = flatOptions[3]; - handleCheckboxChange( - standaloneOption, - true, - ['child1'], + handleCheckboxChange({ + option: standaloneOption, + isChecked: true, + selectedValues: ['child1'], flatOptions, - onChange - ); + onChange, + }); expect(onChange).toHaveBeenCalledWith(['child1', 'standalone']); }); @@ -465,16 +477,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should render a checked checkbox with correct props', () => { const state = { checked: true }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - true, // isRequired - false, // isDisabled - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: true, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -488,16 +500,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should render an indeterminate checkbox with correct props', () => { const state = { checked: false, indeterminate: true }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - false, // isRequired - false, // isDisabled - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -510,16 +522,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should render an unchecked checkbox with correct props', () => { const state = { checked: false }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - false, // isRequired - false, // isDisabled - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -532,16 +544,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should apply correct margin based on level', () => { const state = { checked: false }; - const result = renderCheckbox( - { ...mockOption, level: 2 }, + const result = renderCheckbox({ + option: { ...mockOption, level: 2 }, state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const listItem = container.querySelector('li'); @@ -552,16 +564,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should handle disabled state', () => { const state = { checked: false }; - const result = renderCheckbox( - { ...mockOption, disabled: true }, + const result = renderCheckbox({ + option: { ...mockOption, disabled: true }, state, - 'test-id', - false, - true, // isDisabled - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: true, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -572,17 +584,17 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should handle error state', () => { const state = { checked: false }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef, - true // error - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + error: true, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -597,16 +609,16 @@ describe('ConnectedNestedCheckboxes utils', () => { 'aria-label': 'Custom aria label', }; - const result = renderCheckbox( - optionWithAriaLabel, + const result = renderCheckbox({ + option: optionWithAriaLabel, state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -617,16 +629,16 @@ describe('ConnectedNestedCheckboxes utils', () => { it('should fallback to label text for aria-label when label is string', () => { const state = { checked: false }; - const result = renderCheckbox( - mockOption, + const result = renderCheckbox({ + option: mockOption, state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); @@ -641,16 +653,16 @@ describe('ConnectedNestedCheckboxes utils', () => { label: Element Label, }; - const result = renderCheckbox( - optionWithElementLabel as any, // ts should prevent this from ever happening but we have a default just in case + const result = renderCheckbox({ + option: optionWithElementLabel as any, // ts should prevent this from ever happening but we have a default just in case state, - 'test-id', - false, - false, - mockOnBlur, - mockOnChange, - mockRef - ); + checkboxId: 'test-id', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + }); const { container } = render(result); const checkbox = container.querySelector('input[type="checkbox"]'); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx index 187a406fa33..06c2c1ee4d8 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -46,7 +46,6 @@ export const ConnectedNestedCheckboxes: React.FC< ); setValue(name, expandedValues); - onUpdate?.(expandedValues); // do we want to do this? setHasExpandedInitially(true); }, [ hasExpandedInitially, @@ -67,25 +66,25 @@ export const ConnectedNestedCheckboxes: React.FC< {flatOptions.map((option) => { const state = states.get(option.value)!; - return renderCheckbox( + return renderCheckbox({ option, state, - `${name}-${option.value}`, + checkboxId: `${name}-${option.value}`, isRequired, isDisabled, onBlur, - (event) => { - handleCheckboxChange( + onChange: (event) => { + handleCheckboxChange({ option, - event.target.checked, - value, + isChecked: event.target.checked, + selectedValues: value, flatOptions, onChange, - onUpdate - ); + onUpdate, + }); }, - ref - ); + ref, + }); })} ); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx index 2b3b4aa96ba..af6017f5314 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx @@ -97,14 +97,23 @@ export const calculateStates = ( return states; }; -export const handleCheckboxChange = ( - option: FlatCheckbox, - isChecked: boolean, - selectedValues: string[], - flatOptions: FlatCheckbox[], - onChange: (values: string[]) => void, - onUpdate?: (values: string[]) => void -) => { +interface HandleCheckboxChangeParams { + option: FlatCheckbox; + isChecked: boolean; + selectedValues: string[]; + flatOptions: FlatCheckbox[]; + onChange: (values: string[]) => void; + onUpdate?: (values: string[]) => void; +} + +export const handleCheckboxChange = ({ + option, + isChecked, + selectedValues, + flatOptions, + onChange, + onUpdate, +}: HandleCheckboxChangeParams) => { const currentValue = option.value; let newSelectedValues = [...selectedValues]; @@ -142,17 +151,29 @@ export const handleCheckboxChange = ( onUpdate?.(newSelectedValues); }; -export const renderCheckbox = ( - option: FlatCheckbox, - state: FlatCheckboxState, - checkboxId: string, - isRequired: boolean, - isDisabled: boolean, - onBlur: () => void, - onChange: (event: React.ChangeEvent) => void, - ref: React.RefCallback, - error?: boolean -) => { +interface RenderCheckboxParams { + option: FlatCheckbox; + state: FlatCheckboxState; + checkboxId: string; + isRequired: boolean; + isDisabled: boolean; + onBlur: () => void; + onChange: (event: React.ChangeEvent) => void; + ref: React.RefCallback; + error?: boolean; +} + +export const renderCheckbox = ({ + option, + state, + checkboxId, + isRequired, + isDisabled, + onBlur, + onChange, + ref, + error, +}: RenderCheckboxParams) => { let checkedProps = {}; if (state.checked) { checkedProps = { diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx index 7963a725886..7c92c4bd4b7 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/__tests__/GridFormNestedCheckboxInput.test.tsx @@ -39,6 +39,7 @@ const mockOptions: NestedGridFormCheckboxOption[] = [ ]; const mockOnUpdate = jest.fn(); +const mockOnSubmit = jest.fn(); const TestForm: React.FC<{ defaultValue?: string[]; disabled?: boolean; @@ -57,7 +58,7 @@ const TestForm: React.FC<{ defaultValue, disabled, customError, - validation: { required: 'Please check the box to agree to the terms.' }, + validation: { required: 'Please check at least one option' }, }, ]} submit={{ @@ -65,7 +66,7 @@ const TestForm: React.FC<{ size: 4, }} validation="onSubmit" - onSubmit={jest.fn()} + onSubmit={mockOnSubmit} /> ); @@ -198,15 +199,6 @@ describe('GridFormNestedCheckboxInput', () => { expect(view.getByLabelText('Frontend Technologies')).not.toBeChecked(); expect(view.getByLabelText('React')).not.toBeChecked(); expect(view.getByLabelText('Vue.js')).not.toBeChecked(); - - expect(mockOnUpdate).toHaveBeenCalledWith([ - 'backend', - 'node', - 'express', - 'fastify', - 'python', - 'java', - ]); }); it('should handle multiple parent defaults correctly', async () => { @@ -482,6 +474,38 @@ describe('GridFormNestedCheckboxInput', () => { expect(checkbox).toHaveAttribute('aria-invalid', 'true'); }); }); + + it('should submit successfully when validation passes', async () => { + const { view } = renderView(); + + const reactCheckbox = view.getByLabelText('React'); + + await act(async () => { + fireEvent.click(reactCheckbox); + }); + + const submitButton = view.getByRole('button'); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(mockOnSubmit).toHaveBeenCalledWith( + { technologies: ['react'] }, + expect.any(Object) + ); + }); + + it('should show validation errors and not submit when validation fails', async () => { + const { view } = renderView(); + + const submitButton = view.getByRole('button'); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + view.getByText('Please check at least one option'); + }); }); describe('accessibility', () => { @@ -508,13 +532,12 @@ describe('GridFormNestedCheckboxInput', () => { const { view } = renderView(); const list = view.container.querySelector('ul'); - const listItems = view.container.querySelectorAll('li'); - expect(list).toBeInTheDocument(); - expect(listItems).toHaveLength(11); // Total flattened options - listItems.forEach((item) => { - expect(item).toHaveStyle({ listStyle: 'none' }); + expect(list?.children).toHaveLength(11); // Total flattened options + Array.from(list?.children || []).forEach((item) => { + // each child of the ul should be an li + expect(item).toBeInstanceOf(HTMLLIElement); }); }); }); diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index aab0adf07dc..efd254417d3 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -49,7 +49,6 @@ export const GridFormNestedCheckboxInput: React.FC< ); setValue(field.name, expandedValues); - field.onUpdate?.(expandedValues); // do we want to do this? setHasExpandedInitially(true); }, [hasExpandedInitially, field, flatOptions, setValue]); @@ -62,26 +61,26 @@ export const GridFormNestedCheckboxInput: React.FC< {flatOptions.map((option) => { const state = states.get(option.value)!; - return renderCheckbox( + return renderCheckbox({ option, state, - `${field.name}-${option.value}`, - !!required, - !!isDisabled, + checkboxId: `${field.name}-${option.value}`, + isRequired: !!required, + isDisabled: !!isDisabled, onBlur, - (event) => { - handleCheckboxChange( + onChange: (event) => { + handleCheckboxChange({ option, - event.target.checked, - value, + isChecked: event.target.checked, + selectedValues: value, flatOptions, onChange, - field.onUpdate - ); + onUpdate: field.onUpdate, + }); }, ref, - error - ); + error, + }); })} ); From b55a426c619a6e81d46f6e552cf979966e8e2ecd Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 14 Oct 2025 17:15:18 -0400 Subject: [PATCH 23/44] improvements --- .../ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx | 2 ++ .../ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx | 2 +- .../styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx index 43e9fe0bdb0..3b2395c747c 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedForm/ConnectedForm.stories.tsx @@ -59,6 +59,8 @@ export const Default = () => { minHeight="50rem" onSubmit={(values) => { action('Form Submitted')(values); + // eslint-disable-next-line no-console + console.log('Form Submitted', values); }} {...connectedFormProps} > diff --git a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx index 6ae3c4a2189..4f7dbf97fa3 100644 --- a/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx +++ b/packages/styleguide/src/lib/Organisms/ConnectedForm/ConnectedFormInputs/ConnectedFormInputs.mdx @@ -36,7 +36,7 @@ For further styling configurations, check out LayoutGrid) to customize the layouts of our GridForms. You can also customize the `FormRequiredText` using the `requiredTextProps`. `FormRequiredText` should be spaced apart from or be stylistically different from the text element above it. +#### Default value - + + +#### Placeholder text + +Text inputs are allowed to have traditional `placeholder` text. +This is a somewhat dangerous behavior for accessibility, as browsers +generally don't render placeholder text with high enough color contrast +for AA standards. If you do need to use placeholder text, such as on +landing page forms that have been shown to have higher completion rates +with the text, please make sure the placeholder text doesn't add any new +information to the form -- it should really only rephrase the text label. + +See [this article](https://www.nngroup.com/articles/form-design-placeholders/) or +more details on why using placeholders is often bad. + + + +### Textarea input + + + +### Select input + + + +### Radio group input + + + +### File upload input + +File upload fields allow users to select and upload files. You can add custom validation to restrict file types and sizes. + + + +### Checkbox input + + + +#### Spacing + +Checkboxes can use tight spacing when you need them to fit in smaller areas: + + + +### Nested checkboxes input + +Nested checkboxes allow for hierarchical selection with parent-child relationships between options. Infinite levels of nesting are supported. Clicking a parent checkbox toggles all its children accordingly. Individual child checkboxes can be toggled independently. Checkbox states are `checked` if all children are checked, `indeterminate` if some children are checked, or `unchecked` if no children are checked. The values returned by the form on submit or update are an array of all selected values, including all children. + + + +### Custom inputs + +Some forms, such as the checkout flows that use Recurly, need to define +their own inputs. We can specify a 'custom' field type to with a [render prop](https://reactjs.org/docs/render-props.html). + +We also have a 'custom-group' type for when you are passing in a custom FormGroup - including a label. If you do not want GridForm to surface errors for your field, you should likely use a 'custom-group'. If you chose to go this route, please be aware of [accessibility best practices](https://www.deque.com/blog/anatomy-of-accessible-forms-best-practices/) for forms. + + + +### Hidden input + +Hidden inputs can be used to include data that users can't see or modify with the submission. For this implementation you can set the `defaultValue` in the object and it will be submitted with the regular form data. + + + +### Sweet container input + +"Sweet container" ([honeypot]()) inputs can be used to detect bots by providing a field that would not generally be clicked by human users, but might be triggered automatically by bots. + +We call it a "sweet container" so that bots do not immediately detect it as a honeypot input. + + + +## Form Action Buttons ### Submit button position -We can position the submit button by passing the `position` prop with a -value of `'left'`, `'center'`, `'right'`, or `'stretch'`. +We can position the submit button by passing the `position` prop within `submit` with a +value of `'left'`, `'center'`, `'right'`, or `'stretch'`. The default is `'left'`. + + + + - + -### Submit button options + -We can specify the version of our button by passing the type prop. We can choose between -the `FillButton` or `CTAButton`. +### Submit button type - +We can specify the type submit button by passing the `type` prop within `submit`. We can choose between +the `FillButton` or `CTAButton`. The default is `'fill'`. + + + + ### Inline submit button @@ -94,7 +176,7 @@ We can additionally remove the label from text inputs and checkbox inputs. Use the `hideLabel` prop to remove the label, allowing the submit button to align nicely with the input. **However**, if using `hideLabel` to remove the default label, you should provide an `aria-label` and/or include another label to the right/left of the input to ensure the input is accessible. - + ### Cancel button @@ -102,43 +184,128 @@ Optionally, include a cancel button. -### Custom inputs +## Validation + +GridForm uses [react-hook-form](https://react-hook-form.com) for validation, providing a robust and flexible validation system. All validation rules are applied to individual fields through the `validation` property. + +You can control when validation occurs by setting the `validation` prop on the GridForm: + +- `'onSubmit'` (default) - Validate only when the form is submitted +- `'onChange'` - Validate on every change, submit button is disabled until all required fields are valid +- `'onTouched'` - Validate when a field loses focus + +### Required Fields + +The most common validation is making fields required. You can specify required validation as a boolean or with a custom error message: + +```tsx +// Simple required validation +validation: { + required: true +} + +// Required with custom error message +validation: { + required: 'Please enter your email address' +} +``` + +### Pattern Validation + +Use regular expressions to validate field formats like email addresses, phone numbers, or custom patterns: + +```tsx +validation: { + required: 'Email is required', + pattern: { + value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: 'Please enter a valid email address' + } +} +``` + +### Custom Validation + +For complex validation logic, use custom validation functions. These functions receive the field value and can return `true` for valid input or an error message string: + +```tsx +validation: { + required: 'Please select a file', + validate: (files) => { + const file = files.item(0); + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + return 'Please upload a JPEG, PNG, or GIF image'; + } + if (file.size > 2 * 1024 * 1024) { + return 'File size must be less than 2MB'; + } + return true; + } +} +``` + +### String Length Validation + +Validate minimum and maximum string lengths: + +```tsx +validation: { + required: 'Message is required', + minLength: { + value: 10, + message: 'Message must be at least 10 characters' + }, + maxLength: { + value: 500, + message: 'Message cannot exceed 500 characters' + } +} +``` + +### Number Range Validation + +For number inputs, validate minimum and maximum values: + +```tsx +validation: { + required: 'Age is required', + min: { + value: 18, + message: 'Must be at least 18 years old' + }, + max: { + value: 99, + message: 'Age cannot exceed 99' + } +} +``` -Some forms, such as the checkout flows that use Recurly, need to define -their own inputs. We can specify a 'custom' field type to with a [render prop](https://reactjs.org/docs/render-props.html). +## Layout & Visual Styling -We also have a 'custom-group' type for when you are passing in a custom FormGroup - including a label. If you do not want GridForm to surface errors for your field, you should likely use a 'custom-group'. If you chose to go this route, please be aware of [accessibility best practices](https://www.deque.com/blog/anatomy-of-accessible-forms-best-practices/) for forms. +### GridForm-atting - +We can use the `size` and `rowspan` props (borrowed from LayoutGrid) to customize the layouts of our GridForms. You can also customize the `FormRequiredText` using the `requiredTextProps`. `FormRequiredText` should be spaced apart from or be stylistically different from the text element above it. -### Placeholder text + -Text inputs are allowed to have traditional `placeholder` text. -This is a somewhat dangerous behavior for accessibility, as browsers -generally don't render placeholder text with high enough color contrast -for AA standards. If you do need to use placeholder text, such as on -landing page forms that have been shown to have higher completion rates -with the text, please make sure the placeholder text doesn't add any new -information to the form -- it should really only rephrase the text label. +### `hideRequiredText` -See [this article](https://www.nngroup.com/articles/form-design-placeholders/) or -more details on why using placeholders is often bad. +`hideRequiredText` will hide the '\* Required' text that appears at the top of our forms. This should only be hidden if the form has no required fields. - + -### On field update +### Solo field form -A field can take an onUpdate callback. This callback will fire when the -field's value changes. This could be useful if you need to use the -field's value in a parent component before onSubmit gets triggered. +Solo field form should always have their solo input be required. They should automagically not have the required/optional text - if you have a custom rendered hidden input, you may have to use the `hasSoloField` prop. - + -### InfoTip + GridForm +### InfoTips -A field can include our existing `InfoTip`. The position of the infotip on each field is always set to the bottom-right. +A field can include our existing `InfoTip`. See the InfoTip story for more information on what props are available. -See the `Radio` story for an example of how to add a infotip to a radio option. +See the Radio story for an example of how to add a infotip to a radio option. @@ -154,43 +321,21 @@ When using the left-aligned layout, please note that the `title` takes up 3 colu -### Custom error - -A field can take a custom error in addition to validation errors. The validation error will always take precedence to the custom error. - - - -### Hidden input - -Hidden inputs can be used to include data that users can't see or modify with the submission. For this implementation you can set the `defaultValue` in the object and it will be submitted with the regular form data. - - - -### Sweet container input - -"Sweet container" ([honeypot]()) inputs can be used to detect bots by providing a field that would not generally be clicked by human users, but might be triggered automatically by bots. - -We call it a "sweet container" so that bots do not immediately detect it as a honeypot input. - - - -### Markdown errors - -GridForm renders errors through our Markdown component so we can optionally add markdown to our validation messages. - - +## Field Behavior & State Management -### `` +### Disabled inputs -By toggling dark mode you can see all the colors map to a new color that is accessible for the mode by default. Please use the ColorMode control on the top of this page and navigating to the Playground example to check it out! +If an input is not meant to be usable, such as when a portion of a form is disabled pending user action, you can make it visually disabled with a `disabled` field member. -**Note**: you **cannot** use the deprecated 'business' button type with `ColorMode`. + -### Checkbox spacing +### On field update -If you need to checkboxes to fit into a smaller space, you can use our our `tight` spacing prop for checkboxes that are a bit closer together. +A field can take an onUpdate callback. This callback will fire when the +field's value changes. This could be useful if you need to use the +field's value in a parent component before onSubmit gets triggered. - + ### Loading and disabled states @@ -215,17 +360,17 @@ We can combine these together to create some pretty cool forms which have a load -### `hideRequiredText` +### Custom error -`hideRequiredText` will hide the '\* Required' text that appears at the top of our forms. This should only be hidden if the form has no required fields. +A field can take a custom error in addition to validation errors. The validation error will always take precedence to the custom error. - + -### Solo field form +### Markdown errors -Solo field form should always have their solo input be required. They should automagically not have the required/optional text - if you have a custom rendered hidden input, you may have to use the `hasSoloField` prop. +GridForm renders errors through our Markdown component so we can optionally add markdown to our validation messages. - + ## Playground diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx index 98baea33f9d..a54a3325bb8 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx @@ -9,7 +9,7 @@ import { import { Background } from '@codecademy/gamut-styles'; import { action } from '@storybook/addon-actions'; import type { Meta, StoryObj } from '@storybook/react'; -import { ComponentProps, useState } from 'react'; +import { useState } from 'react'; const meta: Meta = { component: GridForm, @@ -171,438 +171,651 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Default: React.FC> = (args) => { - return ; +export const Default: Story = {}; + +export const TextField: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'simple-text', + size: 9, + type: 'text', + }, + ], + }, }; -const DisabledInputsExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); +export const DefaultTextField: Story = { + args: { + fields: [ + { + defaultValue: 'yeet', + label: 'Text with default value', + name: 'text-with-default', + size: 9, + type: 'text', + }, + ], + }, }; -export const DisabledInputs: Story = { - render: () => , +export const PlaceholderTextField: Story = { + args: { + fields: [ + { + label: 'Text with placeholder', + placeholder: 'Your email', + name: 'placeholder', + size: 9, + type: 'email', + }, + ], + }, }; -const FormattedExample = () => { - return ( - { + const { type } = files.item(0); + const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png']; + if (!allowedTypes.includes(type)) + return 'Please upload a pdf, jpeg, or png file.'; + return true; }, }, - { - label: 'Simple text', - name: 'rowspan-simple-text', - size: 3, - type: 'text', - }, - { - defaultValue: 'yeet', - label: 'Text with default value', - name: 'text-with-default-formatting', - size: 4, - type: 'text', + }, + ], + }, +}; + +export const CheckboxField: Story = { + args: { + fields: [ + { + description: 'I agree to the terms', + label: 'Terms', + name: 'terms', + size: 6, + type: 'checkbox', + id: 'my-super-cool-id', + }, + { + description: 'I agree to the conditions', + label: 'Conditions', + name: 'conditions', + size: 6, + type: 'checkbox', + id: 'my-super-cool-id2', + }, + ], + }, +}; + +export const CheckboxSpacing: Story = { + args: { + fields: [ + { + description: 'I agree to the terms', + label: 'Terms', + name: 'terms', + size: 6, + type: 'checkbox', + id: 'terms-id', + spacing: 'tight', + }, + { + description: 'I agree to the conditions', + label: 'Conditions', + name: 'conditions', + size: 6, + type: 'checkbox', + id: 'conditions-id', + spacing: 'tight', + }, + ], + }, +}; + +export const NestedCheckboxesField: Story = { + args: { + fields: [ + { + label: 'Nested checkboxes', + name: 'nested-checkboxes', + type: 'nested-checkboxes', + defaultValue: ['backend', 'react', 'vue'], + options: [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { + value: 'react', + label: 'React', + options: [ + { value: 'nextjs', label: 'Next.js' }, + { value: 'typescript', label: 'TypeScript' }, + ], + }, + { + value: 'vue', + label: 'Vue.js', + }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + ], + }, + ], + size: 12, + }, + ], + }, +}; + +export const CustomInputs: Story = { + args: { + fields: [ + { + render: ({ error, setValue }) => ( + <> + setValue(event.target.value)} + /> + 🕺 + + ), + label: 'Gimme two more swags', + name: 'custom-input', + size: 12, + validation: { + required: true, + pattern: { + value: /swag(.*)swag/, + message: 'Still not enough swag, what are you doing... 💢', + }, }, - { - label: 'Simple select (required)', - name: 'simple-select-formatting', - options: ['', 'One fish', 'Two fish', 'Red fish', 'Blue fish'], - size: 5, - type: 'select', - validation: { - required: 'Please select an option', + type: 'custom', + }, + { + render: ({ error, setValue }) => ( + + setValue(event.target.value)} + /> + + ), + size: 12, + label: 'Gimme two more swags', + name: 'custom-input-group', + validation: { + required: true, + pattern: { + value: /swag(.*)swag/, + message: 'Still not enough swag, what are you doing... 💢', }, }, - ]} - requiredTextProps={{ color: 'danger', variant: 'title-xs' }} - submit={{ - contents: 'Submit', + type: 'custom-group', + }, + ], + }, +}; + +export const HiddenInput: Story = { + args: { + fields: [ + { + type: 'hidden', + name: 'secret-stuff', + defaultValue: "I'm invisible!", + }, + { + label: "There's more than one field here!", + name: 'custom-hidden-input', + type: 'email', size: 12, - }} - onSubmit={(values) => { - action('Form Submitted')(values); - }} - /> - ); + }, + ], + }, }; -export const Formatted: Story = { - render: () => , +export const SweetContainer: Story = { + args: { + fields: [ + { + label: 'This is our sticky sweet label', + name: 'sweet-container', + type: 'sweet-container', + }, + { + label: "There's something sticky and sweet here!", + name: 'custom-input', + type: 'email', + size: 12, + }, + ], + }, }; -const SubmitButtonPositionExample = () => { - return ( - - - { - action('Form Submitted')(values); - }} - /> - - - { - action('Form Submitted')(values); - }} - /> - - - { - action('Form Submitted')(values); - }} - /> - - - { - action('Form Submitted')(values); - }} - /> - - - ); +export const SubmitButtonRight: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'right-sub-simple-text', + type: 'text', + size: 12, + }, + ], + submit: { + contents: 'Right Submit!?', + position: 'right', + size: 12, + }, + }, }; -export const SubmitButtonPosition: Story = { - render: () => , +export const SubmitButtonLeft: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'left-sub-simple-text', + type: 'text', + size: 12, + }, + ], + submit: { + contents: 'Left Submit!?', + position: 'left', + size: 12, + }, + }, }; -const SubmitButtonOptionsExample = () => { - return ( - <> - { - action('Form Submitted')(values); - }} - /> - {}, + }, + submit: { + contents: 'Submit!?', + position: 'right', + size: 12, + }, + }, +}; + +export const Formatted: Story = { + args: { + fields: [ + { + label: 'Fave Gamut Component', + name: 'rowspan-radiogroup', + options: [ { - label: 'Simple text', - name: 'cta-button-simple-text', - size: 6, - type: 'text', + label: 'FlexBox', + value: 'flex', }, - ]} - hideRequiredText - submit={{ - type: 'cta', - contents: 'CTA Button', - size: 12, - }} - onSubmit={(values) => { - action('Form Submitted')(values); - }} - /> - { - action('Form Submitted')(values); - }} - /> - { - action('Form Submitted')(values); - }} - /> - - ); -}; - -export const SubmitButtonOptions: Story = { - render: () => , -}; - -const InlineExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); -}; - -export const InlineSubmit: Story = { - render: () => , + type: 'text', + }, + { + label: 'Simple select (required)', + name: 'simple-select-formatting', + options: ['', 'One fish', 'Two fish', 'Red fish', 'Blue fish'], + size: 5, + type: 'select', + validation: { + required: 'Please select an option', + }, + }, + ], + requiredTextProps: { color: 'danger', variant: 'title-xs' }, + }, }; -const CancelButtonExample = () => { - return ( - {}, - }} - fields={[ - { - label: 'Simple text', - name: 'right-sub-simple-text', - type: 'text', - size: 12, - }, - ]} - hideRequiredText - submit={{ - contents: 'Right Submit!?', - position: 'right', +export const HideRequiredText: Story = { + args: { + fields: [ + { + label: 'A field', + placeholder: 'I am very optional', + name: 'very-optional', + type: 'text', size: 12, - }} - onSubmit={(values) => { - action('Form Submitted')(values); - }} - /> - ); + }, + { + label: 'A field', + placeholder: 'I am very optional', + name: 'very-optional', + type: 'text', + size: 12, + }, + ], + hideRequiredText: true, + }, }; -export const CancelButton: Story = { - render: () => , +export const SoloField: Story = { + args: { + fields: [ + { + label: 'A field', + placeholder: 'I am a required solo field', + name: 'so-required', + type: 'text', + size: 12, + validation: { + required: 'I am required', + }, + }, + ], + }, }; -const CustomInputsExample = () => { - return ( - ( - <> - setValue(event.target.value)} - /> - 🕺 - - ), - label: 'Gimme two more swags', - name: 'custom-input', - size: 12, - validation: { - required: true, - pattern: { - value: /swag(.*)swag/, - message: 'Still not enough swag, what are you doing... 💢', - }, - }, - type: 'custom', +export const InfoTip: Story = { + args: { + fields: [ + { + infotip: { + info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + alignment: 'bottom-left', }, - { - render: ({ error, setValue }) => ( - - setValue(event.target.value)} - /> - - ), - size: 12, - label: 'Gimme two more swags', - name: 'custom-input-group', - validation: { - required: true, - pattern: { - value: /swag(.*)swag/, - message: 'Still not enough swag, what are you doing... 💢', - }, + label: 'Tool input', + name: 'input-field', + size: 6, + type: 'text', + }, + { + infotip: { + info: , + alignment: 'bottom-right', + }, + label: 'Select with infotip', + options: ['', 'Water', 'Earth', 'Fire', 'Air', 'Boomerang'], + size: 3, + type: 'select', + validation: { + required: 'Please select an option', + }, + name: 'select-field', + }, + { + infotip: { + info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + label: 'Write a paragraph about infotips', + name: 'textarea-field', + size: 6, + type: 'textarea', + rows: 6, + placeholder: 'Check out my infotip', + validation: { + required: 'Please write something about infotips!', + }, + }, + { + infotip: { + info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + label: 'Preferred Modern Artist', + name: 'modern-artist', + options: [ + { + label: 'Taylor Swift', + value: 'taylor-swift', + }, + { + label: 'Beyonce', + value: 'beyonce', }, - type: 'custom-group', + ], + size: 3, + type: 'radio-group', + validation: { + required: 'You gotta pick one!', }, - ]} - submit={{ - contents: 'Submit Me!?', - size: 12, - }} - onSubmit={(values) => { - action('Form Submitted')(values); - }} - /> - ); -}; - -export const CustomInputs: Story = { - render: () => , + }, + { + infotip: { + info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + alignment: 'bottom-right', + }, + label: 'End User License Agreement', + description: 'I promise that I read it', + name: 'eula-checkbox-required-agreement', + size: 4, + type: 'checkbox', + validation: { + required: 'Please check the box to agree to the terms.', + }, + }, + ], + }, }; -const PlaceholderTextExample = () => { +const DisabledInputsExample = () => { return ( { action('Form Submitted')(values); @@ -611,126 +824,115 @@ const PlaceholderTextExample = () => { ); }; -export const PlaceholderText: Story = { - render: () => , -}; - -const OnFieldUpdateExample = () => { - const [text, setText] = useState(''); - return ( - <> - <>The text value is: {text} - { - action('Form Submitted')(values); - }} - /> - - ); -}; - -export const OnFieldUpdate: Story = { - render: () => , -}; - -const InfoTipExample = () => { - return ( - <> - - ), - alignment: 'bottom-right', - }, - label: 'Select with infotip', - options: ['', 'Water', 'Earth', 'Fire', 'Air', 'Boomerang'], - size: 3, - type: 'select', + label: 'hello?', + name: 'text02-left-section', + size: 4, + type: 'text', validation: { - required: 'Please select an option', + required: true, }, - name: 'select-field', }, { - infotip: { - info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - }, - label: 'Write a paragraph about infotips', - name: 'textarea-field', - size: 6, + label: 'Write a paragraph.', + name: 'paragraph01-left-section', + size: 8, type: 'textarea', - rows: 6, - placeholder: 'Check out my infotip', validation: { - required: 'Please write something about infotips!', + required: 'Please write something about penguins!', }, }, { - infotip: { - info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + label: 'howdy?', + name: 'text03-left-section', + size: 4, + type: 'text', + validation: { + required: true, }, - label: 'Preferred Modern Artist', - name: 'modern-artist', - options: [ - { - label: 'Taylor Swift', - value: 'taylor-swift', - }, - { - label: 'Beyonce', - value: 'beyonce', - }, - ], - size: 3, - type: 'radio-group', + }, + { + label: 'whats up?', + name: 'text04--left-section', + size: 4, + type: 'text', validation: { - required: 'You gotta pick one!', + required: true, }, }, { - infotip: { - info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - alignment: 'bottom-right', + label: 'Write another long paragraph', + name: 'paragraph02-left-section', + size: 8, + type: 'textarea', + validation: { + required: 'Please write something about penguins!', }, - label: 'End User License Agreement', - description: 'I promise that I read it', - name: 'eula-checkbox-required-agreement', - size: 4, - type: 'checkbox', + }, + ], + }, + { + title: 'hi there... again', + as: 'h3', + fields: [ + { + label: 'hello....', + name: 'text01-center-section', + size: 5, + type: 'text', validation: { - required: 'Please check the box to agree to the terms.', + required: true, }, }, + ], + }, + ], + requiredTextProps: { color: 'primary', fontStyle: 'italic' }, + }, +}; + +export const DisabledInputs: Story = { + render: () => , +}; + +const OnFieldUpdateExample = () => { + const [text, setText] = useState(''); + return ( + <> + <>The text value is: {text} + { @@ -741,109 +943,8 @@ const InfoTipExample = () => { ); }; -export const InfoTip: Story = { - render: () => , -}; - -const SectionsExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); -}; - -export const Sections: Story = { - render: () => , +export const OnFieldUpdate: Story = { + render: () => , }; const CustomErrorExample = () => { @@ -880,69 +981,6 @@ export const CustomError: Story = { render: () => , }; -const HiddenInputExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); -}; - -export const HiddenInput: Story = { - render: () => , -}; - -const SweetContainerExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); -}; - -export const SweetContainer: Story = { - render: () => , -}; - const MarkdownErrorsExample = () => { return ( , }; -const CheckboxSpacingExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); -}; - -export const CheckboxSpacing: Story = { - render: () => , -}; - const LoadingAndDisabledExample = () => { return ( <> @@ -1280,76 +1251,3 @@ export const FormLoadingExample = () => { export const FormLoading: Story = { render: () => , }; - -const HideRequiredTextExample = () => { - return ( - <> - - { - action('Form Submitted')(values); - }} - /> - - - ); -}; - -export const HideRequiredText: Story = { - render: () => , -}; - -const SoloFieldExample = () => { - return ( - <> - - { - action('Form Submitted')(values); - }} - /> - - - ); -}; - -export const SoloField: Story = { - render: () => , -}; From 4a31decbef55da227be44d930a7b8fa6a3b7d82e Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 16 Oct 2025 09:46:12 -0400 Subject: [PATCH 26/44] update spacing logic --- .../ConnectedNestedCheckboxes/index.tsx | 12 ++---------- .../GridFormNestedCheckboxInput/index.tsx | 15 +++++---------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx index 06c2c1ee4d8..afce7c0d6f6 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -24,15 +24,7 @@ export const ConnectedNestedCheckboxes: React.FC< const defaultValue: string[] = getValues()[name]; - const optionsWithSpacing = options.map((option) => ({ - ...option, - spacing, - })); - - const flatOptions = useMemo( - () => flattenOptions(optionsWithSpacing), - [optionsWithSpacing] - ); + const flatOptions = useMemo(() => flattenOptions(options), [options]); const [hasExpandedInitially, setHasExpandedInitially] = useState(false); @@ -67,7 +59,7 @@ export const ConnectedNestedCheckboxes: React.FC< {flatOptions.map((option) => { const state = states.get(option.value)!; return renderCheckbox({ - option, + option: { ...option, spacing }, state, checkboxId: `${name}-${option.value}`, isRequired, diff --git a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx index efd254417d3..846d0622f67 100644 --- a/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx +++ b/packages/gamut/src/GridForm/GridFormInputGroup/GridFormNestedCheckboxInput/index.tsx @@ -23,15 +23,10 @@ export const GridFormNestedCheckboxInput: React.FC< > = ({ field, required, disabled, error, setValue }) => { const isDisabled = disabled || field.disabled; - const optionsWithSpacing = field.options.map((option) => ({ - ...option, - spacing: field.spacing, - })); - - const flatOptions = useMemo(() => { - const flattened = flattenOptions(optionsWithSpacing); - return flattened; - }, [optionsWithSpacing]); + const flatOptions = useMemo( + () => flattenOptions(field.options), + [field.options] + ); const [hasExpandedInitially, setHasExpandedInitially] = useState(false); @@ -62,7 +57,7 @@ export const GridFormNestedCheckboxInput: React.FC< {flatOptions.map((option) => { const state = states.get(option.value)!; return renderCheckbox({ - option, + option: { ...option, spacing: field.spacing }, state, checkboxId: `${field.name}-${option.value}`, isRequired: !!required, From c380ed9ba95e5ee9bdd2b7ef4598f572ecaf8fc7 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 24 Oct 2025 09:21:49 -0400 Subject: [PATCH 27/44] add deep controls to table of contents --- packages/styleguide/src/lib/Meta/About.mdx | 2 ++ packages/styleguide/src/lib/Meta/Deep Controls Add-On.mdx | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/packages/styleguide/src/lib/Meta/About.mdx b/packages/styleguide/src/lib/Meta/About.mdx index 5fadd6a3420..77487f9b7a3 100644 --- a/packages/styleguide/src/lib/Meta/About.mdx +++ b/packages/styleguide/src/lib/Meta/About.mdx @@ -9,6 +9,7 @@ import { import { parameters as bestPracticesParameters } from './Best Practices.mdx'; import { parameters as brandParameters } from './Brand.mdx'; import { parameters as contributingParameters } from './Contributing.mdx'; +import { parameters as deepControlsParameters } from './Deep Controls Add-On.mdx'; import { parameters as faqsParameters } from './FAQs.mdx'; import { parameters as installationParameters } from './Installation.mdx'; import { parameters as storiesParameters } from './Stories.mdx'; @@ -29,6 +30,7 @@ export const parameters = { links={addParentPath(parameters.id, [ bestPracticesParameters, contributingParameters, + deepControlsParameters, faqsParameters, storiesParameters, brandParameters, diff --git a/packages/styleguide/src/lib/Meta/Deep Controls Add-On.mdx b/packages/styleguide/src/lib/Meta/Deep Controls Add-On.mdx index 8a14af90765..04dc424ba16 100644 --- a/packages/styleguide/src/lib/Meta/Deep Controls Add-On.mdx +++ b/packages/styleguide/src/lib/Meta/Deep Controls Add-On.mdx @@ -2,6 +2,13 @@ import { Meta } from '@storybook/blocks'; import { Callout, ImageWrapper, LinkTo } from '~styleguide/blocks'; +export const parameters = { + id: 'Deep Controls Add-On', + title: 'Deep Controls Add-On', + subtitle: `Enables Storybook controls for nested component properties, allowing you to interactively modify deeply nested props directly from the Controls panel without having to manually edit complex object structures.`, + status: 'static', +}; + # Deep Controls add-on From 253e249a7d662f4b10b24767cc64cbf2954a3c1a Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 24 Oct 2025 10:08:43 -0400 Subject: [PATCH 28/44] remove unused --- .../src/lib/Organisms/GridForm/GridForm.stories.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx index 983eeb2f000..a4fb66e9116 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx @@ -1,15 +1,8 @@ -import { - Column, - FormGroup, - GridForm, - Input, - LayoutGrid, - Markdown, -} from '@codecademy/gamut'; +import { FormGroup, GridForm, Input, Markdown } from '@codecademy/gamut'; import { Background } from '@codecademy/gamut-styles'; import { action } from '@storybook/addon-actions'; import type { Meta, StoryObj } from '@storybook/react'; -import { ComponentProps, useState } from 'react'; +import { useState } from 'react'; import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; const meta: TypeWithDeepControls> = { From 50ca32bd5099996334c39a4e8970329aa16cfe09 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 24 Oct 2025 11:03:56 -0400 Subject: [PATCH 29/44] break down into pages --- .../src/lib/Organisms/GridForm/About.mdx | 38 + .../src/lib/Organisms/GridForm/Buttons.mdx | 57 + .../Organisms/GridForm/Buttons.stories.tsx | 174 +++ .../src/lib/Organisms/GridForm/Fields.mdx | 101 ++ .../lib/Organisms/GridForm/Fields.stories.tsx | 318 +++++ .../src/lib/Organisms/GridForm/GridForm.mdx | 353 +----- .../Organisms/GridForm/GridForm.stories.tsx | 1083 +---------------- .../src/lib/Organisms/GridForm/Layout.mdx | 55 + .../lib/Organisms/GridForm/Layout.stories.tsx | 284 +++++ .../src/lib/Organisms/GridForm/States.mdx | 66 + .../lib/Organisms/GridForm/States.stories.tsx | 384 ++++++ .../src/lib/Organisms/GridForm/Usage.mdx | 46 + .../src/lib/Organisms/GridForm/Validation.mdx | 110 ++ 13 files changed, 1636 insertions(+), 1433 deletions(-) create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/About.mdx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/Buttons.mdx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/Buttons.stories.tsx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/Layout.stories.tsx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/States.mdx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/Usage.mdx create mode 100644 packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx diff --git a/packages/styleguide/src/lib/Organisms/GridForm/About.mdx b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx new file mode 100644 index 00000000000..a12fd3c1314 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx @@ -0,0 +1,38 @@ +import { Meta } from '@storybook/blocks'; + +import { + AboutHeader, + addParentPath, + TableOfContents, +} from '~styleguide/blocks'; + +import { parameters as buttonsParameters } from './Buttons.mdx'; +import { parameters as fieldsParameters } from './Fields.mdx'; +import { parameters as gridFormParameters } from './GridForm.mdx'; +import { parameters as layoutParameters } from './Layout.mdx'; +import { parameters as statesParameters } from './States.mdx'; +import { parameters as usageParameters } from './Usage.mdx'; +import { parameters as validationParameters } from './Validation.mdx'; + +export const parameters = { + id: 'Organisms/GridForm', + title: 'GridForm', + subtitle: 'An efficient way to build and design forms on a grid.', +}; + + + + + + + diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Buttons.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Buttons.mdx new file mode 100644 index 00000000000..13a5973b0c4 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/Buttons.mdx @@ -0,0 +1,57 @@ +import { Canvas, Meta } from '@storybook/blocks'; + +import { ComponentHeader, LinkTo } from '~styleguide/blocks'; + +import * as ButtonsStories from './Buttons.stories'; + +export const parameters = { + title: 'Buttons', + subtitle: 'Configure submit and cancel buttons for your forms.', +}; + + + + + +## Form Action Buttons + +### Submit button position + +We can position the submit button by passing the `position` prop within `submit` with a +value of `'left'`, `'center'`, `'right'`, or `'stretch'`. The default is `'left'`. + + + + + + + + + +### Submit button type + +We can specify the type submit button by passing the `type` prop within `submit`. We can choose between +the `FillButton` or `CTAButton`. The default is `'fill'`. + + + + + +### Inline submit button + +We can make the Submit button inline with an input by setting the column +sizes so they fit on the same row (e.g size 8 for an input and size 4 for +the submit). + +We can additionally remove the label from text inputs and checkbox inputs. +Use the `hideLabel` prop to remove the label, allowing the submit button to +align nicely with the input. **However**, if using `hideLabel` to remove the default label, you should provide an `aria-label` and/or include another label to the right/left of the input to ensure the input is accessible. + + + +### Cancel button + +Optionally, include a cancel button. + + + diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Buttons.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/Buttons.stories.tsx new file mode 100644 index 00000000000..b75d716c89d --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/Buttons.stories.tsx @@ -0,0 +1,174 @@ +import { GridForm } from '@codecademy/gamut'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + component: GridForm, + args: { + onSubmit: (values) => { + action('Form Submitted')(values); + // eslint-disable-next-line no-console + console.log('Form Submitted', values); + }, + fields: [ + { + label: 'Simple text', + name: 'simple-text', + type: 'text', + size: 12, + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const SubmitButtonRight: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'right-sub-simple-text', + type: 'text', + size: 12, + }, + ], + submit: { + contents: 'Right Submit!?', + position: 'right', + size: 12, + }, + }, +}; + +export const SubmitButtonLeft: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'left-sub-simple-text', + type: 'text', + size: 12, + }, + ], + submit: { + contents: 'Left Submit!?', + position: 'left', + size: 12, + }, + }, +}; + +export const SubmitButtonCenter: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'center-sub-simple-text', + type: 'text', + size: 12, + }, + ], + submit: { + contents: 'Center Submit!?', + position: 'center', + size: 12, + }, + }, +}; + +export const SubmitButtonStretch: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'stretch-sub-simple-text', + type: 'text', + size: 12, + }, + ], + submit: { + contents: 'Stretch Submit!?', + position: 'stretch', + size: 12, + }, + }, +}; + +export const SubmitButtonFill: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'fill-sub-simple-text', + type: 'text', + size: 12, + }, + ], + submit: { + contents: 'Fill Submit!?', + type: 'fill', + size: 12, + }, + }, +}; + +export const SubmitButtonCTA: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'cta-sub-simple-text', + type: 'text', + size: 12, + }, + ], + submit: { + contents: 'CTA Submit!?', + type: 'cta', + size: 12, + }, + }, +}; + +export const SubmitButtonInline: Story = { + args: { + fields: [ + { + hideLabel: true, + label: 'Label', + name: 'email', + size: 8, + type: 'text', + }, + ], + submit: { + contents: 'Inline Submit!?', + size: 4, + position: 'right', + }, + }, +}; + +export const CancelButton: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'cancel-sub-simple-text', + type: 'text', + size: 12, + }, + ], + cancel: { + children: 'Cancel', + onClick: () => {}, + }, + submit: { + contents: 'Submit!?', + position: 'right', + size: 12, + }, + }, +}; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx new file mode 100644 index 00000000000..a090f6fe186 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx @@ -0,0 +1,101 @@ +import { Canvas, Meta } from '@storybook/blocks'; + +import { ComponentHeader } from '~styleguide/blocks'; + +import * as FieldsStories from './Fields.stories'; + +export const parameters = { + title: 'Fields', + subtitle: 'Comprehensive field types supported by GridForm.', +}; + + + + + +## Fields + +GridForm supports a comprehensive set of field types to cover various form input needs. Each field type has specific properties and validation options. + +### Text inputs + +Text inputs support various HTML input types including `text`, `email`, `password`, `number`, `tel`, `url`, `search`, `date`, `time`, and more. All text inputs share the same basic properties but may have different validation patterns. + + + +#### Default value + + + +#### Placeholder text + +Text inputs are allowed to have traditional `placeholder` text. +This is a somewhat dangerous behavior for accessibility, as browsers +generally don't render placeholder text with high enough color contrast +for AA standards. If you do need to use placeholder text, such as on +landing page forms that have been shown to have higher completion rates +with the text, please make sure the placeholder text doesn't add any new +information to the form -- it should really only rephrase the text label. + +See [this article](https://www.nngroup.com/articles/form-design-placeholders/) or +more details on why using placeholders is often bad. + + + +### Textarea input + + + +### Select input + + + +### Radio group input + + + +### File upload input + +File upload fields allow users to select and upload files. You can add custom validation to restrict file types and sizes. + + + +### Checkbox input + + + +#### Spacing + +Checkboxes can use tight spacing when you need them to fit in smaller areas: + + + +### Nested checkboxes input + +Nested checkboxes allow for hierarchical selection with parent-child relationships between options. Infinite levels of nesting are supported. Clicking a parent checkbox toggles all its children accordingly. Individual child checkboxes can be toggled independently. Checkbox states are `checked` if all children are checked, `indeterminate` if some children are checked, or `unchecked` if no children are checked. The values returned by the form on submit or update are an array of all selected values, including all children. + + + +### Custom inputs + +Some forms, such as the checkout flows that use Recurly, need to define +their own inputs. We can specify a 'custom' field type to with a [render prop](https://reactjs.org/docs/render-props.html). + +We also have a 'custom-group' type for when you are passing in a custom FormGroup - including a label. If you do not want GridForm to surface errors for your field, you should likely use a 'custom-group'. If you chose to go this route, please be aware of [accessibility best practices](https://www.deque.com/blog/anatomy-of-accessible-forms-best-practices/) for forms. + + + +### Hidden input + +Hidden inputs can be used to include data that users can't see or modify with the submission. For this implementation you can set the `defaultValue` in the object and it will be submitted with the regular form data. + + + +### Sweet container input + +"Sweet container" ([honeypot]()) inputs can be used to detect bots by providing a field that would not generally be clicked by human users, but might be triggered automatically by bots. + +We call it a "sweet container" so that bots do not immediately detect it as a honeypot input. + + + diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx new file mode 100644 index 00000000000..89e34757396 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx @@ -0,0 +1,318 @@ +import { FormGroup, GridForm, Input } from '@codecademy/gamut'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + component: GridForm, + args: { + onSubmit: (values) => { + action('Form Submitted')(values); + // eslint-disable-next-line no-console + console.log('Form Submitted', values); + }, + submit: { + contents: 'Submit', + size: 4, + position: 'left', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const TextField: Story = { + args: { + fields: [ + { + label: 'Simple text', + name: 'simple-text', + size: 9, + type: 'text', + }, + ], + }, +}; + +export const DefaultTextField: Story = { + args: { + fields: [ + { + defaultValue: 'yeet', + label: 'Text with default value', + name: 'text-with-default', + size: 9, + type: 'text', + }, + ], + }, +}; + +export const PlaceholderTextField: Story = { + args: { + fields: [ + { + label: 'Text with placeholder', + placeholder: 'Your email', + name: 'placeholder', + size: 9, + type: 'email', + }, + ], + }, +}; + +export const TextareaField: Story = { + args: { + fields: [ + { + label: 'Write a paragraph about penguins', + name: 'textarea-input', + size: 9, + type: 'textarea', + }, + ], + }, +}; + +export const SelectField: Story = { + args: { + fields: [ + { + label: 'Simple select', + name: 'simple-select', + options: ['', 'One fish', 'Two fish', 'Red fish', 'Blue fish'], + size: 9, + type: 'select', + }, + ], + }, +}; + +export const RadioGroupField: Story = { + args: { + fields: [ + { + label: 'Preferred Modern Artist', + name: 'artist', + options: [ + { + label: 'Cardi B', + value: 'cardi', + }, + { + label: 'Nicki Minaj', + value: 'nicki', + }, + ], + size: 9, + type: 'radio-group', + }, + ], + }, +}; + +export const FileUploadField: Story = { + args: { + fields: [ + { + label: 'Upload a cat image (we support pdf, jpeg, or png files)', + name: 'file-input', + size: 9, + type: 'file', + validation: { + required: true, + validate: (files) => { + const { type } = files.item(0); + const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png']; + if (!allowedTypes.includes(type)) + return 'Please upload a pdf, jpeg, or png file.'; + return true; + }, + }, + }, + ], + }, +}; + +export const CheckboxField: Story = { + args: { + fields: [ + { + description: 'I agree to the terms', + label: 'Terms', + name: 'terms', + size: 6, + type: 'checkbox', + id: 'my-super-cool-id', + }, + { + description: 'I agree to the conditions', + label: 'Conditions', + name: 'conditions', + size: 6, + type: 'checkbox', + id: 'my-super-cool-id2', + }, + ], + }, +}; + +export const CheckboxSpacing: Story = { + args: { + fields: [ + { + description: 'I agree to the terms', + label: 'Terms', + name: 'terms', + size: 6, + type: 'checkbox', + id: 'terms-id', + spacing: 'tight', + }, + { + description: 'I agree to the conditions', + label: 'Conditions', + name: 'conditions', + size: 6, + type: 'checkbox', + id: 'conditions-id', + spacing: 'tight', + }, + ], + }, +}; + +export const NestedCheckboxesField: Story = { + args: { + fields: [ + { + label: 'Nested checkboxes', + name: 'nested-checkboxes', + type: 'nested-checkboxes', + defaultValue: ['backend', 'react', 'vue'], + options: [ + { + value: 'frontend', + label: 'Frontend Technologies', + options: [ + { + value: 'react', + label: 'React', + options: [ + { value: 'nextjs', label: 'Next.js' }, + { value: 'typescript', label: 'TypeScript' }, + ], + }, + { + value: 'vue', + label: 'Vue.js', + }, + { value: 'angular', label: 'Angular' }, + ], + }, + { + value: 'backend', + label: 'Backend Technologies', + options: [ + { value: 'node', label: 'Node.js' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + ], + }, + ], + size: 12, + }, + ], + }, +}; + +export const CustomInputs: Story = { + args: { + fields: [ + { + render: ({ error, setValue }) => ( + <> + setValue(event.target.value)} + /> + 🕺 + + ), + label: 'Gimme two more swags', + name: 'custom-input', + size: 12, + validation: { + required: true, + pattern: { + value: /swag(.*)swag/, + message: 'Still not enough swag, what are you doing... 💢', + }, + }, + type: 'custom', + }, + { + render: ({ error, setValue }) => ( + + setValue(event.target.value)} + /> + + ), + size: 12, + label: 'Gimme two more swags', + name: 'custom-input-group', + validation: { + required: true, + pattern: { + value: /swag(.*)swag/, + message: 'Still not enough swag, what are you doing... 💢', + }, + }, + type: 'custom-group', + }, + ], + }, +}; + +export const HiddenInput: Story = { + args: { + fields: [ + { + type: 'hidden', + name: 'secret-stuff', + defaultValue: "I'm invisible!", + }, + { + label: "There's more than one field here!", + name: 'custom-hidden-input', + type: 'email', + size: 12, + }, + ], + }, +}; + +export const SweetContainer: Story = { + args: { + fields: [ + { + label: 'This is our sticky sweet label', + name: 'sweet-container', + type: 'sweet-container', + }, + { + label: "There's something sticky and sweet here!", + name: 'custom-input', + type: 'email', + size: 12, + }, + ], + }, +}; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.mdx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.mdx index d27b0eb5ab0..0d720d68bbd 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.mdx @@ -1,12 +1,12 @@ import { Canvas, Controls, Meta } from '@storybook/blocks'; -import { ComponentHeader, LinkTo } from '~styleguide/blocks'; +import { ComponentHeader } from '~styleguide/blocks'; import * as GridFormStories from './GridForm.stories'; export const parameters = { title: 'GridForm', - subtitle: `GridForm an efficient way to build and design forms on a grid.`, + subtitle: 'An efficient way to build and design forms on a grid.', design: { type: 'figma', url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1689%3A3910', @@ -23,355 +23,6 @@ export const parameters = { -## Usage - -The GridForm organism provides an easy, out-of-the-box way to implement forms from a list of fields. When provided a list of fields, GridForm strings together the appropriate Form Elements inside a LayoutGrid. - -GridForm provides the following benefits: - -1. **Simplicity**: This organism takes in plain JSON-like props and uses them to string together a validated form -2. **Accessibility**: All GridForms handle accessibility styling and behaviors, passing tests out-of-the-box -3. **Functionality**: Validation and submission logic is handled by the [react-hook-form](https://react-hook-form.com) library -4. **Visual Consistency**: Aligns all input elements with the correct vertical rhythms and grid spacing - -## Designing with GridForm - -All [Form Input](https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1189%3A0) components in the Figma library are consistent with their implementations in code. By setting the form inputs within the component's layout grid, we can design forms that are compatible with Gamut. - -The [GridForm page in Gamut](https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1689%3A3910) also contains several starter templates for incorporating this organism in your designs. - -- **Starter**: Contains sample components to begin creating your own form -- **Sections**: Contains the headers and dividers that are rendered in the optional Sections modules -- **Inline Submit**: A form incorporating the Inline Submit Button style of GridForm -- **Instructions**: Contains suggestions for modifying the Figma component for your own designs - -### Figma component instructions - -- Enable Layout Grid (^G) -- Select a `❖ GridForm` variant as a template - - Starter, Sections, Inline Submit -- Detatch the component to modify the `📐 LayoutGrid` -- Add, remove, and edit `⬦ Form Inputs` - - Input Field, TextArea, Checkbox, Radio Button, Select -- Customize `🚥 GridFormButtons` - - Submit button style (Fill/CTA), position, cancel button - -## Fields - -GridForm supports a comprehensive set of field types to cover various form input needs. Each field type has specific properties and validation options. - -### Text inputs - -Text inputs support various HTML input types including `text`, `email`, `password`, `number`, `tel`, `url`, `search`, `date`, `time`, and more. All text inputs share the same basic properties but may have different validation patterns. - - - -#### Default value - - - -#### Placeholder text - -Text inputs are allowed to have traditional `placeholder` text. -This is a somewhat dangerous behavior for accessibility, as browsers -generally don't render placeholder text with high enough color contrast -for AA standards. If you do need to use placeholder text, such as on -landing page forms that have been shown to have higher completion rates -with the text, please make sure the placeholder text doesn't add any new -information to the form -- it should really only rephrase the text label. - -See [this article](https://www.nngroup.com/articles/form-design-placeholders/) or -more details on why using placeholders is often bad. - - - -### Textarea input - - - -### Select input - - - -### Radio group input - - - -### File upload input - -File upload fields allow users to select and upload files. You can add custom validation to restrict file types and sizes. - - - -### Checkbox input - - - -#### Spacing - -Checkboxes can use tight spacing when you need them to fit in smaller areas: - - - -### Nested checkboxes input - -Nested checkboxes allow for hierarchical selection with parent-child relationships between options. Infinite levels of nesting are supported. Clicking a parent checkbox toggles all its children accordingly. Individual child checkboxes can be toggled independently. Checkbox states are `checked` if all children are checked, `indeterminate` if some children are checked, or `unchecked` if no children are checked. The values returned by the form on submit or update are an array of all selected values, including all children. - - - -### Custom inputs - -Some forms, such as the checkout flows that use Recurly, need to define -their own inputs. We can specify a 'custom' field type to with a [render prop](https://reactjs.org/docs/render-props.html). - -We also have a 'custom-group' type for when you are passing in a custom FormGroup - including a label. If you do not want GridForm to surface errors for your field, you should likely use a 'custom-group'. If you chose to go this route, please be aware of [accessibility best practices](https://www.deque.com/blog/anatomy-of-accessible-forms-best-practices/) for forms. - - - -### Hidden input - -Hidden inputs can be used to include data that users can't see or modify with the submission. For this implementation you can set the `defaultValue` in the object and it will be submitted with the regular form data. - - - -### Sweet container input - -"Sweet container" ([honeypot]()) inputs can be used to detect bots by providing a field that would not generally be clicked by human users, but might be triggered automatically by bots. - -We call it a "sweet container" so that bots do not immediately detect it as a honeypot input. - - - -## Form Action Buttons - -### Submit button position - -We can position the submit button by passing the `position` prop within `submit` with a -value of `'left'`, `'center'`, `'right'`, or `'stretch'`. The default is `'left'`. - - - - - - - - - -### Submit button type - -We can specify the type submit button by passing the `type` prop within `submit`. We can choose between -the `FillButton` or `CTAButton`. The default is `'fill'`. - - - - - -### Inline submit button - -We can make the Submit button inline with an input by setting the column -sizes so they fit on the same row (e.g size 8 for an input and size 4 for -the submit). - -We can additionally remove the label from text inputs and checkbox inputs. -Use the `hideLabel` prop to remove the label, allowing the submit button to -align nicely with the input. **However**, if using `hideLabel` to remove the default label, you should provide an `aria-label` and/or include another label to the right/left of the input to ensure the input is accessible. - - - -### Cancel button - -Optionally, include a cancel button. - - - -## Validation - -GridForm uses [react-hook-form](https://react-hook-form.com) for validation, providing a robust and flexible validation system. All validation rules are applied to individual fields through the `validation` property. - -You can control when validation occurs by setting the `validation` prop on the GridForm: - -- `'onSubmit'` (default) - Validate only when the form is submitted -- `'onChange'` - Validate on every change, submit button is disabled until all required fields are valid -- `'onTouched'` - Validate when a field loses focus - -### Required Fields - -The most common validation is making fields required. You can specify required validation as a boolean or with a custom error message: - -```tsx -// Simple required validation -validation: { - required: true -} - -// Required with custom error message -validation: { - required: 'Please enter your email address' -} -``` - -### Pattern Validation - -Use regular expressions to validate field formats like email addresses, phone numbers, or custom patterns: - -```tsx -validation: { - required: 'Email is required', - pattern: { - value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, - message: 'Please enter a valid email address' - } -} -``` - -### Custom Validation - -For complex validation logic, use custom validation functions. These functions receive the field value and can return `true` for valid input or an error message string: - -```tsx -validation: { - required: 'Please select a file', - validate: (files) => { - const file = files.item(0); - const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; - if (!allowedTypes.includes(file.type)) { - return 'Please upload a JPEG, PNG, or GIF image'; - } - if (file.size > 2 * 1024 * 1024) { - return 'File size must be less than 2MB'; - } - return true; - } -} -``` - -### String Length Validation - -Validate minimum and maximum string lengths: - -```tsx -validation: { - required: 'Message is required', - minLength: { - value: 10, - message: 'Message must be at least 10 characters' - }, - maxLength: { - value: 500, - message: 'Message cannot exceed 500 characters' - } -} -``` - -### Number Range Validation - -For number inputs, validate minimum and maximum values: - -```tsx -validation: { - required: 'Age is required', - min: { - value: 18, - message: 'Must be at least 18 years old' - }, - max: { - value: 99, - message: 'Age cannot exceed 99' - } -} -``` - -## Layout & Visual Styling - -### GridForm-atting - -We can use the `size` and `rowspan` props (borrowed from LayoutGrid) to customize the layouts of our GridForms. You can also customize the `FormRequiredText` using the `requiredTextProps`. `FormRequiredText` should be spaced apart from or be stylistically different from the text element above it. - - - -### `hideRequiredText` - -`hideRequiredText` will hide the '\* Required' text that appears at the top of our forms. This should only be hidden if the form has no required fields. - - - -### Solo field form - -Solo field form should always have their solo input be required. They should automagically not have the required/optional text - if you have a custom rendered hidden input, you may have to use the `hasSoloField` prop. - - - -### InfoTips - -A field can include our existing `InfoTip`. See the InfoTip story for more information on what props are available. - -See the Radio story for an example of how to add a infotip to a radio option. - - - -### Sections - -Our `GridForm`s optionally take an array of sections that have left and center-aligned variants. - -Each sections should have a title that correctly follows [heading ranks](https://usability.yale.edu/web-accessibility/articles/headings). You can set the text type of this title though the `as` prop on that section -- the default is `h2`. You can only set the `as` prop to a heading. - -You can set the Text variant prop for the section title the same way. Only title variants are reccomended, but if you need more granular control of the Text component, you can pass them into `titleWrapperProps`. - -When using the left-aligned layout, please note that the `title` takes up 3 columns, so any field size over 9 may cause inconsistent behavior! - - - -## Field Behavior & State Management - -### Disabled inputs - -If an input is not meant to be usable, such as when a portion of a form is disabled pending user action, you can make it visually disabled with a `disabled` field member. - - - -### On field update - -A field can take an onUpdate callback. This callback will fire when the -field's value changes. This could be useful if you need to use the -field's value in a parent component before onSubmit gets triggered. - - - -### Loading and disabled states - -You can set the state of the submit button to `loading` as `true` to show a loading spinner. This is useful when you need to show the user that the form is submitting. -You can also set `disabled` to `true` to disable submission. - - - -### Disabled fields on submit - -`disableFieldsOnSubmit` will disable all form fields once the form has been successfully submitted. If you have any server-side validation that needs to happen, we recommend using the `wasSubmitSuccessful` prop, but submission will also fail if a promise is rejected within your `onSubmit` or if a field does not pass validation. - - - -### Reset form on submit - -`resetOnSubmit` will reset the form once the GridForm has been successfully submitted. If you have any server validation that needs to happen, we recommend using the `wasSubmitSuccessful` prop, but submission will also fail if a promise is rejected within your `onSubmit` or if a field does not pass validation. - - - -We can combine these together to create some pretty cool forms which have a loading state, disable their fields while submitting, and reset the form when the submit was successful. - - - -### Custom error - -A field can take a custom error in addition to validation errors. The validation error will always take precedence to the custom error. - - - -### Markdown errors - -GridForm renders errors through our Markdown component so we can optionally add markdown to our validation messages. - - - ## Playground diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx index a4fb66e9116..bb4fce3b884 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx @@ -1,8 +1,6 @@ -import { FormGroup, GridForm, Input, Markdown } from '@codecademy/gamut'; -import { Background } from '@codecademy/gamut-styles'; +import { GridForm } from '@codecademy/gamut'; import { action } from '@storybook/addon-actions'; import type { Meta, StoryObj } from '@storybook/react'; -import { useState } from 'react'; import type { TypeWithDeepControls } from 'storybook-addon-deep-controls'; const meta: TypeWithDeepControls> = { @@ -229,1082 +227,3 @@ export default meta; type Story = StoryObj; export const Default: Story = {}; - -export const TextField: Story = { - args: { - fields: [ - { - label: 'Simple text', - name: 'simple-text', - size: 9, - type: 'text', - }, - ], - }, -}; - -export const DefaultTextField: Story = { - args: { - fields: [ - { - defaultValue: 'yeet', - label: 'Text with default value', - name: 'text-with-default', - size: 9, - type: 'text', - }, - ], - }, -}; - -export const PlaceholderTextField: Story = { - args: { - fields: [ - { - label: 'Text with placeholder', - placeholder: 'Your email', - name: 'placeholder', - size: 9, - type: 'email', - }, - ], - }, -}; - -export const TextareaField: Story = { - args: { - fields: [ - { - label: 'Write a paragraph about penguins', - name: 'textarea-input', - size: 9, - type: 'textarea', - }, - ], - }, -}; - -export const SelectField: Story = { - args: { - fields: [ - { - label: 'Simple select', - name: 'simple-select', - options: ['', 'One fish', 'Two fish', 'Red fish', 'Blue fish'], - size: 9, - type: 'select', - }, - ], - }, -}; - -export const RadioGroupField: Story = { - args: { - fields: [ - { - label: 'Preferred Modern Artist', - name: 'artist', - options: [ - { - label: 'Cardi B', - value: 'cardi', - }, - { - label: 'Nicki Minaj', - value: 'nicki', - }, - ], - size: 9, - type: 'radio-group', - }, - ], - }, -}; - -export const FileUploadField: Story = { - args: { - fields: [ - { - label: 'Upload a cat image (we support pdf, jpeg, or png files)', - name: 'file-input', - size: 9, - type: 'file', - validation: { - required: true, - validate: (files) => { - const { type } = files.item(0); - const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png']; - if (!allowedTypes.includes(type)) - return 'Please upload a pdf, jpeg, or png file.'; - return true; - }, - }, - }, - ], - }, -}; - -export const CheckboxField: Story = { - args: { - fields: [ - { - description: 'I agree to the terms', - label: 'Terms', - name: 'terms', - size: 6, - type: 'checkbox', - id: 'my-super-cool-id', - }, - { - description: 'I agree to the conditions', - label: 'Conditions', - name: 'conditions', - size: 6, - type: 'checkbox', - id: 'my-super-cool-id2', - }, - ], - }, -}; - -export const CheckboxSpacing: Story = { - args: { - fields: [ - { - description: 'I agree to the terms', - label: 'Terms', - name: 'terms', - size: 6, - type: 'checkbox', - id: 'terms-id', - spacing: 'tight', - }, - { - description: 'I agree to the conditions', - label: 'Conditions', - name: 'conditions', - size: 6, - type: 'checkbox', - id: 'conditions-id', - spacing: 'tight', - }, - ], - }, -}; - -export const NestedCheckboxesField: Story = { - args: { - fields: [ - { - label: 'Nested checkboxes', - name: 'nested-checkboxes', - type: 'nested-checkboxes', - defaultValue: ['backend', 'react', 'vue'], - options: [ - { - value: 'frontend', - label: 'Frontend Technologies', - options: [ - { - value: 'react', - label: 'React', - options: [ - { value: 'nextjs', label: 'Next.js' }, - { value: 'typescript', label: 'TypeScript' }, - ], - }, - { - value: 'vue', - label: 'Vue.js', - }, - { value: 'angular', label: 'Angular' }, - ], - }, - { - value: 'backend', - label: 'Backend Technologies', - options: [ - { value: 'node', label: 'Node.js' }, - { value: 'python', label: 'Python' }, - { value: 'java', label: 'Java' }, - ], - }, - ], - size: 12, - }, - ], - }, -}; - -export const CustomInputs: Story = { - args: { - fields: [ - { - render: ({ error, setValue }) => ( - <> - setValue(event.target.value)} - /> - 🕺 - - ), - label: 'Gimme two more swags', - name: 'custom-input', - size: 12, - validation: { - required: true, - pattern: { - value: /swag(.*)swag/, - message: 'Still not enough swag, what are you doing... 💢', - }, - }, - type: 'custom', - }, - { - render: ({ error, setValue }) => ( - - setValue(event.target.value)} - /> - - ), - size: 12, - label: 'Gimme two more swags', - name: 'custom-input-group', - validation: { - required: true, - pattern: { - value: /swag(.*)swag/, - message: 'Still not enough swag, what are you doing... 💢', - }, - }, - type: 'custom-group', - }, - ], - }, -}; - -export const HiddenInput: Story = { - args: { - fields: [ - { - type: 'hidden', - name: 'secret-stuff', - defaultValue: "I'm invisible!", - }, - { - label: "There's more than one field here!", - name: 'custom-hidden-input', - type: 'email', - size: 12, - }, - ], - }, -}; - -export const SweetContainer: Story = { - args: { - fields: [ - { - label: 'This is our sticky sweet label', - name: 'sweet-container', - type: 'sweet-container', - }, - { - label: "There's something sticky and sweet here!", - name: 'custom-input', - type: 'email', - size: 12, - }, - ], - }, -}; - -export const SubmitButtonRight: Story = { - args: { - fields: [ - { - label: 'Simple text', - name: 'right-sub-simple-text', - type: 'text', - size: 12, - }, - ], - submit: { - contents: 'Right Submit!?', - position: 'right', - size: 12, - }, - }, -}; - -export const SubmitButtonLeft: Story = { - args: { - fields: [ - { - label: 'Simple text', - name: 'left-sub-simple-text', - type: 'text', - size: 12, - }, - ], - submit: { - contents: 'Left Submit!?', - position: 'left', - size: 12, - }, - }, -}; - -export const SubmitButtonCenter: Story = { - args: { - fields: [ - { - label: 'Simple text', - name: 'center-sub-simple-text', - type: 'text', - size: 12, - }, - ], - submit: { - contents: 'Center Submit!?', - position: 'center', - size: 12, - }, - }, -}; - -export const SubmitButtonStretch: Story = { - args: { - fields: [ - { - label: 'Simple text', - name: 'stretch-sub-simple-text', - type: 'text', - size: 12, - }, - ], - submit: { - contents: 'Stretch Submit!?', - position: 'stretch', - size: 12, - }, - }, -}; - -export const SubmitButtonFill: Story = { - args: { - fields: [ - { - label: 'Simple text', - name: 'fill-sub-simple-text', - type: 'text', - size: 12, - }, - ], - submit: { - contents: 'Fill Submit!?', - type: 'fill', - size: 12, - }, - }, -}; - -export const SubmitButtonCTA: Story = { - args: { - fields: [ - { - label: 'Simple text', - name: 'cta-sub-simple-text', - type: 'text', - size: 12, - }, - ], - submit: { - contents: 'CTA Submit!?', - type: 'cta', - size: 12, - }, - }, -}; - -export const SubmitButtonInline: Story = { - args: { - fields: [ - { - hideLabel: true, - label: 'Label', - name: 'email', - size: 8, - type: 'text', - }, - ], - submit: { - contents: 'Inline Submit!?', - size: 4, - position: 'right', - }, - }, -}; - -export const CancelButton: Story = { - args: { - fields: [ - { - label: 'Simple text', - name: 'cancel-sub-simple-text', - type: 'text', - size: 12, - }, - ], - cancel: { - children: 'Cancel', - onClick: () => {}, - }, - submit: { - contents: 'Submit!?', - position: 'right', - size: 12, - }, - }, -}; - -export const Formatted: Story = { - args: { - fields: [ - { - label: 'Fave Gamut Component', - name: 'rowspan-radiogroup', - options: [ - { - label: 'FlexBox', - value: 'flex', - }, - { - label: 'GridForm', - value: 'grid', - }, - { - label: 'Text', - value: 'text', - }, - ], - size: 3, - rowspan: 3, - type: 'radio-group', - validation: { - required: 'You gotta pick one!', - }, - }, - { - label: 'Simple text', - name: 'rowspan-simple-text', - size: 3, - type: 'text', - }, - { - defaultValue: 'yeet', - label: 'Text with default value', - name: 'text-with-default-formatting', - size: 4, - type: 'text', - }, - { - label: 'Simple select (required)', - name: 'simple-select-formatting', - options: ['', 'One fish', 'Two fish', 'Red fish', 'Blue fish'], - size: 5, - type: 'select', - validation: { - required: 'Please select an option', - }, - }, - ], - requiredTextProps: { color: 'danger', variant: 'title-xs' }, - }, -}; - -export const HideRequiredText: Story = { - args: { - fields: [ - { - label: 'A field', - placeholder: 'I am very optional', - name: 'very-optional', - type: 'text', - size: 12, - }, - { - label: 'A field', - placeholder: 'I am very optional', - name: 'very-optional', - type: 'text', - size: 12, - }, - ], - hideRequiredText: true, - }, -}; - -export const SoloField: Story = { - args: { - fields: [ - { - label: 'A field', - placeholder: 'I am a required solo field', - name: 'so-required', - type: 'text', - size: 12, - validation: { - required: 'I am required', - }, - }, - ], - }, -}; - -export const InfoTip: Story = { - args: { - fields: [ - { - infotip: { - info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - alignment: 'bottom-left', - }, - label: 'Tool input', - name: 'input-field', - size: 6, - type: 'text', - }, - { - infotip: { - info: , - alignment: 'bottom-right', - }, - label: 'Select with infotip', - options: ['', 'Water', 'Earth', 'Fire', 'Air', 'Boomerang'], - size: 3, - type: 'select', - validation: { - required: 'Please select an option', - }, - name: 'select-field', - }, - { - infotip: { - info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - }, - label: 'Write a paragraph about infotips', - name: 'textarea-field', - size: 6, - type: 'textarea', - rows: 6, - placeholder: 'Check out my infotip', - validation: { - required: 'Please write something about infotips!', - }, - }, - { - infotip: { - info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - }, - label: 'Preferred Modern Artist', - name: 'modern-artist', - options: [ - { - label: 'Taylor Swift', - value: 'taylor-swift', - }, - { - label: 'Beyonce', - value: 'beyonce', - }, - ], - size: 3, - type: 'radio-group', - validation: { - required: 'You gotta pick one!', - }, - }, - { - infotip: { - info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - alignment: 'bottom-right', - }, - label: 'End User License Agreement', - description: 'I promise that I read it', - name: 'eula-checkbox-required-agreement', - size: 4, - type: 'checkbox', - validation: { - required: 'Please check the box to agree to the terms.', - }, - }, - ], - }, -}; - -const DisabledInputsExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); -}; - -export const Sections: Story = { - args: { - fields: [ - { - title: 'first section', - layout: 'left', - variant: 'title-xs', - titleWrapperProps: { - color: 'danger', - }, - fields: [ - { - label: 'hi?', - name: 'text01-left-section', - size: 4, - type: 'text', - validation: { - required: true, - }, - }, - { - label: 'hello?', - name: 'text02-left-section', - size: 4, - type: 'text', - validation: { - required: true, - }, - }, - { - label: 'Write a paragraph.', - name: 'paragraph01-left-section', - size: 8, - type: 'textarea', - validation: { - required: 'Please write something about penguins!', - }, - }, - { - label: 'howdy?', - name: 'text03-left-section', - size: 4, - type: 'text', - validation: { - required: true, - }, - }, - { - label: 'whats up?', - name: 'text04--left-section', - size: 4, - type: 'text', - validation: { - required: true, - }, - }, - { - label: 'Write another long paragraph', - name: 'paragraph02-left-section', - size: 8, - type: 'textarea', - validation: { - required: 'Please write something about penguins!', - }, - }, - ], - }, - { - title: 'hi there... again', - as: 'h3', - fields: [ - { - label: 'hello....', - name: 'text01-center-section', - size: 5, - type: 'text', - validation: { - required: true, - }, - }, - ], - }, - ], - requiredTextProps: { color: 'primary', fontStyle: 'italic' }, - }, -}; - -export const DisabledInputs: Story = { - render: () => , -}; - -const OnFieldUpdateExample = () => { - const [text, setText] = useState(''); - return ( - <> - <>The text value is: {text} - { - action('Form Submitted')(values); - }} - /> - - ); -}; - -export const OnFieldUpdate: Story = { - render: () => , -}; - -const CustomErrorExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); -}; - -export const CustomError: Story = { - render: () => , -}; - -const MarkdownErrorsExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); -}; - -export const MarkdownErrors: Story = { - render: () => , -}; - -const LoadingAndDisabledExample = () => { - return ( - <> - - { - action('Form Submitted')(values); - }} - /> - - - { - action('Form Submitted')(values); - }} - /> - - - ); -}; - -export const LoadingAndDisabled: Story = { - render: () => , -}; - -const DisabledFieldsOnSubmitExample = () => { - return ( - <> - - { - action('Form Submitted')(values); - }} - /> - - - { - action('Form Submitted')(values); - }} - /> - - - - false || "It's never gonna work out between us", - }, - }, - size: 12, - }, - ]} - hideRequiredText - submit={{ - contents: 'Submit Me 🤷🏻', - size: 12, - }} - onSubmit={(values) => { - action('Form Submitted')(values); - }} - /> - - - ); -}; - -export const DisabledFieldsOnSubmit: Story = { - render: () => , -}; - -const ResetOnSubmitExample = () => { - return ( - <> - - { - action('Form Submitted')(values); - }} - /> - - - ); -}; - -export const ResetOnSubmit: Story = { - render: () => , -}; - -export const FormLoadingExample = () => { - const [loading, setLoading] = useState(false); - - const wait = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const onSubmit = async () => { - setLoading(true); - await wait(2000); - setLoading(false); - }; - - return ( - - - - ); -}; - -export const FormLoading: Story = { - render: () => , -}; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx new file mode 100644 index 00000000000..1462ae59034 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx @@ -0,0 +1,55 @@ +import { Canvas, Meta } from '@storybook/blocks'; + +import { ComponentHeader, LinkTo } from '~styleguide/blocks'; + +import * as LayoutStories from './Layout.stories'; + +export const parameters = { + title: 'Layout', + subtitle: 'Customize the visual layout and styling of your forms.', +}; + + + + + +## Layout & Visual Styling + +### GridForm-atting + +We can use the `size` and `rowspan` props (borrowed from LayoutGrid) to customize the layouts of our GridForms. You can also customize the `FormRequiredText` using the `requiredTextProps`. `FormRequiredText` should be spaced apart from or be stylistically different from the text element above it. + + + +### `hideRequiredText` + +`hideRequiredText` will hide the '\* Required' text that appears at the top of our forms. This should only be hidden if the form has no required fields. + + + +### Solo field form + +Solo field form should always have their solo input be required. They should automagically not have the required/optional text - if you have a custom rendered hidden input, you may have to use the `hasSoloField` prop. + + + +### InfoTips + +A field can include our existing `InfoTip`. See the InfoTip story for more information on what props are available. + +See the Radio story for an example of how to add a infotip to a radio option. + + + +### Sections + +Our `GridForm`s optionally take an array of sections that have left and center-aligned variants. + +Each sections should have a title that correctly follows [heading ranks](https://usability.yale.edu/web-accessibility/articles/headings). You can set the text type of this title though the `as` prop on that section -- the default is `h2`. You can only set the `as` prop to a heading. + +You can set the Text variant prop for the section title the same way. Only title variants are reccomended, but if you need more granular control of the Text component, you can pass them into `titleWrapperProps`. + +When using the left-aligned layout, please note that the `title` takes up 3 columns, so any field size over 9 may cause inconsistent behavior! + + + diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Layout.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/Layout.stories.tsx new file mode 100644 index 00000000000..407191e62fb --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/Layout.stories.tsx @@ -0,0 +1,284 @@ +import { GridForm, Markdown } from '@codecademy/gamut'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + component: GridForm, + args: { + onSubmit: (values) => { + action('Form Submitted')(values); + // eslint-disable-next-line no-console + console.log('Form Submitted', values); + }, + submit: { + contents: 'Submit', + size: 4, + position: 'left', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Formatted: Story = { + args: { + fields: [ + { + label: 'Fave Gamut Component', + name: 'rowspan-radiogroup', + options: [ + { + label: 'FlexBox', + value: 'flex', + }, + { + label: 'GridForm', + value: 'grid', + }, + { + label: 'Text', + value: 'text', + }, + ], + size: 3, + rowspan: 3, + type: 'radio-group', + validation: { + required: 'You gotta pick one!', + }, + }, + { + label: 'Simple text', + name: 'rowspan-simple-text', + size: 3, + type: 'text', + }, + { + defaultValue: 'yeet', + label: 'Text with default value', + name: 'text-with-default-formatting', + size: 4, + type: 'text', + }, + { + label: 'Simple select (required)', + name: 'simple-select-formatting', + options: ['', 'One fish', 'Two fish', 'Red fish', 'Blue fish'], + size: 5, + type: 'select', + validation: { + required: 'Please select an option', + }, + }, + ], + requiredTextProps: { color: 'danger', variant: 'title-xs' }, + }, +}; + +export const HideRequiredText: Story = { + args: { + fields: [ + { + label: 'A field', + placeholder: 'I am very optional', + name: 'very-optional', + type: 'text', + size: 12, + }, + { + label: 'A field', + placeholder: 'I am very optional', + name: 'very-optional', + type: 'text', + size: 12, + }, + ], + hideRequiredText: true, + }, +}; + +export const SoloField: Story = { + args: { + fields: [ + { + label: 'A field', + placeholder: 'I am a required solo field', + name: 'so-required', + type: 'text', + size: 12, + validation: { + required: 'I am required', + }, + }, + ], + }, +}; + +export const InfoTip: Story = { + args: { + fields: [ + { + infotip: { + info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + alignment: 'bottom-left', + }, + label: 'Tool input', + name: 'input-field', + size: 6, + type: 'text', + }, + { + infotip: { + info: , + alignment: 'bottom-right', + }, + label: 'Select with infotip', + options: ['', 'Water', 'Earth', 'Fire', 'Air', 'Boomerang'], + size: 3, + type: 'select', + validation: { + required: 'Please select an option', + }, + name: 'select-field', + }, + { + infotip: { + info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + label: 'Write a paragraph about infotips', + name: 'textarea-field', + size: 6, + type: 'textarea', + rows: 6, + placeholder: 'Check out my infotip', + validation: { + required: 'Please write something about infotips!', + }, + }, + { + infotip: { + info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + label: 'Preferred Modern Artist', + name: 'modern-artist', + options: [ + { + label: 'Taylor Swift', + value: 'taylor-swift', + }, + { + label: 'Beyonce', + value: 'beyonce', + }, + ], + size: 3, + type: 'radio-group', + validation: { + required: 'You gotta pick one!', + }, + }, + { + infotip: { + info: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + alignment: 'bottom-right', + }, + label: 'End User License Agreement', + description: 'I promise that I read it', + name: 'eula-checkbox-required-agreement', + size: 4, + type: 'checkbox', + validation: { + required: 'Please check the box to agree to the terms.', + }, + }, + ], + }, +}; + +export const Sections: Story = { + args: { + fields: [ + { + title: 'first section', + layout: 'left', + variant: 'title-xs', + titleWrapperProps: { + color: 'danger', + }, + fields: [ + { + label: 'hi?', + name: 'text01-left-section', + size: 4, + type: 'text', + validation: { + required: true, + }, + }, + { + label: 'hello?', + name: 'text02-left-section', + size: 4, + type: 'text', + validation: { + required: true, + }, + }, + { + label: 'Write a paragraph.', + name: 'paragraph01-left-section', + size: 8, + type: 'textarea', + validation: { + required: 'Please write something about penguins!', + }, + }, + { + label: 'howdy?', + name: 'text03-left-section', + size: 4, + type: 'text', + validation: { + required: true, + }, + }, + { + label: 'whats up?', + name: 'text04--left-section', + size: 4, + type: 'text', + validation: { + required: true, + }, + }, + { + label: 'Write another long paragraph', + name: 'paragraph02-left-section', + size: 8, + type: 'textarea', + validation: { + required: 'Please write something about penguins!', + }, + }, + ], + }, + { + title: 'hi there... again', + as: 'h3', + fields: [ + { + label: 'hello....', + name: 'text01-center-section', + size: 5, + type: 'text', + validation: { + required: true, + }, + }, + ], + }, + ], + requiredTextProps: { color: 'primary', fontStyle: 'italic' }, + }, +}; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx new file mode 100644 index 00000000000..defae34bdec --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx @@ -0,0 +1,66 @@ +import { Canvas, Meta } from '@storybook/blocks'; + +import { ComponentHeader, LinkTo } from '~styleguide/blocks'; + +import * as StatesStories from './States.stories'; + +export const parameters = { + title: 'States', + subtitle: 'Manage field behavior and form state.', +}; + + + + + +## Field Behavior & State Management + +### Disabled inputs + +If an input is not meant to be usable, such as when a portion of a form is disabled pending user action, you can make it visually disabled with a `disabled` field member. + + + +### On field update + +A field can take an onUpdate callback. This callback will fire when the +field's value changes. This could be useful if you need to use the +field's value in a parent component before onSubmit gets triggered. + + + +### Loading and disabled states + +You can set the state of the submit button to `loading` as `true` to show a loading spinner. This is useful when you need to show the user that the form is submitting. +You can also set `disabled` to `true` to disable submission. + + + +### Disabled fields on submit + +`disableFieldsOnSubmit` will disable all form fields once the form has been successfully submitted. If you have any server-side validation that needs to happen, we recommend using the `wasSubmitSuccessful` prop, but submission will also fail if a promise is rejected within your `onSubmit` or if a field does not pass validation. + + + +### Reset form on submit + +`resetOnSubmit` will reset the form once the GridForm has been successfully submitted. If you have any server validation that needs to happen, we recommend using the `wasSubmitSuccessful` prop, but submission will also fail if a promise is rejected within your `onSubmit` or if a field does not pass validation. + + + +We can combine these together to create some pretty cool forms which have a loading state, disable their fields while submitting, and reset the form when the submit was successful. + + + +### Custom error + +A field can take a custom error in addition to validation errors. The validation error will always take precedence to the custom error. + + + +### Markdown errors + +GridForm renders errors through our Markdown component so we can optionally add markdown to our validation messages. + + + diff --git a/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx new file mode 100644 index 00000000000..ad10a8ed384 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx @@ -0,0 +1,384 @@ +import { GridForm } from '@codecademy/gamut'; +import { Background } from '@codecademy/gamut-styles'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +const meta: Meta = { + component: GridForm, +}; + +export default meta; +type Story = StoryObj; + +const DisabledInputsExample = () => { + return ( + { + action('Form Submitted')(values); + }} + /> + ); +}; + +export const DisabledInputs: Story = { + render: () => , +}; + +const OnFieldUpdateExample = () => { + const [text, setText] = useState(''); + return ( + <> + <>The text value is: {text} + { + action('Form Submitted')(values); + }} + /> + + ); +}; + +export const OnFieldUpdate: Story = { + render: () => , +}; + +const LoadingAndDisabledExample = () => { + return ( + <> + + { + action('Form Submitted')(values); + }} + /> + + + { + action('Form Submitted')(values); + }} + /> + + + ); +}; + +export const LoadingAndDisabled: Story = { + render: () => , +}; + +const DisabledFieldsOnSubmitExample = () => { + return ( + <> + + { + action('Form Submitted')(values); + }} + /> + + + { + action('Form Submitted')(values); + }} + /> + + + + false || "It's never gonna work out between us", + }, + }, + size: 12, + }, + ]} + hideRequiredText + submit={{ + contents: 'Submit Me 🤷🏻', + size: 12, + }} + onSubmit={(values) => { + action('Form Submitted')(values); + }} + /> + + + ); +}; + +export const DisabledFieldsOnSubmit: Story = { + render: () => , +}; + +const ResetOnSubmitExample = () => { + return ( + <> + + { + action('Form Submitted')(values); + }} + /> + + + ); +}; + +export const ResetOnSubmit: Story = { + render: () => , +}; + +export const FormLoadingExample = () => { + const [loading, setLoading] = useState(false); + + const wait = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const onSubmit = async () => { + setLoading(true); + await wait(2000); + setLoading(false); + }; + + return ( + + + + ); +}; + +export const FormLoading: Story = { + render: () => , +}; + +const CustomErrorExample = () => { + return ( + { + action('Form Submitted')(values); + }} + /> + ); +}; + +export const CustomError: Story = { + render: () => , +}; + +const MarkdownErrorsExample = () => { + return ( + { + action('Form Submitted')(values); + }} + /> + ); +}; + +export const MarkdownErrors: Story = { + render: () => , +}; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Usage.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Usage.mdx new file mode 100644 index 00000000000..2b30506426b --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/Usage.mdx @@ -0,0 +1,46 @@ +import { Meta } from '@storybook/blocks'; + +import { ComponentHeader, LinkTo } from '~styleguide/blocks'; + +export const parameters = { + title: 'Usage', + subtitle: 'How to use and design with GridForm.', +}; + + + + + +## Usage + +The GridForm organism provides an easy, out-of-the-box way to implement forms from a list of fields. When provided a list of fields, GridForm strings together the appropriate Form Elements inside a LayoutGrid. + +GridForm provides the following benefits: + +1. **Simplicity**: This organism takes in plain JSON-like props and uses them to string together a validated form +2. **Accessibility**: All GridForms handle accessibility styling and behaviors, passing tests out-of-the-box +3. **Functionality**: Validation and submission logic is handled by the [react-hook-form](https://react-hook-form.com) library +4. **Visual Consistency**: Aligns all input elements with the correct vertical rhythms and grid spacing + +## Designing with GridForm + +All [Form Input](https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1189%3A0) components in the Figma library are consistent with their implementations in code. By setting the form inputs within the component's layout grid, we can design forms that are compatible with Gamut. + +The [GridForm page in Gamut](https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1689%3A3910) also contains several starter templates for incorporating this organism in your designs. + +- **Starter**: Contains sample components to begin creating your own form +- **Sections**: Contains the headers and dividers that are rendered in the optional Sections modules +- **Inline Submit**: A form incorporating the Inline Submit Button style of GridForm +- **Instructions**: Contains suggestions for modifying the Figma component for your own designs + +### Figma component instructions + +- Enable Layout Grid (^G) +- Select a `❖ GridForm` variant as a template + - Starter, Sections, Inline Submit +- Detatch the component to modify the `📐 LayoutGrid` +- Add, remove, and edit `⬦ Form Inputs` + - Input Field, TextArea, Checkbox, Radio Button, Select +- Customize `🚥 GridFormButtons` + - Submit button style (Fill/CTA), position, cancel button + diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx new file mode 100644 index 00000000000..0232291cd4f --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx @@ -0,0 +1,110 @@ +import { Meta } from '@storybook/blocks'; + +import { ComponentHeader } from '~styleguide/blocks'; + +export const parameters = { + title: 'Validation', + subtitle: 'Robust form validation using react-hook-form.', +}; + + + + + +## Validation + +GridForm uses [react-hook-form](https://react-hook-form.com) for validation, providing a robust and flexible validation system. All validation rules are applied to individual fields through the `validation` property. + +You can control when validation occurs by setting the `validation` prop on the GridForm: + +- `'onSubmit'` (default) - Validate only when the form is submitted +- `'onChange'` - Validate on every change, submit button is disabled until all required fields are valid +- `'onTouched'` - Validate when a field loses focus + +### Required Fields + +The most common validation is making fields required. You can specify required validation as a boolean or with a custom error message: + +```tsx +// Simple required validation +validation: { + required: true +} + +// Required with custom error message +validation: { + required: 'Please enter your email address' +} +``` + +### Pattern Validation + +Use regular expressions to validate field formats like email addresses, phone numbers, or custom patterns: + +```tsx +validation: { + required: 'Email is required', + pattern: { + value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: 'Please enter a valid email address' + } +} +``` + +### Custom Validation + +For complex validation logic, use custom validation functions. These functions receive the field value and can return `true` for valid input or an error message string: + +```tsx +validation: { + required: 'Please select a file', + validate: (files) => { + const file = files.item(0); + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + return 'Please upload a JPEG, PNG, or GIF image'; + } + if (file.size > 2 * 1024 * 1024) { + return 'File size must be less than 2MB'; + } + return true; + } +} +``` + +### String Length Validation + +Validate minimum and maximum string lengths: + +```tsx +validation: { + required: 'Message is required', + minLength: { + value: 10, + message: 'Message must be at least 10 characters' + }, + maxLength: { + value: 500, + message: 'Message cannot exceed 500 characters' + } +} +``` + +### Number Range Validation + +For number inputs, validate minimum and maximum values: + +```tsx +validation: { + required: 'Age is required', + min: { + value: 18, + message: 'Must be at least 18 years old' + }, + max: { + value: 99, + message: 'Age cannot exceed 99' + } +} +``` + From 3d94191cf9157e109cd48c722b0016a433a7f498 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 24 Oct 2025 16:30:59 -0400 Subject: [PATCH 30/44] clean up --- .../src/lib/Organisms/GridForm/Buttons.mdx | 23 +- .../Organisms/GridForm/Buttons.stories.tsx | 83 +-- .../src/lib/Organisms/GridForm/Fields.mdx | 32 +- .../src/lib/Organisms/GridForm/Layout.mdx | 12 +- .../src/lib/Organisms/GridForm/States.mdx | 23 +- .../lib/Organisms/GridForm/States.stories.tsx | 532 +++++++----------- .../src/lib/Organisms/GridForm/Validation.mdx | 10 +- 7 files changed, 265 insertions(+), 450 deletions(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Buttons.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Buttons.mdx index 13a5973b0c4..9a3791934df 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Buttons.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Buttons.mdx @@ -13,9 +13,7 @@ export const parameters = { -## Form Action Buttons - -### Submit button position +## Submit button position We can position the submit button by passing the `position` prop within `submit` with a value of `'left'`, `'center'`, `'right'`, or `'stretch'`. The default is `'left'`. @@ -28,7 +26,7 @@ value of `'left'`, `'center'`, `'right'`, or `'stretch'`. The default is `'left' -### Submit button type +## Submit button type We can specify the type submit button by passing the `type` prop within `submit`. We can choose between the `FillButton` or `CTAButton`. The default is `'fill'`. @@ -37,7 +35,7 @@ the `FillButton` or -## Fields - -GridForm supports a comprehensive set of field types to cover various form input needs. Each field type has specific properties and validation options. - -### Text inputs +## Text inputs Text inputs support various HTML input types including `text`, `email`, `password`, `number`, `tel`, `url`, `search`, `date`, `time`, and more. All text inputs share the same basic properties but may have different validation patterns. -#### Default value +### Default value -#### Placeholder text +### Placeholder text Text inputs are allowed to have traditional `placeholder` text. This is a somewhat dangerous behavior for accessibility, as browsers @@ -42,41 +38,41 @@ more details on why using placeholders is often bad. -### Textarea input +## Textarea input -### Select input +## Select input -### Radio group input +## Radio group input -### File upload input +## File upload input File upload fields allow users to select and upload files. You can add custom validation to restrict file types and sizes. -### Checkbox input +## Checkbox input -#### Spacing +### Spacing Checkboxes can use tight spacing when you need them to fit in smaller areas: -### Nested checkboxes input +## Nested checkboxes input Nested checkboxes allow for hierarchical selection with parent-child relationships between options. Infinite levels of nesting are supported. Clicking a parent checkbox toggles all its children accordingly. Individual child checkboxes can be toggled independently. Checkbox states are `checked` if all children are checked, `indeterminate` if some children are checked, or `unchecked` if no children are checked. The values returned by the form on submit or update are an array of all selected values, including all children. -### Custom inputs +## Custom inputs Some forms, such as the checkout flows that use Recurly, need to define their own inputs. We can specify a 'custom' field type to with a [render prop](https://reactjs.org/docs/render-props.html). @@ -85,13 +81,13 @@ We also have a 'custom-group' type for when you are passing in a custom FormGrou -### Hidden input +## Hidden input Hidden inputs can be used to include data that users can't see or modify with the submission. For this implementation you can set the `defaultValue` in the object and it will be submitted with the regular form data. -### Sweet container input +## Sweet container input "Sweet container" ([honeypot]()) inputs can be used to detect bots by providing a field that would not generally be clicked by human users, but might be triggered automatically by bots. diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx index 1462ae59034..a39e03721c5 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx @@ -13,27 +13,25 @@ export const parameters = { -## Layout & Visual Styling - -### GridForm-atting +## GridForm-atting We can use the `size` and `rowspan` props (borrowed from LayoutGrid) to customize the layouts of our GridForms. You can also customize the `FormRequiredText` using the `requiredTextProps`. `FormRequiredText` should be spaced apart from or be stylistically different from the text element above it. -### `hideRequiredText` +## `hideRequiredText` `hideRequiredText` will hide the '\* Required' text that appears at the top of our forms. This should only be hidden if the form has no required fields. -### Solo field form +## Solo field form Solo field form should always have their solo input be required. They should automagically not have the required/optional text - if you have a custom rendered hidden input, you may have to use the `hasSoloField` prop. -### InfoTips +## InfoTips A field can include our existing `InfoTip`. See the InfoTip story for more information on what props are available. @@ -41,7 +39,7 @@ See the Radio story for an e -### Sections +## Sections Our `GridForm`s optionally take an array of sections that have left and center-aligned variants. diff --git a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx index defae34bdec..5324dab351a 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx @@ -13,15 +13,13 @@ export const parameters = { -## Field Behavior & State Management - -### Disabled inputs +## Disabled inputs If an input is not meant to be usable, such as when a portion of a form is disabled pending user action, you can make it visually disabled with a `disabled` field member. -### On field update +## On field update A field can take an onUpdate callback. This callback will fire when the field's value changes. This could be useful if you need to use the @@ -29,36 +27,31 @@ field's value in a parent component before onSubmit gets triggered. -### Loading and disabled states - -You can set the state of the submit button to `loading` as `true` to show a loading spinner. This is useful when you need to show the user that the form is submitting. -You can also set `disabled` to `true` to disable submission. - - - -### Disabled fields on submit +## Disabled fields on submit `disableFieldsOnSubmit` will disable all form fields once the form has been successfully submitted. If you have any server-side validation that needs to happen, we recommend using the `wasSubmitSuccessful` prop, but submission will also fail if a promise is rejected within your `onSubmit` or if a field does not pass validation. -### Reset form on submit +## Reset form on submit `resetOnSubmit` will reset the form once the GridForm has been successfully submitted. If you have any server validation that needs to happen, we recommend using the `wasSubmitSuccessful` prop, but submission will also fail if a promise is rejected within your `onSubmit` or if a field does not pass validation. +## Putting it all together + We can combine these together to create some pretty cool forms which have a loading state, disable their fields while submitting, and reset the form when the submit was successful. -### Custom error +## Custom error A field can take a custom error in addition to validation errors. The validation error will always take precedence to the custom error. -### Markdown errors +## Markdown errors GridForm renders errors through our Markdown component so we can optionally add markdown to our validation messages. diff --git a/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx index ad10a8ed384..615ddac00c5 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx @@ -1,5 +1,4 @@ import { GridForm } from '@codecademy/gamut'; -import { Background } from '@codecademy/gamut-styles'; import { action } from '@storybook/addon-actions'; import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; @@ -11,374 +10,229 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const DisabledInputsExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); -}; - export const DisabledInputs: Story = { - render: () => , -}; - -const OnFieldUpdateExample = () => { - const [text, setText] = useState(''); - return ( - <> - <>The text value is: {text} - { - action('Form Submitted')(values); - }} - /> - - ); + args: { + fields: [ + { + disabled: true, + label: 'Disabled text', + name: 'disabled-text', + type: 'text', + size: 6, + }, + { + label: 'Enabled text', + name: 'enabled-text', + type: 'text', + size: 6, + }, + ], + hideRequiredText: true, + submit: { + contents: 'Right Submit!?', + position: 'right', + size: 12, + }, + onSubmit: (values) => { + action('Form Submitted')(values); + }, + }, }; export const OnFieldUpdate: Story = { - render: () => , -}; - -const LoadingAndDisabledExample = () => { - return ( - <> - - { - action('Form Submitted')(values); - }} - /> - - - { - action('Form Submitted')(values); - }} - /> - - - ); -}; - -export const LoadingAndDisabled: Story = { - render: () => , -}; - -const DisabledFieldsOnSubmitExample = () => { - return ( - <> - - { - action('Form Submitted')(values); - }} - /> - - - { - action('Form Submitted')(values); - }} - /> - - + args: { + hideRequiredText: true, + submit: { + contents: 'Submit Me!?', + size: 12, + }, + onSubmit: (values) => { + action('Form Submitted')(values); + }, + }, + render: function OnFieldUpdate(args) { + const [text, setText] = useState(''); + return ( + <> + <>The text value is: {text} - false || "It's never gonna work out between us", - }, - }, - size: 12, + label: 'Text with onUpdate', + name: 'onUpdate-simple-text', + size: 3, + type: 'text', + onUpdate: setText, }, ]} - hideRequiredText - submit={{ - contents: 'Submit Me 🤷🏻', - size: 12, - }} - onSubmit={(values) => { - action('Form Submitted')(values); - }} /> - - - ); + + ); + }, }; export const DisabledFieldsOnSubmit: Story = { - render: () => , -}; - -const ResetOnSubmitExample = () => { - return ( - <> - - { - action('Form Submitted')(values); - }} - /> - - - ); + args: { + disableFieldsOnSubmit: true, + fields: [ + { + label: 'Email', + placeholder: 'i will disable on correct submission!', + name: 'disabled-fields-on-submit', + type: 'email', + validation: { + required: 'pls fill this out', + pattern: { + value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,})+$/, + message: 'that is not an email 😔', + }, + }, + size: 12, + }, + ], + hideRequiredText: true, + submit: { + contents: 'Submit Me 💖', + size: 12, + }, + onSubmit: (values) => { + action('Form Submitted')(values); + }, + }, }; export const ResetOnSubmit: Story = { - render: () => , + args: { + fields: [ + { + label: 'Email', + placeholder: 'i will reset on correct submission!', + name: 'reset-on-submit', + type: 'email', + validation: { + required: 'pls fill this out', + pattern: { + value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,})+$/, + message: 'that is not an email 😔', + }, + }, + size: 12, + }, + ], + hideRequiredText: true, + resetOnSubmit: true, + submit: { + contents: 'Submit Me 💖', + size: 12, + }, + onSubmit: (values) => { + action('Form Submitted')(values); + }, + }, }; -export const FormLoadingExample = () => { - const [loading, setLoading] = useState(false); - - const wait = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const onSubmit = async () => { - setLoading(true); - await wait(2000); - setLoading(false); - }; - - return ( - - + new Promise((resolve) => setTimeout(resolve, ms)); + + const onSubmit = async () => { + setLoading(true); + await wait(2000); + setLoading(false); + }; + + return ( + - - ); -}; - -export const FormLoading: Story = { - render: () => , -}; - -const CustomErrorExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); + ); + }, }; export const CustomError: Story = { - render: () => , -}; - -const MarkdownErrorsExample = () => { - return ( - { - action('Form Submitted')(values); - }} - /> - ); + }, + ], + submit: { + contents: 'Submit Me!?', + size: 12, + }, + onSubmit: (values) => { + action('Form Submitted')(values); + }, + }, }; export const MarkdownErrors: Story = { - render: () => , + args: { + fields: [ + { + label: 'there is a markdown error here!', + name: 'markdown-error', + type: 'email', + validation: { + required: + 'This is [an example](https://www.youtube.com/watch?v=5IuRzJRrRpQ) error link.', + }, + size: 12, + }, + ], + submit: { + contents: 'Submit', + size: 12, + }, + onSubmit: (values) => { + action('Form Submitted')(values); + }, + }, }; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx index 0232291cd4f..84eb334f9ee 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx @@ -21,7 +21,7 @@ You can control when validation occurs by setting the `validation` prop on the G - `'onChange'` - Validate on every change, submit button is disabled until all required fields are valid - `'onTouched'` - Validate when a field loses focus -### Required Fields +## Required Fields The most common validation is making fields required. You can specify required validation as a boolean or with a custom error message: @@ -37,7 +37,7 @@ validation: { } ``` -### Pattern Validation +## Pattern Validation Use regular expressions to validate field formats like email addresses, phone numbers, or custom patterns: @@ -51,7 +51,7 @@ validation: { } ``` -### Custom Validation +## Custom Validation For complex validation logic, use custom validation functions. These functions receive the field value and can return `true` for valid input or an error message string: @@ -72,7 +72,7 @@ validation: { } ``` -### String Length Validation +## String Length Validation Validate minimum and maximum string lengths: @@ -90,7 +90,7 @@ validation: { } ``` -### Number Range Validation +## Number Range Validation For number inputs, validate minimum and maximum values: From 33b6332726abd0993dc93d1c8945ea6f77a2627b Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 27 Oct 2025 09:36:17 -0400 Subject: [PATCH 31/44] DRY up states --- .../lib/Organisms/GridForm/States.stories.tsx | 79 +++++-------------- 1 file changed, 19 insertions(+), 60 deletions(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx index 615ddac00c5..dcc3dde1e17 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/States.stories.tsx @@ -5,6 +5,19 @@ import { useState } from 'react'; const meta: Meta = { component: GridForm, + args: { + onSubmit: (values) => { + action('Form Submitted')(values); + // eslint-disable-next-line no-console + console.log('Form Submitted', values); + }, + submit: { + contents: 'Submit', + size: 4, + position: 'left', + }, + hideRequiredText: true, + }, }; export default meta; @@ -27,29 +40,10 @@ export const DisabledInputs: Story = { size: 6, }, ], - hideRequiredText: true, - submit: { - contents: 'Right Submit!?', - position: 'right', - size: 12, - }, - onSubmit: (values) => { - action('Form Submitted')(values); - }, }, }; export const OnFieldUpdate: Story = { - args: { - hideRequiredText: true, - submit: { - contents: 'Submit Me!?', - size: 12, - }, - onSubmit: (values) => { - action('Form Submitted')(values); - }, - }, render: function OnFieldUpdate(args) { const [text, setText] = useState(''); return ( @@ -61,7 +55,7 @@ export const OnFieldUpdate: Story = { { label: 'Text with onUpdate', name: 'onUpdate-simple-text', - size: 3, + size: 9, type: 'text', onUpdate: setText, }, @@ -88,17 +82,9 @@ export const DisabledFieldsOnSubmit: Story = { message: 'that is not an email 😔', }, }, - size: 12, + size: 9, }, ], - hideRequiredText: true, - submit: { - contents: 'Submit Me 💖', - size: 12, - }, - onSubmit: (values) => { - action('Form Submitted')(values); - }, }, }; @@ -117,18 +103,10 @@ export const ResetOnSubmit: Story = { message: 'that is not an email 😔', }, }, - size: 12, + size: 9, }, ], - hideRequiredText: true, resetOnSubmit: true, - submit: { - contents: 'Submit Me 💖', - size: 12, - }, - onSubmit: (values) => { - action('Form Submitted')(values); - }, }, }; @@ -150,15 +128,10 @@ export const FormLoading: Story = { message: 'that is not an email 😔', }, }, - size: 12, + size: 9, }, ], - hideRequiredText: true, resetOnSubmit: true, - submit: { - contents: 'Submit Me 💖', - size: 5, - }, }, render: function FormLoading(args) { const [loading, setLoading] = useState(false); @@ -191,7 +164,7 @@ export const CustomError: Story = { { label: 'Who is the best at bending?', name: 'custom-error', - size: 5, + size: 9, type: 'text', customError: 'NOT Flexo.', validation: { @@ -203,13 +176,6 @@ export const CustomError: Story = { }, }, ], - submit: { - contents: 'Submit Me!?', - size: 12, - }, - onSubmit: (values) => { - action('Form Submitted')(values); - }, }, }; @@ -224,15 +190,8 @@ export const MarkdownErrors: Story = { required: 'This is [an example](https://www.youtube.com/watch?v=5IuRzJRrRpQ) error link.', }, - size: 12, + size: 9, }, ], - submit: { - contents: 'Submit', - size: 12, - }, - onSubmit: (values) => { - action('Form Submitted')(values); - }, }, }; From 951a98fc50e0d084158e426a3103ae993a8fd13c Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 27 Oct 2025 10:55:52 -0400 Subject: [PATCH 32/44] fix it up --- .../styleguide/src/lib/Organisms/About.mdx | 2 +- .../src/lib/Organisms/GridForm/About.mdx | 2 -- .../src/lib/Organisms/GridForm/Fields.mdx | 2 +- .../src/lib/Organisms/GridForm/GridForm.mdx | 30 ------------------- .../src/lib/Organisms/GridForm/Usage.mdx | 21 +++++++++++-- ...GridForm.stories.tsx => Usage.stories.tsx} | 0 6 files changed, 21 insertions(+), 36 deletions(-) delete mode 100644 packages/styleguide/src/lib/Organisms/GridForm/GridForm.mdx rename packages/styleguide/src/lib/Organisms/GridForm/{GridForm.stories.tsx => Usage.stories.tsx} (100%) diff --git a/packages/styleguide/src/lib/Organisms/About.mdx b/packages/styleguide/src/lib/Organisms/About.mdx index 90429136a41..09876d6032b 100644 --- a/packages/styleguide/src/lib/Organisms/About.mdx +++ b/packages/styleguide/src/lib/Organisms/About.mdx @@ -7,7 +7,7 @@ import { } from '~styleguide/blocks'; import { parameters as connectedFormParameters } from './ConnectedForm/About.mdx'; -import { parameters as gridFormParameters } from './GridForm/GridForm.mdx'; +import { parameters as gridFormParameters } from './GridForm/About.mdx'; import { parameters as listsTablesParameters } from './Lists & Tables/About.mdx'; import { parameters as markdownParameters } from './Markdown/Markdown.mdx'; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/About.mdx b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx index a12fd3c1314..c6c69c6cc97 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/About.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx @@ -8,7 +8,6 @@ import { import { parameters as buttonsParameters } from './Buttons.mdx'; import { parameters as fieldsParameters } from './Fields.mdx'; -import { parameters as gridFormParameters } from './GridForm.mdx'; import { parameters as layoutParameters } from './Layout.mdx'; import { parameters as statesParameters } from './States.mdx'; import { parameters as usageParameters } from './Usage.mdx'; @@ -26,7 +25,6 @@ export const parameters = { diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.mdx b/packages/styleguide/src/lib/Organisms/GridForm/GridForm.mdx deleted file mode 100644 index 0d720d68bbd..00000000000 --- a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.mdx +++ /dev/null @@ -1,30 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as GridFormStories from './GridForm.stories'; - -export const parameters = { - title: 'GridForm', - subtitle: 'An efficient way to build and design forms on a grid.', - design: { - type: 'figma', - url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1689%3A3910', - }, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/GridForm/GridForm.tsx', - }, -}; - - - - - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Usage.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Usage.mdx index 2b30506426b..46e87f7cfc0 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Usage.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Usage.mdx @@ -1,13 +1,25 @@ -import { Meta } from '@storybook/blocks'; +import { Canvas, Controls, Meta } from '@storybook/blocks'; import { ComponentHeader, LinkTo } from '~styleguide/blocks'; +import * as GridFormStories from './Usage.stories'; + export const parameters = { title: 'Usage', subtitle: 'How to use and design with GridForm.', + design: { + type: 'figma', + url: 'https://www.figma.com/file/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=1689%3A3910', + }, + status: 'current', + source: { + repo: 'gamut', + githubLink: + 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/GridForm/GridForm.tsx', + }, }; - + @@ -44,3 +56,8 @@ The [GridForm page in Gamut](https://www.figma.com/file/ReGfRNillGABAj5SlITalN/% - Customize `🚥 GridFormButtons` - Submit button style (Fill/CTA), position, cancel button +## Playground + + + + diff --git a/packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/Usage.stories.tsx similarity index 100% rename from packages/styleguide/src/lib/Organisms/GridForm/GridForm.stories.tsx rename to packages/styleguide/src/lib/Organisms/GridForm/Usage.stories.tsx From 59ebec6baf4f61a975fdf476db3d8f3a8c29c7d5 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 27 Oct 2025 11:15:31 -0400 Subject: [PATCH 33/44] format --- packages/styleguide/src/lib/Organisms/GridForm/About.mdx | 1 - packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx | 1 - packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx | 1 - packages/styleguide/src/lib/Organisms/GridForm/States.mdx | 1 - .../styleguide/src/lib/Organisms/GridForm/Validation.mdx | 5 ++--- 5 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/About.mdx b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx index c6c69c6cc97..9c3a51deec9 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/About.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx @@ -33,4 +33,3 @@ export const parameters = { statesParameters, ])} /> - diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx index d229bb7994c..31ee97c1a91 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx @@ -94,4 +94,3 @@ Hidden inputs can be used to include data that users can't see or modify with th We call it a "sweet container" so that bots do not immediately detect it as a honeypot input. - diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx index a39e03721c5..749b5cd7204 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx @@ -50,4 +50,3 @@ You can set the Text variant pro When using the left-aligned layout, please note that the `title` takes up 3 columns, so any field size over 9 may cause inconsistent behavior! - diff --git a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx index 5324dab351a..f7bdd36831a 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx @@ -56,4 +56,3 @@ A field can take a custom error in addition to validation errors. The validation GridForm renders errors through our Markdown component so we can optionally add markdown to our validation messages. - diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx index 84eb334f9ee..cfcd032334f 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx @@ -28,12 +28,12 @@ The most common validation is making fields required. You can specify required v ```tsx // Simple required validation validation: { - required: true + required: true; } // Required with custom error message validation: { - required: 'Please enter your email address' + required: 'Please enter your email address'; } ``` @@ -107,4 +107,3 @@ validation: { } } ``` - From b491baa11d3dc43830af96c77d88a745dfb06c4b Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 29 Oct 2025 10:49:29 -0400 Subject: [PATCH 34/44] Update packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx Co-authored-by: Kenny Lin --- packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx index 31ee97c1a91..4d9f6490d7a 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx @@ -31,7 +31,7 @@ generally don't render placeholder text with high enough color contrast for AA standards. If you do need to use placeholder text, such as on landing page forms that have been shown to have higher completion rates with the text, please make sure the placeholder text doesn't add any new -information to the form -- it should really only rephrase the text label. +information to the form — it should really only rephrase the text label. See [this article](https://www.nngroup.com/articles/form-design-placeholders/) or more details on why using placeholders is often bad. From 94029e1790eb58d7272a2376a877cb55dd4afaf4 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 29 Oct 2025 10:49:45 -0400 Subject: [PATCH 35/44] Update packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx Co-authored-by: Kenny Lin --- packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx index 4d9f6490d7a..9a08b8ed553 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx @@ -33,7 +33,7 @@ landing page forms that have been shown to have higher completion rates with the text, please make sure the placeholder text doesn't add any new information to the form — it should really only rephrase the text label. -See [this article](https://www.nngroup.com/articles/form-design-placeholders/) or +See [this article](https://www.nngroup.com/articles/form-design-placeholders/) for more details on why using placeholders is often bad. From 5aebe44a0cfd44bfb82f04e7e6b44c36858d6c64 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 29 Oct 2025 10:50:09 -0400 Subject: [PATCH 36/44] Update packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx Co-authored-by: Kenny Lin --- packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx index 9a08b8ed553..5739868bde9 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx @@ -75,7 +75,7 @@ Nested checkboxes allow for hierarchical selection with parent-child relationshi ## Custom inputs Some forms, such as the checkout flows that use Recurly, need to define -their own inputs. We can specify a 'custom' field type to with a [render prop](https://reactjs.org/docs/render-props.html). +their own inputs. We can specify a `'custom'` field type along with a [`render` prop](https://reactjs.org/docs/render-props.html). We also have a 'custom-group' type for when you are passing in a custom FormGroup - including a label. If you do not want GridForm to surface errors for your field, you should likely use a 'custom-group'. If you chose to go this route, please be aware of [accessibility best practices](https://www.deque.com/blog/anatomy-of-accessible-forms-best-practices/) for forms. From 0757139515de44457805ad12bb6993382ba3d5b9 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 29 Oct 2025 10:50:18 -0400 Subject: [PATCH 37/44] Update packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx Co-authored-by: Kenny Lin --- packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx index 749b5cd7204..dd3277897de 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx @@ -41,7 +41,7 @@ See the Radio story for an e ## Sections -Our `GridForm`s optionally take an array of sections that have left and center-aligned variants. +`GridForm` can optionally take an array of sections that have left and center-aligned variants. Each sections should have a title that correctly follows [heading ranks](https://usability.yale.edu/web-accessibility/articles/headings). You can set the text type of this title though the `as` prop on that section -- the default is `h2`. You can only set the `as` prop to a heading. From bddbdb3c696f7da239a2c36287d4a12ec539e4ff Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 29 Oct 2025 10:50:38 -0400 Subject: [PATCH 38/44] Update packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx Co-authored-by: Kenny Lin --- packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx index 5739868bde9..a5398ea3950 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.mdx @@ -77,7 +77,7 @@ Nested checkboxes allow for hierarchical selection with parent-child relationshi Some forms, such as the checkout flows that use Recurly, need to define their own inputs. We can specify a `'custom'` field type along with a [`render` prop](https://reactjs.org/docs/render-props.html). -We also have a 'custom-group' type for when you are passing in a custom FormGroup - including a label. If you do not want GridForm to surface errors for your field, you should likely use a 'custom-group'. If you chose to go this route, please be aware of [accessibility best practices](https://www.deque.com/blog/anatomy-of-accessible-forms-best-practices/) for forms. +We also have a `'custom-group'` type for when you are passing in a custom `FormGroup` - including a label. If you do not want `GridForm` to surface errors for your field, you should likely use a `'custom-group'`. If you chose to go this route, please be aware of [accessibility best practices](https://www.deque.com/blog/anatomy-of-accessible-forms-best-practices/) for forms. From c22064fee35246153472fc1e64e04766cbef54cd Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 29 Oct 2025 10:50:53 -0400 Subject: [PATCH 39/44] Update packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx Co-authored-by: Kenny Lin --- packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx index cfcd032334f..8cd4748988e 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx @@ -39,7 +39,7 @@ validation: { ## Pattern Validation -Use regular expressions to validate field formats like email addresses, phone numbers, or custom patterns: +Use regular expressions (regex) to validate field formats like email addresses, phone numbers, or custom patterns: ```tsx validation: { From 7c923dc7a627e441c5dce7a2cdc1fbeec9ef8ab6 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 29 Oct 2025 10:51:09 -0400 Subject: [PATCH 40/44] Update packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx Co-authored-by: Kenny Lin --- packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx index dd3277897de..fad51233688 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx @@ -45,7 +45,7 @@ See the Radio story for an e Each sections should have a title that correctly follows [heading ranks](https://usability.yale.edu/web-accessibility/articles/headings). You can set the text type of this title though the `as` prop on that section -- the default is `h2`. You can only set the `as` prop to a heading. -You can set the Text variant prop for the section title the same way. Only title variants are reccomended, but if you need more granular control of the Text component, you can pass them into `titleWrapperProps`. +You can set the Text variant prop for the section title the same way. Only title variants are recommended, but if you need more granular control of the `Text` component, you can pass them into `titleWrapperProps`. When using the left-aligned layout, please note that the `title` takes up 3 columns, so any field size over 9 may cause inconsistent behavior! From 4097e9a6c9ae36e8d24f984ef918873808abc499 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 29 Oct 2025 10:51:16 -0400 Subject: [PATCH 41/44] Update packages/styleguide/src/lib/Organisms/GridForm/States.mdx Co-authored-by: Kenny Lin --- packages/styleguide/src/lib/Organisms/GridForm/States.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx index f7bdd36831a..60dcce6d40c 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx @@ -21,7 +21,7 @@ If an input is not meant to be usable, such as when a portion of a form is disab ## On field update -A field can take an onUpdate callback. This callback will fire when the +A field can take an `onUpdate` callback. This callback will fire when the field's value changes. This could be useful if you need to use the field's value in a parent component before onSubmit gets triggered. From 0506a55383e2a9af26d10ea950f852362a7fe4b4 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 29 Oct 2025 10:51:26 -0400 Subject: [PATCH 42/44] Update packages/styleguide/src/lib/Organisms/GridForm/States.mdx Co-authored-by: Kenny Lin --- packages/styleguide/src/lib/Organisms/GridForm/States.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx index 60dcce6d40c..57b8986060a 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx @@ -23,7 +23,7 @@ If an input is not meant to be usable, such as when a portion of a form is disab A field can take an `onUpdate` callback. This callback will fire when the field's value changes. This could be useful if you need to use the -field's value in a parent component before onSubmit gets triggered. +field's value in a parent component before `onSubmit` gets triggered. From 1a1d79cb8faa07851150ece2621137fa4484fc83 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 30 Oct 2025 13:19:23 -0400 Subject: [PATCH 43/44] PR feedback --- .../styleguide/src/lib/Organisms/GridForm/About.mdx | 12 ++++++++++++ .../src/lib/Organisms/GridForm/Buttons.mdx | 3 ++- .../styleguide/src/lib/Organisms/GridForm/Fields.mdx | 1 + .../src/lib/Organisms/GridForm/Fields.stories.tsx | 12 ++++++++++++ .../styleguide/src/lib/Organisms/GridForm/Layout.mdx | 7 ++++--- .../styleguide/src/lib/Organisms/GridForm/States.mdx | 1 + .../src/lib/Organisms/GridForm/Validation.mdx | 1 + 7 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/About.mdx b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx index 9c3a51deec9..c1636a9eeb2 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/About.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx @@ -3,6 +3,7 @@ import { Meta } from '@storybook/blocks'; import { AboutHeader, addParentPath, + LinkTo, TableOfContents, } from '~styleguide/blocks'; @@ -23,6 +24,17 @@ export const parameters = { +## Getting started + +If you're new to `GridForm`, start with Usage to see an overview of how `GridForm` works and interact with a live playground. + +All other pages below provide guidance on specific parts of `GridForm`: +- **Fields** - Available field types and their configurations +- **Buttons** - Form submit and cancel button configurations +- **Validation** - Form validation patterns +- **Layout** - Grid layout options, required text styling, and info tips +- **States** - Form states like disabled, loading, error, and more + @@ -38,7 +39,7 @@ the `FillButton` or diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx b/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx index 89e34757396..40ecc134b0d 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Fields.stories.tsx @@ -145,6 +145,9 @@ export const CheckboxField: Story = { size: 6, type: 'checkbox', id: 'my-super-cool-id', + validation: { + required: true, + }, }, { description: 'I agree to the conditions', @@ -153,6 +156,9 @@ export const CheckboxField: Story = { size: 6, type: 'checkbox', id: 'my-super-cool-id2', + validation: { + required: true, + }, }, ], }, @@ -169,6 +175,9 @@ export const CheckboxSpacing: Story = { type: 'checkbox', id: 'terms-id', spacing: 'tight', + validation: { + required: true, + }, }, { description: 'I agree to the conditions', @@ -178,6 +187,9 @@ export const CheckboxSpacing: Story = { type: 'checkbox', id: 'conditions-id', spacing: 'tight', + validation: { + required: true, + }, }, ], }, diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx index fad51233688..0c1ac337c07 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Layout.mdx @@ -7,6 +7,7 @@ import * as LayoutStories from './Layout.stories'; export const parameters = { title: 'Layout', subtitle: 'Customize the visual layout and styling of your forms.', + status: 'static', }; @@ -15,7 +16,7 @@ export const parameters = { ## GridForm-atting -We can use the `size` and `rowspan` props (borrowed from LayoutGrid) to customize the layouts of our GridForms. You can also customize the `FormRequiredText` using the `requiredTextProps`. `FormRequiredText` should be spaced apart from or be stylistically different from the text element above it. +We can use the `size` and `rowspan` props (borrowed from LayoutGrid) to customize the layouts of our GridForms. You can also customize the `FormRequiredText` using the `requiredTextProps`. `FormRequiredText` should be spaced apart from or be stylistically different from the text element above it. @@ -33,9 +34,9 @@ Solo field form should always have their solo input be required. They should aut ## InfoTips -A field can include our existing `InfoTip`. See the InfoTip story for more information on what props are available. +A field can include our existing `InfoTip`. See the InfoTip story for more information on what props are available. -See the Radio story for an example of how to add a infotip to a radio option. +See the Radio story for an example of how to add a infotip to a radio option. diff --git a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx index 57b8986060a..7a7d865956d 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/States.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/States.mdx @@ -7,6 +7,7 @@ import * as StatesStories from './States.stories'; export const parameters = { title: 'States', subtitle: 'Manage field behavior and form state.', + status: 'static', }; diff --git a/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx index 8cd4748988e..6b430df07ea 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/Validation.mdx @@ -5,6 +5,7 @@ import { ComponentHeader } from '~styleguide/blocks'; export const parameters = { title: 'Validation', subtitle: 'Robust form validation using react-hook-form.', + status: 'static', }; From 93e0b34c72190c57a80374c0cd5edf9ebff77236 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 3 Nov 2025 10:49:59 -0500 Subject: [PATCH 44/44] feedback --- packages/styleguide/src/lib/Organisms/GridForm/About.mdx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/styleguide/src/lib/Organisms/GridForm/About.mdx b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx index c1636a9eeb2..b2e5a1cfb1b 100644 --- a/packages/styleguide/src/lib/Organisms/GridForm/About.mdx +++ b/packages/styleguide/src/lib/Organisms/GridForm/About.mdx @@ -28,12 +28,7 @@ export const parameters = { If you're new to `GridForm`, start with Usage to see an overview of how `GridForm` works and interact with a live playground. -All other pages below provide guidance on specific parts of `GridForm`: -- **Fields** - Available field types and their configurations -- **Buttons** - Form submit and cancel button configurations -- **Validation** - Form validation patterns -- **Layout** - Grid layout options, required text styling, and info tips -- **States** - Form states like disabled, loading, error, and more +All other pages below provide guidance on specific parts of `GridForm`.