From a955d8cc158afcfa039bedf6354ad3494a26b542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sat, 5 Jul 2025 23:31:18 +0200 Subject: [PATCH 01/12] feat(wip/FieldCheckboxGroup): CheckboxGroup, stories and small style adjustments --- .../field-checkbox-group/docs.stories.tsx | 64 +++++++ .../form/field-checkbox-group/index.tsx | 92 ++++++++++ .../form/field-radio-group/index.tsx | 12 +- app/components/form/form-field-controller.tsx | 10 +- app/components/ui/checkbox-group.stories.tsx | 168 ++++++++++++++++++ app/components/ui/checkbox-group.tsx | 17 ++ app/components/ui/checkbox.tsx | 1 + app/components/ui/radio-group.tsx | 2 +- 8 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 app/components/form/field-checkbox-group/docs.stories.tsx create mode 100644 app/components/form/field-checkbox-group/index.tsx create mode 100644 app/components/ui/checkbox-group.stories.tsx create mode 100644 app/components/ui/checkbox-group.tsx diff --git a/app/components/form/field-checkbox-group/docs.stories.tsx b/app/components/form/field-checkbox-group/docs.stories.tsx new file mode 100644 index 000000000..f7d148172 --- /dev/null +++ b/app/components/form/field-checkbox-group/docs.stories.tsx @@ -0,0 +1,64 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Meta } from '@storybook/react-vite'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { zu } from '@/lib/zod/zod-utils'; + +import { FormFieldController } from '@/components/form'; +import { onSubmit } from '@/components/form/docs.utils'; +import { FieldCheckboxGroup } from '@/components/form/field-checkbox-group'; +import { Button } from '@/components/ui/button'; + +import { Form, FormField, FormFieldHelper, FormFieldLabel } from '../'; + +export default { + title: 'Form/FieldCheckboxGroup', + component: FieldCheckboxGroup, +} satisfies Meta; + +const zFormSchema = () => + z.object({ + bears: zu.array.nonEmpty( + z.string().array(), + 'Please select your favorite bearstronaut' + ), + }); + +const formOptions = { + mode: 'onBlur', + resolver: zodResolver(zFormSchema()), + defaultValues: { + bears: [] as string[], + }, +} as const; + +const astrobears = [ + { value: 'bearstrong', label: 'Bearstrong' }, + { value: 'pawdrin', label: 'Buzz Pawdrin' }, + { value: 'grizzlyrin', label: 'Yuri Grizzlyrin' }, +]; + +export const Default = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; diff --git a/app/components/form/field-checkbox-group/index.tsx b/app/components/form/field-checkbox-group/index.tsx new file mode 100644 index 000000000..e7b8f0322 --- /dev/null +++ b/app/components/form/field-checkbox-group/index.tsx @@ -0,0 +1,92 @@ +import { ComponentProps, ReactNode } from 'react'; +import { Controller, FieldPath, FieldValues } from 'react-hook-form'; + +import { cn } from '@/lib/tailwind/utils'; + +import { FormFieldError } from '@/components/form'; +import { useFormField } from '@/components/form/form-field'; +import { FieldProps } from '@/components/form/form-field-controller'; +import { Checkbox, CheckboxProps } from '@/components/ui/checkbox'; +import { CheckboxGroup } from '@/components/ui/checkbox-group'; + +type CheckboxOption = Omit & { + label: ReactNode; +}; +export type FieldCheckboxGroupProps< + TFIeldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = FieldProps< + TFIeldValues, + TName, + { + type: 'checkbox-group'; + options: Array; + containerProps?: ComponentProps<'div'>; + } & ComponentProps +>; + +export const FieldCheckboxGroup = < + TFIeldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>( + props: FieldCheckboxGroupProps +) => { + const { + type, + name, + control, + defaultValue, + disabled, + shouldUnregister, + containerProps, + options, + ...rest + } = props; + + const ctx = useFormField(); + + return ( + { + const isInvalid = fieldState.error ? true : undefined; + return ( +
+ + {options.map(({ label, ...option }) => ( + + {label} + + ))} + + +
+ ); + }} + /> + ); +}; diff --git a/app/components/form/field-radio-group/index.tsx b/app/components/form/field-radio-group/index.tsx index 43bf134e0..0a43aaacf 100644 --- a/app/components/form/field-radio-group/index.tsx +++ b/app/components/form/field-radio-group/index.tsx @@ -54,6 +54,7 @@ export const FieldRadioGroup = < defaultValue={defaultValue} shouldUnregister={shouldUnregister} render={({ field: { onChange, value, ...field }, fieldState }) => { + const isInvalid = fieldState.error ? true : undefined; return (
{renderOption({ label, + 'aria-invalid': isInvalid, ...field, ...option, })} @@ -91,7 +93,13 @@ export const FieldRadioGroup = < } return ( - + {label} ); diff --git a/app/components/form/form-field-controller.tsx b/app/components/form/form-field-controller.tsx index 0e4a45ab8..c0775f0aa 100644 --- a/app/components/form/form-field-controller.tsx +++ b/app/components/form/form-field-controller.tsx @@ -10,6 +10,10 @@ import { FieldCheckbox, FieldCheckboxProps, } from '@/components/form/field-checkbox'; +import { + FieldCheckboxGroup, + FieldCheckboxGroupProps, +} from '@/components/form/field-checkbox-group'; import { FieldNumber, FieldNumberProps } from '@/components/form/field-number'; import { FieldDate, FieldDateProps } from './field-date'; @@ -54,7 +58,8 @@ export type FormFieldControllerProps< | FieldTextProps | FieldOtpProps | FieldRadioGroupProps - | FieldCheckboxProps; + | FieldCheckboxProps + | FieldCheckboxGroupProps; export const FormFieldController = < TFieldValues extends FieldValues = FieldValues, @@ -96,6 +101,9 @@ export const FormFieldController = < case 'checkbox': return ; + + case 'checkbox-group': + return ; // -- ADD NEW FIELD COMPONENT HERE -- } }; diff --git a/app/components/ui/checkbox-group.stories.tsx b/app/components/ui/checkbox-group.stories.tsx new file mode 100644 index 000000000..be3716e5e --- /dev/null +++ b/app/components/ui/checkbox-group.stories.tsx @@ -0,0 +1,168 @@ +import { Meta } from '@storybook/react-vite'; +import { useState } from 'react'; + +import { Checkbox } from '@/components/ui/checkbox'; +import { CheckboxGroup } from '@/components/ui/checkbox-group'; + +export default { + title: 'CheckboxGroup', + component: CheckboxGroup, +} satisfies Meta; + +const astrobears = [ + { value: 'bearstrong', label: 'Bearstrong', disabled: false }, + { value: 'pawdrin', label: 'Buzz Pawdrin', disabled: false }, + { value: 'grizzlyrin', label: 'Yuri Grizzlyrin', disabled: true }, +] as const; + +export const Default = () => { + return ( + + {astrobears.map((option) => ( + + {option.label} + + ))} + + ); +}; + +export const DefaultValue = () => { + return ( + + {astrobears.map((option) => ( + + {option.label} + + ))} + + ); +}; + +export const Disabled = () => { + return ( + + {astrobears.map((option) => ( + + {option.label} + + ))} + + ); +}; + +export const DisabledOption = () => { + return ( + + {astrobears.map((option) => ( + + {option.label} + + ))} + + ); +}; + +const nestedBears = [ + { + label: 'Bear 1', + value: 'bear-1', + children: null, + }, + { + label: 'Bear 2', + value: 'bear-2', + children: null, + }, + { + label: 'Bear 3', + value: 'bear-3', + children: [ + { + label: 'Little bear 1', + value: 'little-bear-1', + }, + { + label: 'Little bear 2', + value: 'little-bear-2', + }, + { + label: 'Little bear 3', + value: 'little-bear-3', + }, + ], + }, +] as const; + +const bears = nestedBears.map((bear) => bear.value); +const littleBears = nestedBears[2].children.map((bear) => bear.value); +export const WithNestedGroups = () => { + const [bearsValue, setBearsValue] = useState([]); + const [littleBearsValue, setLittleBearsValue] = useState([]); + + return ( + { + if (value.includes('bear-3')) { + setLittleBearsValue(littleBears); + } else if (littleBearsValue.length === littleBears.length) { + setLittleBearsValue([]); + } + setBearsValue(value); + }} + allValues={bears} + defaultValue={[]} + > + Astrobears +
+ {nestedBears.map((option) => { + if (!option.children) { + return ( + + {option.label} + + ); + } + + return ( + { + if (value.length === littleBears.length) { + setBearsValue((prev) => + Array.from(new Set([...prev, 'bear-3'])) + ); + } else { + setBearsValue((prev) => prev.filter((v) => v !== 'bear-3')); + } + setLittleBearsValue(value); + }} + allValues={option.children.map((bear) => bear.value)} + defaultValue={[]} + > + {option.label} +
+ {option.children.map((nestedOption) => { + return ( + + {nestedOption.label} + + ); + })} +
+
+ ); + })} +
+
+ ); +}; diff --git a/app/components/ui/checkbox-group.tsx b/app/components/ui/checkbox-group.tsx new file mode 100644 index 000000000..b803d5f2b --- /dev/null +++ b/app/components/ui/checkbox-group.tsx @@ -0,0 +1,17 @@ +import { CheckboxGroup as CheckboxGroupPrimitive } from '@base-ui-components/react/checkbox-group'; + +import { cn } from '@/lib/tailwind/utils'; + +export type CheckboxGroupProps = CheckboxGroupPrimitive.Props; + +export function CheckboxGroup(props: CheckboxGroupProps) { + return ( + + Astrobears +
- {nestedBears.map((option) => { - if (!option.children) { - return ( - - {option.label} - - ); - } - - return ( - { - if (value.length === littleBears.length) { - setBearsValue((prev) => - Array.from(new Set([...prev, 'bear-3'])) - ); - } else { - setBearsValue((prev) => prev.filter((v) => v !== 'bear-3')); - } - setLittleBearsValue(value); - }} - allValues={option.children.map((bear) => bear.value)} - defaultValue={[]} - > - {option.label} -
- {option.children.map((nestedOption) => { - return ( - - {nestedOption.label} - - ); - })} -
-
- ); - })} + {astrobears.map((option) => ( + + {option.label} + + ))}
); diff --git a/app/components/ui/checkbox-group.tsx b/app/components/ui/checkbox-group.tsx index b803d5f2b..a9d63e627 100644 --- a/app/components/ui/checkbox-group.tsx +++ b/app/components/ui/checkbox-group.tsx @@ -2,9 +2,9 @@ import { CheckboxGroup as CheckboxGroupPrimitive } from '@base-ui-components/rea import { cn } from '@/lib/tailwind/utils'; -export type CheckboxGroupProps = CheckboxGroupPrimitive.Props; +export type BaseCheckboxGroupProps = CheckboxGroupPrimitive.Props; -export function CheckboxGroup(props: CheckboxGroupProps) { +export function CheckboxGroup(props: BaseCheckboxGroupProps) { return ( Date: Wed, 23 Jul 2025 10:31:11 +0200 Subject: [PATCH 03/12] fix: improve checkbox-group api --- app/components/ui/checkbox-group.stories.tsx | 61 +++++++++++++++++- app/components/ui/checkbox-group.tsx | 5 +- app/components/ui/checkbox.tsx | 1 - app/components/ui/checkbox.utils.tsx | 66 ++++++++++++++++++++ 4 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 app/components/ui/checkbox.utils.tsx diff --git a/app/components/ui/checkbox-group.stories.tsx b/app/components/ui/checkbox-group.stories.tsx index 795b5e9f7..24ab554ed 100644 --- a/app/components/ui/checkbox-group.stories.tsx +++ b/app/components/ui/checkbox-group.stories.tsx @@ -2,6 +2,7 @@ import { Meta } from '@storybook/react-vite'; import { useState } from 'react'; import { Checkbox } from '@/components/ui/checkbox'; +import { useCheckboxGroup } from '@/components/ui/checkbox.utils'; import { CheckboxGroup } from '@/components/ui/checkbox-group'; export default { title: 'CheckboxGroup', @@ -78,7 +79,7 @@ export const ParentCheckbox = () => { Astrobears -
+
{astrobears.map((option) => ( {option.label} @@ -88,3 +89,61 @@ export const ParentCheckbox = () => { ); }; + +const nestedBears = [ + { value: 'bearstrong', label: 'Bearstrong', children: undefined }, + { value: 'pawdrin', label: 'Buzz Pawdrin', children: undefined }, + { + value: 'grizzlyrin', + label: 'Yuri Grizzlyrin', + children: [ + { value: 'mini-grizzlyrin-1', label: 'Mini grizzlyrin 1' }, + { value: 'mini-grizzlyrin-2', label: 'Mini grizzlyrin 2' }, + ], + }, +]; + +export const NestedParentCheckbox = () => { + const { + main: { indeterminate, ...main }, + nested, + } = useCheckboxGroup(nestedBears, { + nestedKey: 'grizzlyrin', + mainDefaultValue: [], + nestedDefaultValue: [], + }); + + return ( + + + Astrobears + +
+ {nestedBears.map((bear) => { + if (!bear.children) { + return ( + + {bear.label} + + ); + } + + return ( + + + {bear.label} + +
+ {bear.children.map((bear) => ( + + {bear.label} + + ))} +
+
+ ); + })} +
+
+ ); +}; diff --git a/app/components/ui/checkbox-group.tsx b/app/components/ui/checkbox-group.tsx index a9d63e627..d9db641d1 100644 --- a/app/components/ui/checkbox-group.tsx +++ b/app/components/ui/checkbox-group.tsx @@ -8,10 +8,7 @@ export function CheckboxGroup(props: BaseCheckboxGroupProps) { return ( + {option.label} +
+ ); + + // If the item is a regular checkbox + if (!option.children || !option.children.length) { + return {option.label}; + } + + // If the item has nested values + return ( + ); } diff --git a/app/components/ui/checkbox.utils.tsx b/app/components/ui/checkbox.utils.tsx index 406077668..061ef737f 100644 --- a/app/components/ui/checkbox.utils.tsx +++ b/app/components/ui/checkbox.utils.tsx @@ -8,16 +8,17 @@ type CheckboxOption = { export function useCheckboxGroup( options: Array, params: { - nestedKey: string; + groups: string[]; mainDefaultValue?: string[]; nestedDefaultValue?: string[]; } ) { - const { nestedKey, mainDefaultValue, nestedDefaultValue } = params; + const { groups, mainDefaultValue, nestedDefaultValue } = params; const mainAllValues = options.map((option) => option.value); + const nestedAllValues = options - .find((option) => option.value === nestedKey) + .find((option) => option.value === groups[0]) ?.children?.map((option) => option.value) ?? []; const [mainValue, setMainValue] = useState(mainDefaultValue ?? []); @@ -36,7 +37,7 @@ export function useCheckboxGroup( indeterminate: isMainIndeterminate, onValueChange: (value: string[]) => { // Update children value - if (value.includes(nestedKey)) { + if (value.includes(groups[0]!)) { setNestedValue(nestedAllValues); } else if (areAllNestedChecked) { setNestedValue([]); @@ -53,9 +54,9 @@ export function useCheckboxGroup( onValueChange: (value: string[]) => { // Update parent value if (value.length === nestedAllValues.length) { - setMainValue((prev) => Array.from(new Set([...prev, nestedKey]))); + setMainValue((prev) => Array.from(new Set([...prev, groups[0]!]))); } else { - setMainValue((prev) => prev.filter((v) => v !== nestedKey)); + setMainValue((prev) => prev.filter((v) => v !== groups[0]!)); } // Update self value From 31ae7102ba1e99a8cdcc5b2fce6a4db1b064535c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Wed, 23 Jul 2025 14:33:21 +0200 Subject: [PATCH 05/12] feat: wip: Easy to use version --- app/components/ui/checkbox-group.stories.tsx | 104 ++++----- app/components/ui/checkbox-group.tsx | 216 ++++++++++--------- app/components/ui/checkbox.utils.tsx | 171 +++++++++++---- app/types/utilities.d.ts | 21 ++ 4 files changed, 323 insertions(+), 189 deletions(-) diff --git a/app/components/ui/checkbox-group.stories.tsx b/app/components/ui/checkbox-group.stories.tsx index e8931a2a3..3009f2128 100644 --- a/app/components/ui/checkbox-group.stories.tsx +++ b/app/components/ui/checkbox-group.stories.tsx @@ -95,10 +95,10 @@ const nestedBears = [ { value: 'pawdrin', label: 'Buzz Pawdrin', - // children: [ - // { value: 'mini-pawdrin-1', label: 'Mini pawdrin 1' }, - // { value: 'mini-pawdrin-2', label: 'Mini pawdrin 2' }, - // ], + children: [ + { value: 'mini-pawdrin-1', label: 'Mini pawdrin 1' }, + { value: 'mini-pawdrin-2', label: 'Mini pawdrin 2' }, + ], }, { value: 'grizzlyrin', @@ -110,62 +110,64 @@ const nestedBears = [ }, ]; -export const NestedParentCheckbox = () => { +export const Nested = () => { + return ( + + ); +}; + +export const NestedWithCustomLogic = () => { const { main: { indeterminate, ...main }, nested, } = useCheckboxGroup(nestedBears, { - groups: ['grizzlyrin'], + groups: ['grizzlyrin', 'pawdrin'], mainDefaultValue: [], - nestedDefaultValue: [], + nestedDefaultValue: { + grizzlyrin: [], + pawdrin: ['mini-pawdrin-1'], + }, }); return ( - - - Astrobears - -
- {nestedBears.map((bear) => { - if (!bear.children) { + <> + + + Astrobears + +
+ {nestedBears.map((bear) => { + if (!bear.children) { + return ( + + {bear.label} + + ); + } + return ( - - {bear.label} - + + + {bear.label} + +
+ {bear.children.map((bear) => ( + + {bear.label} + + ))} +
+
); - } - - return ( - - - {bear.label} - -
- {bear.children.map((bear) => ( - - {bear.label} - - ))} -
-
- ); - })} -
-
- ); -}; - -export const SimpleNestedParentCheckbox = () => { - return ( - + })} +
+
+ [{main.value.join(', ')}]
[{nested['grizzlyrin']?.value?.join(', ')} + ] + ); }; diff --git a/app/components/ui/checkbox-group.tsx b/app/components/ui/checkbox-group.tsx index 896138b80..f9422b681 100644 --- a/app/components/ui/checkbox-group.tsx +++ b/app/components/ui/checkbox-group.tsx @@ -1,126 +1,142 @@ import { CheckboxGroup as CheckboxGroupPrimitive } from '@base-ui-components/react/checkbox-group'; +import { cva, VariantProps } from 'class-variance-authority'; import { ReactNode, useId } from 'react'; -import { cn } from '@/lib/tailwind/utils'; - import { Checkbox } from '@/components/ui/checkbox'; import { useCheckboxGroup } from '@/components/ui/checkbox.utils'; -type ChildrenOrOption = - | { - children: ReactNode; - options?: never; - groups?: never; - } - | { - options: Array; - children?: never; - groups?: string[]; - }; -export type BaseCheckboxGroupProps = Omit< - CheckboxGroupPrimitive.Props, - 'children' -> & { isNested?: boolean } & ChildrenOrOption; +const checkboxGroupVariants = cva('flex flex-col items-start gap-1', { + variants: { + size: { + // TODO + default: '', + sm: '', + lg: '', + }, + isNested: { + true: 'pl-4', + }, + }, + defaultVariants: { + size: 'default', + isNested: false, + }, +}); + +export type CheckboxOption = { + label: ReactNode; + value: string; + children?: Array; +}; + +type BaseCheckboxGroupProps = Omit; -/** For now, this component is only meant to work up until 2 level deep nested groups */ +type WithChildren = { children: ReactNode }; +type WithOptions = { + checkAll?: { label: ReactNode; value?: string }; + options: Array; + children?: never; + groups?: string[]; +}; +type ChildrenOrOption = OneOf<[WithChildren, WithOptions]>; + +export type CheckboxGroupProps = BaseCheckboxGroupProps & + VariantProps & + ChildrenOrOption; + +/** For now, this component is only meant to work up until 2 levels deep nested groups */ export function CheckboxGroup({ children, options, groups, - className, + size, isNested: isNestedProp, + className, ...props -}: BaseCheckboxGroupProps) { - const isNested = groups?.length || isNestedProp; - const groupId = useId(); +}: CheckboxGroupProps) { + const isNested = !!isNestedProp || !!groups?.length; - const { - main: { indeterminate, ...main }, - nested, - } = useCheckboxGroup( - options?.filter((option) => option.type !== 'root') ?? ([] as TODO), - { - groups: groups ?? [], - } - ); - - return ( - - {options?.map((option) => ( - - ))} + /> + ) : ( + {children} - + ); } -type RootOption = { - type: 'root'; - label: ReactNode; - value?: string; - children?: never; -}; - -type BaseOption = { - type?: never; - label: ReactNode; - value: string; - children?: never; -}; +function CheckboxGroupWithOptions({ + options, + groups, + checkAll, + isNested, + ...props +}: BaseCheckboxGroupProps & WithOptions & { isNested?: boolean }) { + const groupId = useId(); -type NestedOption = { - type?: never; - label: ReactNode; - value: string; - children?: Array; -}; + const { + main: { indeterminate, ...main }, + nested, + } = useCheckboxGroup(options, { + groups: groups ?? [], + }); -type CheckboxOption = { - indeterminate?: boolean; - nested?: ReturnType['nested']; -} & (RootOption | BaseOption | NestedOption); + const rootProps = isNested ? main : {}; -export function CheckboxGroupItem(option: CheckboxOption) { - // If the item is a root CheckAll - if (option.type === 'root') - return ( - - {option.label} - - ); + return ( + + {checkAll && ( + + {checkAll.label} + + )} + {options.map((option) => { + const nestedGroup = nested[option.value ?? '']; - // If the item is a regular checkbox - if (!option.children || !option.children.length) { - return {option.label}; - } + if (!option.children || !option.children.length) { + return ( + + {option.label} + + ); + } - // If the item has nested values - return ( - + return ( + + ); + })} + ); } + +function CheckboxGroupWithChildren({ + children, + ...props +}: BaseCheckboxGroupProps & WithChildren) { + return {children}; +} diff --git a/app/components/ui/checkbox.utils.tsx b/app/components/ui/checkbox.utils.tsx index 061ef737f..6832844b9 100644 --- a/app/components/ui/checkbox.utils.tsx +++ b/app/components/ui/checkbox.utils.tsx @@ -1,67 +1,162 @@ -import { ReactNode, useState } from 'react'; +import { Dispatch, SetStateAction, useState } from 'react'; +import { difference, unique } from 'remeda'; -type CheckboxOption = { - label: ReactNode; - value: string; - children?: Array; -}; +import { CheckboxOption } from '@/components/ui/checkbox-group'; + +type NestedGroupValues = Record; export function useCheckboxGroup( options: Array, params: { groups: string[]; mainDefaultValue?: string[]; - nestedDefaultValue?: string[]; + nestedDefaultValue?: NestedGroupValues; } ) { const { groups, mainDefaultValue, nestedDefaultValue } = params; - const mainAllValues = options.map((option) => option.value); - - const nestedAllValues = - options - .find((option) => option.value === groups[0]) - ?.children?.map((option) => option.value) ?? []; const [mainValue, setMainValue] = useState(mainDefaultValue ?? []); - const [nestedValue, setNestedValue] = useState( - nestedDefaultValue ?? [] - ); - const areAllNestedChecked = nestedValue.length === nestedAllValues.length; - const isMainIndeterminate = nestedValue.length > 0 && !areAllNestedChecked; + const nestedGroups = useNestedGroups(options, { + groups: groups, + defaultValues: nestedDefaultValue, + onParentChange: setMainValue, + }); + + const topAllValues = options.map((option) => option.value); + + const allNestedGroupValues = nestedGroups.flatMap((group) => group.allValues); + const mainAllValues = [...topAllValues, ...allNestedGroupValues]; return { main: { allValues: mainAllValues, defaultValue: mainDefaultValue, value: mainValue, - indeterminate: isMainIndeterminate, + indeterminate: nestedGroups.some((group) => group.isMainIndeterminate), onValueChange: (value: string[]) => { - // Update children value - if (value.includes(groups[0]!)) { - setNestedValue(nestedAllValues); - } else if (areAllNestedChecked) { - setNestedValue([]); - } + // Update all nested groups values + nestedGroups.forEach((group) => { + if (value.includes(group.group)) { + group.onValueChange(group.allValues); + } else if (group.value?.length === group.allValues?.length) { + group.onValueChange([]); + } + }); // Update self value setMainValue(value); }, }, - nested: { - allValues: nestedAllValues, - defaultValue: nestedDefaultValue, + nested: Object.fromEntries( + nestedGroups.map(({ isMainIndeterminate, group, ...nestedGroup }) => [ + group, + nestedGroup, + ]) + ), + }; +} + +type OnParentChangeFn = (newMainValue: (prev: string[]) => string[]) => void; + +function useNestedGroups( + options: Array, + params: { + groups: string[]; + defaultValues?: NestedGroupValues; + onParentChange: OnParentChangeFn; + } +) { + const { defaultValues, groups, onParentChange } = params; + + const [nestedValues, setNestedValues] = useState( + defaultValues ?? Object.fromEntries(groups.map((group) => [group, []])) + ); + + return groups.map((group) => { + const { + nestedValue, + nestedAllValues, + updateMainValue, + setNestedValue: setOneNestedValue, + isMainIndeterminate, + } = getNestedValueParams({ + options, + parentKey: group, + onParentChange, + nestedValues, + setNestedValues, + }); + + return { + group, value: nestedValue, onValueChange: (value: string[]) => { - // Update parent value - if (value.length === nestedAllValues.length) { - setMainValue((prev) => Array.from(new Set([...prev, groups[0]!]))); - } else { - setMainValue((prev) => prev.filter((v) => v !== groups[0]!)); - } - - // Update self value - setNestedValue(value); + updateMainValue(value); + setOneNestedValue(value); }, - }, + allValues: nestedAllValues, + isMainIndeterminate, + }; + }); +} + +/** + * Helper method to get the setters, getters and other param for the nested group associated with `parentKey` + */ +function getNestedValueParams({ + options, + parentKey, + onParentChange, + nestedValues, + setNestedValues, +}: { + options: Array; + parentKey: string; + onParentChange: OnParentChangeFn; + nestedValues: NestedGroupValues; + setNestedValues: Dispatch>; +}) { + const nestedAllValues = + options + .find((option) => option.value === parentKey) + ?.children?.map((option) => option.value) ?? []; + + const updateMainValue = (newNested: string[]) => { + const areAllChecked = newNested.length === nestedAllValues.length; + const arePartiallyChecked = !areAllChecked && newNested.length > 0; + + const nestedWithParent = [...nestedAllValues, parentKey]; + const withoutValuesAndParent = difference(nestedWithParent); + + onParentChange((prev) => { + console.log('prev', prev); + + console.log('test', newNested); + if (areAllChecked) { + return unique([...prev, parentKey, ...newNested]); + } + + if (arePartiallyChecked) { + return unique([...withoutValuesAndParent(prev), ...newNested]); + } + + return withoutValuesAndParent(prev); + }); + }; + + const setOneNestedValue = (newNested: string[]) => + setNestedValues((prev) => ({ + ...prev, + [parentKey]: newNested, + })); + + return { + nestedAllValues, + updateMainValue, + nestedValue: nestedValues[parentKey] ?? [], // We know this exists hence the type cast + setNestedValue: setOneNestedValue, + isMainIndeterminate: + (nestedValues[parentKey]?.length ?? 0) > 0 && + (nestedValues[parentKey]?.length ?? 0) < nestedAllValues.length, }; } diff --git a/app/types/utilities.d.ts b/app/types/utilities.d.ts index 92e5be77b..0b9dbab19 100644 --- a/app/types/utilities.d.ts +++ b/app/types/utilities.d.ts @@ -29,6 +29,27 @@ type StrictUnionHelper = T extends ExplicitAny : never; type StrictUnion = StrictUnionHelper; +type OnlyFirst = F & { [Key in keyof Omit]?: never }; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +type MergeTypes = TypesArray extends [ + infer Head, + ...infer Rem, +] + ? MergeTypes + : Res; + +/** + * Build typesafe discriminated unions from an array of types + */ +type OneOf< + TypesArray extends any[], + Res = never, + AllProperties = MergeTypes, +> = TypesArray extends [infer Head, ...infer Rem] + ? OneOf, AllProperties> + : Res; + /** * Clean up type for better DX */ From 637b0afd105a7b0df06884e8d74408f33b93b2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Wed, 23 Jul 2025 18:00:27 +0200 Subject: [PATCH 06/12] fix: remove FieldCheckboxGroup for now --- .../field-checkbox-group/docs.stories.tsx | 64 ------------- .../form/field-checkbox-group/index.tsx | 92 ------------------- app/components/form/form-field-controller.tsx | 9 +- 3 files changed, 1 insertion(+), 164 deletions(-) delete mode 100644 app/components/form/field-checkbox-group/docs.stories.tsx delete mode 100644 app/components/form/field-checkbox-group/index.tsx diff --git a/app/components/form/field-checkbox-group/docs.stories.tsx b/app/components/form/field-checkbox-group/docs.stories.tsx deleted file mode 100644 index f7d148172..000000000 --- a/app/components/form/field-checkbox-group/docs.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { Meta } from '@storybook/react-vite'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { zu } from '@/lib/zod/zod-utils'; - -import { FormFieldController } from '@/components/form'; -import { onSubmit } from '@/components/form/docs.utils'; -import { FieldCheckboxGroup } from '@/components/form/field-checkbox-group'; -import { Button } from '@/components/ui/button'; - -import { Form, FormField, FormFieldHelper, FormFieldLabel } from '../'; - -export default { - title: 'Form/FieldCheckboxGroup', - component: FieldCheckboxGroup, -} satisfies Meta; - -const zFormSchema = () => - z.object({ - bears: zu.array.nonEmpty( - z.string().array(), - 'Please select your favorite bearstronaut' - ), - }); - -const formOptions = { - mode: 'onBlur', - resolver: zodResolver(zFormSchema()), - defaultValues: { - bears: [] as string[], - }, -} as const; - -const astrobears = [ - { value: 'bearstrong', label: 'Bearstrong' }, - { value: 'pawdrin', label: 'Buzz Pawdrin' }, - { value: 'grizzlyrin', label: 'Yuri Grizzlyrin' }, -]; - -export const Default = () => { - const form = useForm(formOptions); - - return ( -
-
- - Bearstronaut - Select your favorite bearstronaut - - -
- -
-
-
- ); -}; diff --git a/app/components/form/field-checkbox-group/index.tsx b/app/components/form/field-checkbox-group/index.tsx deleted file mode 100644 index e7b8f0322..000000000 --- a/app/components/form/field-checkbox-group/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { ComponentProps, ReactNode } from 'react'; -import { Controller, FieldPath, FieldValues } from 'react-hook-form'; - -import { cn } from '@/lib/tailwind/utils'; - -import { FormFieldError } from '@/components/form'; -import { useFormField } from '@/components/form/form-field'; -import { FieldProps } from '@/components/form/form-field-controller'; -import { Checkbox, CheckboxProps } from '@/components/ui/checkbox'; -import { CheckboxGroup } from '@/components/ui/checkbox-group'; - -type CheckboxOption = Omit & { - label: ReactNode; -}; -export type FieldCheckboxGroupProps< - TFIeldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> = FieldProps< - TFIeldValues, - TName, - { - type: 'checkbox-group'; - options: Array; - containerProps?: ComponentProps<'div'>; - } & ComponentProps ->; - -export const FieldCheckboxGroup = < - TFIeldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->( - props: FieldCheckboxGroupProps -) => { - const { - type, - name, - control, - defaultValue, - disabled, - shouldUnregister, - containerProps, - options, - ...rest - } = props; - - const ctx = useFormField(); - - return ( - { - const isInvalid = fieldState.error ? true : undefined; - return ( -
- - {options.map(({ label, ...option }) => ( - - {label} - - ))} - - -
- ); - }} - /> - ); -}; diff --git a/app/components/form/form-field-controller.tsx b/app/components/form/form-field-controller.tsx index c0775f0aa..f2ce21738 100644 --- a/app/components/form/form-field-controller.tsx +++ b/app/components/form/form-field-controller.tsx @@ -10,10 +10,6 @@ import { FieldCheckbox, FieldCheckboxProps, } from '@/components/form/field-checkbox'; -import { - FieldCheckboxGroup, - FieldCheckboxGroupProps, -} from '@/components/form/field-checkbox-group'; import { FieldNumber, FieldNumberProps } from '@/components/form/field-number'; import { FieldDate, FieldDateProps } from './field-date'; @@ -58,8 +54,7 @@ export type FormFieldControllerProps< | FieldTextProps | FieldOtpProps | FieldRadioGroupProps - | FieldCheckboxProps - | FieldCheckboxGroupProps; + | FieldCheckboxProps; export const FormFieldController = < TFieldValues extends FieldValues = FieldValues, @@ -102,8 +97,6 @@ export const FormFieldController = < case 'checkbox': return ; - case 'checkbox-group': - return ; // -- ADD NEW FIELD COMPONENT HERE -- } }; From d11738a9aedad3aca2fbbc3ae9dc450050cac2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Fri, 25 Jul 2025 17:10:33 +0200 Subject: [PATCH 07/12] refactor: simple checkbox group --- app/components/ui/checkbox-group.stories.tsx | 107 ------------ app/components/ui/checkbox-group.tsx | 122 ++------------ app/components/ui/checkbox.utils.tsx | 162 ------------------- 3 files changed, 10 insertions(+), 381 deletions(-) delete mode 100644 app/components/ui/checkbox.utils.tsx diff --git a/app/components/ui/checkbox-group.stories.tsx b/app/components/ui/checkbox-group.stories.tsx index 3009f2128..c86d88948 100644 --- a/app/components/ui/checkbox-group.stories.tsx +++ b/app/components/ui/checkbox-group.stories.tsx @@ -1,8 +1,6 @@ import { Meta } from '@storybook/react-vite'; -import { useState } from 'react'; import { Checkbox } from '@/components/ui/checkbox'; -import { useCheckboxGroup } from '@/components/ui/checkbox.utils'; import { CheckboxGroup } from '@/components/ui/checkbox-group'; export default { title: 'CheckboxGroup', @@ -66,108 +64,3 @@ export const DisabledOption = () => {
); }; - -export const ParentCheckbox = () => { - const [value, setValue] = useState([]); - - return ( - bear.value)} - > - - Astrobears - -
- {astrobears.map((option) => ( - - {option.label} - - ))} -
-
- ); -}; - -const nestedBears = [ - { value: 'bearstrong', label: 'Bearstrong', children: undefined }, - { - value: 'pawdrin', - label: 'Buzz Pawdrin', - children: [ - { value: 'mini-pawdrin-1', label: 'Mini pawdrin 1' }, - { value: 'mini-pawdrin-2', label: 'Mini pawdrin 2' }, - ], - }, - { - value: 'grizzlyrin', - label: 'Yuri Grizzlyrin', - children: [ - { value: 'mini-grizzlyrin-1', label: 'Mini grizzlyrin 1' }, - { value: 'mini-grizzlyrin-2', label: 'Mini grizzlyrin 2' }, - ], - }, -]; - -export const Nested = () => { - return ( - - ); -}; - -export const NestedWithCustomLogic = () => { - const { - main: { indeterminate, ...main }, - nested, - } = useCheckboxGroup(nestedBears, { - groups: ['grizzlyrin', 'pawdrin'], - mainDefaultValue: [], - nestedDefaultValue: { - grizzlyrin: [], - pawdrin: ['mini-pawdrin-1'], - }, - }); - - return ( - <> - - - Astrobears - -
- {nestedBears.map((bear) => { - if (!bear.children) { - return ( - - {bear.label} - - ); - } - - return ( - - - {bear.label} - -
- {bear.children.map((bear) => ( - - {bear.label} - - ))} -
-
- ); - })} -
-
- [{main.value.join(', ')}]
[{nested['grizzlyrin']?.value?.join(', ')} - ] - - ); -}; diff --git a/app/components/ui/checkbox-group.tsx b/app/components/ui/checkbox-group.tsx index f9422b681..5212971a9 100644 --- a/app/components/ui/checkbox-group.tsx +++ b/app/components/ui/checkbox-group.tsx @@ -1,9 +1,5 @@ import { CheckboxGroup as CheckboxGroupPrimitive } from '@base-ui-components/react/checkbox-group'; import { cva, VariantProps } from 'class-variance-authority'; -import { ReactNode, useId } from 'react'; - -import { Checkbox } from '@/components/ui/checkbox'; -import { useCheckboxGroup } from '@/components/ui/checkbox.utils'; const checkboxGroupVariants = cva('flex flex-col items-start gap-1', { variants: { @@ -13,130 +9,32 @@ const checkboxGroupVariants = cva('flex flex-col items-start gap-1', { sm: '', lg: '', }, - isNested: { - true: 'pl-4', - }, }, defaultVariants: { size: 'default', - isNested: false, }, }); -export type CheckboxOption = { - label: ReactNode; - value: string; - children?: Array; -}; - -type BaseCheckboxGroupProps = Omit; - -type WithChildren = { children: ReactNode }; -type WithOptions = { - checkAll?: { label: ReactNode; value?: string }; - options: Array; - children?: never; - groups?: string[]; -}; -type ChildrenOrOption = OneOf<[WithChildren, WithOptions]>; +type BaseCheckboxGroupProps = CheckboxGroupPrimitive.Props; export type CheckboxGroupProps = BaseCheckboxGroupProps & - VariantProps & - ChildrenOrOption; + VariantProps; -/** For now, this component is only meant to work up until 2 levels deep nested groups */ export function CheckboxGroup({ children, - options, - groups, - size, - isNested: isNestedProp, className, + size, ...props }: CheckboxGroupProps) { - const isNested = !!isNestedProp || !!groups?.length; - - const formattedClassName = checkboxGroupVariants({ - size, - isNested, - className, - }); - return options?.length ? ( - - ) : ( - - {children} - - ); -} - -function CheckboxGroupWithOptions({ - options, - groups, - checkAll, - isNested, - ...props -}: BaseCheckboxGroupProps & WithOptions & { isNested?: boolean }) { - const groupId = useId(); - - const { - main: { indeterminate, ...main }, - nested, - } = useCheckboxGroup(options, { - groups: groups ?? [], - }); - - const rootProps = isNested ? main : {}; - return ( - - {checkAll && ( - - {checkAll.label} - - )} - {options.map((option) => { - const nestedGroup = nested[option.value ?? '']; - - if (!option.children || !option.children.length) { - return ( - - {option.label} - - ); - } - - return ( - - ); + + {children} ); } - -function CheckboxGroupWithChildren({ - children, - ...props -}: BaseCheckboxGroupProps & WithChildren) { - return {children}; -} diff --git a/app/components/ui/checkbox.utils.tsx b/app/components/ui/checkbox.utils.tsx deleted file mode 100644 index 6832844b9..000000000 --- a/app/components/ui/checkbox.utils.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { Dispatch, SetStateAction, useState } from 'react'; -import { difference, unique } from 'remeda'; - -import { CheckboxOption } from '@/components/ui/checkbox-group'; - -type NestedGroupValues = Record; -export function useCheckboxGroup( - options: Array, - params: { - groups: string[]; - mainDefaultValue?: string[]; - nestedDefaultValue?: NestedGroupValues; - } -) { - const { groups, mainDefaultValue, nestedDefaultValue } = params; - - const [mainValue, setMainValue] = useState(mainDefaultValue ?? []); - - const nestedGroups = useNestedGroups(options, { - groups: groups, - defaultValues: nestedDefaultValue, - onParentChange: setMainValue, - }); - - const topAllValues = options.map((option) => option.value); - - const allNestedGroupValues = nestedGroups.flatMap((group) => group.allValues); - const mainAllValues = [...topAllValues, ...allNestedGroupValues]; - - return { - main: { - allValues: mainAllValues, - defaultValue: mainDefaultValue, - value: mainValue, - indeterminate: nestedGroups.some((group) => group.isMainIndeterminate), - onValueChange: (value: string[]) => { - // Update all nested groups values - nestedGroups.forEach((group) => { - if (value.includes(group.group)) { - group.onValueChange(group.allValues); - } else if (group.value?.length === group.allValues?.length) { - group.onValueChange([]); - } - }); - - // Update self value - setMainValue(value); - }, - }, - nested: Object.fromEntries( - nestedGroups.map(({ isMainIndeterminate, group, ...nestedGroup }) => [ - group, - nestedGroup, - ]) - ), - }; -} - -type OnParentChangeFn = (newMainValue: (prev: string[]) => string[]) => void; - -function useNestedGroups( - options: Array, - params: { - groups: string[]; - defaultValues?: NestedGroupValues; - onParentChange: OnParentChangeFn; - } -) { - const { defaultValues, groups, onParentChange } = params; - - const [nestedValues, setNestedValues] = useState( - defaultValues ?? Object.fromEntries(groups.map((group) => [group, []])) - ); - - return groups.map((group) => { - const { - nestedValue, - nestedAllValues, - updateMainValue, - setNestedValue: setOneNestedValue, - isMainIndeterminate, - } = getNestedValueParams({ - options, - parentKey: group, - onParentChange, - nestedValues, - setNestedValues, - }); - - return { - group, - value: nestedValue, - onValueChange: (value: string[]) => { - updateMainValue(value); - setOneNestedValue(value); - }, - allValues: nestedAllValues, - isMainIndeterminate, - }; - }); -} - -/** - * Helper method to get the setters, getters and other param for the nested group associated with `parentKey` - */ -function getNestedValueParams({ - options, - parentKey, - onParentChange, - nestedValues, - setNestedValues, -}: { - options: Array; - parentKey: string; - onParentChange: OnParentChangeFn; - nestedValues: NestedGroupValues; - setNestedValues: Dispatch>; -}) { - const nestedAllValues = - options - .find((option) => option.value === parentKey) - ?.children?.map((option) => option.value) ?? []; - - const updateMainValue = (newNested: string[]) => { - const areAllChecked = newNested.length === nestedAllValues.length; - const arePartiallyChecked = !areAllChecked && newNested.length > 0; - - const nestedWithParent = [...nestedAllValues, parentKey]; - const withoutValuesAndParent = difference(nestedWithParent); - - onParentChange((prev) => { - console.log('prev', prev); - - console.log('test', newNested); - if (areAllChecked) { - return unique([...prev, parentKey, ...newNested]); - } - - if (arePartiallyChecked) { - return unique([...withoutValuesAndParent(prev), ...newNested]); - } - - return withoutValuesAndParent(prev); - }); - }; - - const setOneNestedValue = (newNested: string[]) => - setNestedValues((prev) => ({ - ...prev, - [parentKey]: newNested, - })); - - return { - nestedAllValues, - updateMainValue, - nestedValue: nestedValues[parentKey] ?? [], // We know this exists hence the type cast - setNestedValue: setOneNestedValue, - isMainIndeterminate: - (nestedValues[parentKey]?.length ?? 0) > 0 && - (nestedValues[parentKey]?.length ?? 0) < nestedAllValues.length, - }; -} From ba9382b59c09df75336c8bb77e6e0db1ab286bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Fri, 25 Jul 2025 18:09:22 +0200 Subject: [PATCH 08/12] feat(field-checkbox-group): add stories and tests --- .../field-checkbox-group/docs.stories.tsx | 172 +++++++++++ .../field-checkbox-group.spec.tsx | 288 ++++++++++++++++++ .../form/field-checkbox-group/index.tsx | 108 +++++++ app/components/form/form-field-controller.tsx | 9 +- 4 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 app/components/form/field-checkbox-group/docs.stories.tsx create mode 100644 app/components/form/field-checkbox-group/field-checkbox-group.spec.tsx create mode 100644 app/components/form/field-checkbox-group/index.tsx diff --git a/app/components/form/field-checkbox-group/docs.stories.tsx b/app/components/form/field-checkbox-group/docs.stories.tsx new file mode 100644 index 000000000..8d23a98bf --- /dev/null +++ b/app/components/form/field-checkbox-group/docs.stories.tsx @@ -0,0 +1,172 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { zu } from '@/lib/zod/zod-utils'; + +import { FormFieldController } from '@/components/form'; +import { onSubmit } from '@/components/form/docs.utils'; +import { Button } from '@/components/ui/button'; + +import { Form, FormField, FormFieldHelper, FormFieldLabel } from '../'; + +export default { + title: 'Form/FieldCheckboxGroup', +}; + +const zFormSchema = () => + z.object({ + bear: zu.array.nonEmpty(z.string().array()), + }); + +const formOptions = { + mode: 'onBlur', + resolver: zodResolver(zFormSchema()), + defaultValues: { + bear: [], + } as z.infer>, +} as const; + +const options = [ + { value: 'bearstrong', label: 'Bearstrong' }, + { value: 'pawdrin', label: 'Buzz Pawdrin' }, + { value: 'grizzlyrin', label: 'Yuri Grizzlyrin' }, +]; + +export const Default = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const DefaultValue = () => { + const form = useForm({ + ...formOptions, + defaultValues: { + bear: ['pawdrin'], + }, + }); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const Disabled = () => { + const form = useForm({ + ...formOptions, + defaultValues: { + bear: ['pawdrin'], + }, + }); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const Row = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const WithDisabledOption = () => { + const form = useForm(formOptions); + + const optionsWithDisabled = [ + { value: 'bearstrong', label: 'Bearstrong' }, + { value: 'pawdrin', label: 'Buzz Pawdrin' }, + { value: 'grizzlyrin', label: 'Yuri Grizzlyrin', disabled: true }, + ]; + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; diff --git a/app/components/form/field-checkbox-group/field-checkbox-group.spec.tsx b/app/components/form/field-checkbox-group/field-checkbox-group.spec.tsx new file mode 100644 index 000000000..eb63c2b7a --- /dev/null +++ b/app/components/form/field-checkbox-group/field-checkbox-group.spec.tsx @@ -0,0 +1,288 @@ +import { expect, test, vi } from 'vitest'; +import { axe } from 'vitest-axe'; +import { z } from 'zod'; + +import { render, screen, setupUser } from '@/tests/utils'; + +import { FormField, FormFieldController, FormFieldLabel } from '..'; +import { FormMocked } from '../form-test-utils'; + +const options = [ + { value: 'bearstrong', label: 'Bearstrong' }, + { value: 'pawdrin', label: 'Buzz Pawdrin' }, + { value: 'grizzlyrin', label: 'Yuri Grizzlyrin' }, + { value: 'jemibear', label: 'Mae Jemibear', disabled: true }, +]; + +test('should have no a11y violations', async () => { + const mockedSubmit = vi.fn(); + HTMLCanvasElement.prototype.getContext = vi.fn(); + + const { container } = render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); + +test('should toggle checkbox on click', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const checkbox = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + expect(checkbox).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['pawdrin'] }); +}); + +test('should toggle checkbox on label click', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const checkbox = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); + const label = screen.getByText('Buzz Pawdrin'); + + expect(checkbox).not.toBeChecked(); + await user.click(label); + expect(checkbox).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['pawdrin'] }); +}); + +test('should allow selecting multiple checkboxes', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const cb1 = screen.getByRole('checkbox', { name: 'Bearstrong' }); + const cb2 = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); + + await user.click(cb1); + await user.click(cb2); + + expect(cb1).toBeChecked(); + expect(cb2).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ + bears: ['bearstrong', 'pawdrin'], + }); +}); + +test('keyboard interaction: toggle with space', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const cb1 = screen.getByRole('checkbox', { name: 'Bearstrong' }); + + await user.tab(); + expect(cb1).toHaveFocus(); + + await user.keyboard(' '); + expect(cb1).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['bearstrong'] }); +}); + +test('default values', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const cb = screen.getByRole('checkbox', { name: 'Yuri Grizzlyrin' }); + expect(cb).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['grizzlyrin'] }); +}); + +test('disabled group', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const cb = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); + expect(cb).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: undefined }); +}); + +test('disabled option', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const disabledCb = screen.getByRole('checkbox', { name: 'Mae Jemibear' }); + expect(disabledCb).toBeDisabled(); + + await user.click(disabledCb); + expect(disabledCb).not.toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: [] }); +}); diff --git a/app/components/form/field-checkbox-group/index.tsx b/app/components/form/field-checkbox-group/index.tsx new file mode 100644 index 000000000..4a2120573 --- /dev/null +++ b/app/components/form/field-checkbox-group/index.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import { Controller, FieldPath, FieldValues } from 'react-hook-form'; + +import { cn } from '@/lib/tailwind/utils'; + +import { FormFieldError } from '@/components/form'; +import { useFormField } from '@/components/form/form-field'; +import { FieldProps } from '@/components/form/form-field-controller'; +import { Checkbox, CheckboxProps } from '@/components/ui/checkbox'; +import { CheckboxGroup } from '@/components/ui/checkbox-group'; + +type CheckboxOption = Omit & { + label: string; + value: string; +}; + +export type FieldCheckboxGroupProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = FieldProps< + TFieldValues, + TName, + { + type: 'checkbox-group'; + options: Array; + containerProps?: React.ComponentProps<'div'>; + } & Omit, 'allValues'> +>; + +export const FieldCheckboxGroup = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>( + props: FieldCheckboxGroupProps +) => { + const { + name, + control, + disabled, + defaultValue, + shouldUnregister, + containerProps, + options, + size, + ...rest + } = props; + const ctx = useFormField(); + + console.log(options); + return ( + { + const isInvalid = fieldState.error ? true : undefined; + + return ( +
+ { + onChange?.(value); + rest.onValueChange?.(value, event); + }} + {...rest} + > + {options.map(({ label, ...option }) => { + const checkboxId = `${ctx.id}-${option.value}`; + + return ( + + {label} + + ); + })} + + [{value}] + +
+ ); + }} + /> + ); +}; diff --git a/app/components/form/form-field-controller.tsx b/app/components/form/form-field-controller.tsx index f2ce21738..c0775f0aa 100644 --- a/app/components/form/form-field-controller.tsx +++ b/app/components/form/form-field-controller.tsx @@ -10,6 +10,10 @@ import { FieldCheckbox, FieldCheckboxProps, } from '@/components/form/field-checkbox'; +import { + FieldCheckboxGroup, + FieldCheckboxGroupProps, +} from '@/components/form/field-checkbox-group'; import { FieldNumber, FieldNumberProps } from '@/components/form/field-number'; import { FieldDate, FieldDateProps } from './field-date'; @@ -54,7 +58,8 @@ export type FormFieldControllerProps< | FieldTextProps | FieldOtpProps | FieldRadioGroupProps - | FieldCheckboxProps; + | FieldCheckboxProps + | FieldCheckboxGroupProps; export const FormFieldController = < TFieldValues extends FieldValues = FieldValues, @@ -97,6 +102,8 @@ export const FormFieldController = < case 'checkbox': return ; + case 'checkbox-group': + return ; // -- ADD NEW FIELD COMPONENT HERE -- } }; From a08db542893d75c05fd2659b50d40e9ba07cc728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sat, 26 Jul 2025 13:08:15 +0200 Subject: [PATCH 09/12] fix: small issues --- .../field-checkbox-group/docs.stories.tsx | 18 ++++----- .../form/field-checkbox-group/index.tsx | 29 ++++++--------- app/components/ui/checkbox-group.tsx | 37 +++---------------- 3 files changed, 25 insertions(+), 59 deletions(-) diff --git a/app/components/form/field-checkbox-group/docs.stories.tsx b/app/components/form/field-checkbox-group/docs.stories.tsx index 8d23a98bf..c2e3d6917 100644 --- a/app/components/form/field-checkbox-group/docs.stories.tsx +++ b/app/components/form/field-checkbox-group/docs.stories.tsx @@ -16,14 +16,14 @@ export default { const zFormSchema = () => z.object({ - bear: zu.array.nonEmpty(z.string().array()), + bears: zu.array.nonEmpty(z.string().array(), 'Select at least one answer.'), }); const formOptions = { mode: 'onBlur', resolver: zodResolver(zFormSchema()), defaultValues: { - bear: [], + bears: [], } as z.infer>, } as const; @@ -45,7 +45,7 @@ export const Default = () => { @@ -61,7 +61,7 @@ export const DefaultValue = () => { const form = useForm({ ...formOptions, defaultValues: { - bear: ['pawdrin'], + bears: ['pawdrin'], }, }); @@ -74,7 +74,7 @@ export const DefaultValue = () => { @@ -90,7 +90,7 @@ export const Disabled = () => { const form = useForm({ ...formOptions, defaultValues: { - bear: ['pawdrin'], + bears: ['pawdrin'], }, }); @@ -103,7 +103,7 @@ export const Disabled = () => { @@ -128,7 +128,7 @@ export const Row = () => { @@ -159,7 +159,7 @@ export const WithDisabledOption = () => { diff --git a/app/components/form/field-checkbox-group/index.tsx b/app/components/form/field-checkbox-group/index.tsx index 4a2120573..232b2bf3a 100644 --- a/app/components/form/field-checkbox-group/index.tsx +++ b/app/components/form/field-checkbox-group/index.tsx @@ -46,7 +46,6 @@ export const FieldCheckboxGroup = < } = props; const ctx = useFormField(); - console.log(options); return ( { onChange?.(value); rest.onValueChange?.(value, event); }} {...rest} > - {options.map(({ label, ...option }) => { - const checkboxId = `${ctx.id}-${option.value}`; - - return ( - - {label} - - ); - })} + {options.map(({ label, ...option }) => ( + + {label} + + ))}
- [{value}]
); diff --git a/app/components/ui/checkbox-group.tsx b/app/components/ui/checkbox-group.tsx index 5212971a9..cd9c198e7 100644 --- a/app/components/ui/checkbox-group.tsx +++ b/app/components/ui/checkbox-group.tsx @@ -1,40 +1,13 @@ import { CheckboxGroup as CheckboxGroupPrimitive } from '@base-ui-components/react/checkbox-group'; -import { cva, VariantProps } from 'class-variance-authority'; -const checkboxGroupVariants = cva('flex flex-col items-start gap-1', { - variants: { - size: { - // TODO - default: '', - sm: '', - lg: '', - }, - }, - defaultVariants: { - size: 'default', - }, -}); +import { cn } from '@/lib/tailwind/utils'; -type BaseCheckboxGroupProps = CheckboxGroupPrimitive.Props; - -export type CheckboxGroupProps = BaseCheckboxGroupProps & - VariantProps; - -export function CheckboxGroup({ - children, - className, - size, - ...props -}: CheckboxGroupProps) { +type CheckboxGroupProps = CheckboxGroupPrimitive.Props; +export function CheckboxGroup({ className, ...props }: CheckboxGroupProps) { return ( - {children} - + /> ); } From dac71024defeb66d44165ff84afdece2b654c252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sat, 26 Jul 2025 23:18:44 +0200 Subject: [PATCH 10/12] feat: Custom nested checkbox group and field --- .../docs.stories.tsx | 149 +++++++++ .../field-checkbox-group.spec.tsx | 288 ++++++++++++++++++ .../field-nested-checkbox-group/index.tsx | 139 +++++++++ app/components/form/form-field-controller.tsx | 10 +- .../ui/nested-checkbox-group/docs.stories.tsx | 258 ++++++++++++++++ .../ui/nested-checkbox-group/helpers.ts | 11 + .../nested-checkbox-group.tsx | 58 ++++ .../nested-checkbox-interface.tsx | 96 ++++++ .../nested-checkbox-group/nested-checkbox.tsx | 48 +++ .../ui/nested-checkbox-group/store.ts | 93 ++++++ package.json | 1 + pnpm-lock.yaml | 11 +- 12 files changed, 1160 insertions(+), 2 deletions(-) create mode 100644 app/components/form/field-nested-checkbox-group/docs.stories.tsx create mode 100644 app/components/form/field-nested-checkbox-group/field-checkbox-group.spec.tsx create mode 100644 app/components/form/field-nested-checkbox-group/index.tsx create mode 100644 app/components/ui/nested-checkbox-group/docs.stories.tsx create mode 100644 app/components/ui/nested-checkbox-group/helpers.ts create mode 100644 app/components/ui/nested-checkbox-group/nested-checkbox-group.tsx create mode 100644 app/components/ui/nested-checkbox-group/nested-checkbox-interface.tsx create mode 100644 app/components/ui/nested-checkbox-group/nested-checkbox.tsx create mode 100644 app/components/ui/nested-checkbox-group/store.ts diff --git a/app/components/form/field-nested-checkbox-group/docs.stories.tsx b/app/components/form/field-nested-checkbox-group/docs.stories.tsx new file mode 100644 index 000000000..ec9db1232 --- /dev/null +++ b/app/components/form/field-nested-checkbox-group/docs.stories.tsx @@ -0,0 +1,149 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { zu } from '@/lib/zod/zod-utils'; + +import { FormFieldController } from '@/components/form'; +import { onSubmit } from '@/components/form/docs.utils'; +import { NestedCheckboxOption } from '@/components/form/field-nested-checkbox-group'; +import { Button } from '@/components/ui/button'; + +import { Form, FormField, FormFieldHelper, FormFieldLabel } from '..'; + +export default { + title: 'Form/FieldNestedCheckboxGroup', +}; + +const zFormSchema = () => + z.object({ + bears: zu.array.nonEmpty(z.string().array(), 'Select at least one answer.'), + }); + +const formOptions = { + mode: 'onBlur', + resolver: zodResolver(zFormSchema()), + defaultValues: { + bears: [], + } as z.infer>, +} as const; + +const astrobears: Array = [ + { + value: 'bearstrong', + label: 'Bearstrong', + children: undefined, + }, + { value: 'pawdrin', label: 'Buzz Pawdrin', children: undefined }, + { + value: 'grizzlyrin', + label: 'Yuri Grizzlyrin', + disabled: true, + children: [ + { + value: 'mini-grizzlyrin-1', + label: 'Mini Grizzlyrin 1', + }, + { + value: 'mini-grizzlyrin-2', + label: 'Mini Grizzlyrin 2', + }, + ], + }, +]; + +export const Default = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const DefaultValue = () => { + const form = useForm({ + ...formOptions, + defaultValues: { + bears: ['grizzlyrin', 'mini-grizzlyrin-1', 'mini-grizzlyrin-2'], + }, + }); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const Disabled = () => { + const form = useForm(formOptions); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; diff --git a/app/components/form/field-nested-checkbox-group/field-checkbox-group.spec.tsx b/app/components/form/field-nested-checkbox-group/field-checkbox-group.spec.tsx new file mode 100644 index 000000000..eb63c2b7a --- /dev/null +++ b/app/components/form/field-nested-checkbox-group/field-checkbox-group.spec.tsx @@ -0,0 +1,288 @@ +import { expect, test, vi } from 'vitest'; +import { axe } from 'vitest-axe'; +import { z } from 'zod'; + +import { render, screen, setupUser } from '@/tests/utils'; + +import { FormField, FormFieldController, FormFieldLabel } from '..'; +import { FormMocked } from '../form-test-utils'; + +const options = [ + { value: 'bearstrong', label: 'Bearstrong' }, + { value: 'pawdrin', label: 'Buzz Pawdrin' }, + { value: 'grizzlyrin', label: 'Yuri Grizzlyrin' }, + { value: 'jemibear', label: 'Mae Jemibear', disabled: true }, +]; + +test('should have no a11y violations', async () => { + const mockedSubmit = vi.fn(); + HTMLCanvasElement.prototype.getContext = vi.fn(); + + const { container } = render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); + +test('should toggle checkbox on click', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const checkbox = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + expect(checkbox).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['pawdrin'] }); +}); + +test('should toggle checkbox on label click', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const checkbox = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); + const label = screen.getByText('Buzz Pawdrin'); + + expect(checkbox).not.toBeChecked(); + await user.click(label); + expect(checkbox).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['pawdrin'] }); +}); + +test('should allow selecting multiple checkboxes', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const cb1 = screen.getByRole('checkbox', { name: 'Bearstrong' }); + const cb2 = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); + + await user.click(cb1); + await user.click(cb2); + + expect(cb1).toBeChecked(); + expect(cb2).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ + bears: ['bearstrong', 'pawdrin'], + }); +}); + +test('keyboard interaction: toggle with space', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const cb1 = screen.getByRole('checkbox', { name: 'Bearstrong' }); + + await user.tab(); + expect(cb1).toHaveFocus(); + + await user.keyboard(' '); + expect(cb1).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['bearstrong'] }); +}); + +test('default values', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const cb = screen.getByRole('checkbox', { name: 'Yuri Grizzlyrin' }); + expect(cb).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['grizzlyrin'] }); +}); + +test('disabled group', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const cb = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); + expect(cb).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: undefined }); +}); + +test('disabled option', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const disabledCb = screen.getByRole('checkbox', { name: 'Mae Jemibear' }); + expect(disabledCb).toBeDisabled(); + + await user.click(disabledCb); + expect(disabledCb).not.toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: [] }); +}); diff --git a/app/components/form/field-nested-checkbox-group/index.tsx b/app/components/form/field-nested-checkbox-group/index.tsx new file mode 100644 index 000000000..131f96518 --- /dev/null +++ b/app/components/form/field-nested-checkbox-group/index.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; +import { + Controller, + ControllerRenderProps, + FieldPath, + FieldValues, +} from 'react-hook-form'; + +import { cn } from '@/lib/tailwind/utils'; + +import { FormFieldError } from '@/components/form'; +import { useFormField } from '@/components/form/form-field'; +import { FieldProps } from '@/components/form/form-field-controller'; +import { + NestedCheckbox, + NestedCheckboxProps, +} from '@/components/ui/nested-checkbox-group/nested-checkbox'; +import { NestedCheckboxGroup } from '@/components/ui/nested-checkbox-group/nested-checkbox-group'; + +export type NestedCheckboxOption = Omit< + NestedCheckboxProps, + 'children' | 'value' | 'render' +> & { + label: string; + value: string; + children?: Array; +}; + +export type FieldNestedCheckboxGroupProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = FieldProps< + TFieldValues, + TName, + { + type: 'nested-checkbox-group'; + options: Array; + containerProps?: React.ComponentProps<'div'>; + } & Omit, 'allValues'> +>; + +export const FieldNestedCheckboxGroup = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>( + props: FieldNestedCheckboxGroupProps +) => { + const { + name, + control, + disabled, + defaultValue, + shouldUnregister, + containerProps, + options, + size, + ...rest + } = props; + const ctx = useFormField(); + + return ( + { + const isInvalid = fieldState.error ? true : undefined; + + return ( +
+ { + rest.onValueChange?.(value); + onChange(value); + }} + > + {renderOptions(options, { + 'aria-invalid': isInvalid, + ...field, + })} + + +
+ ); + }} + /> + ); +}; + +function renderOptions< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>( + options: NestedCheckboxOption[], + commonProps: Omit< + NestedCheckboxProps & ControllerRenderProps, + 'value' | 'onChange' | 'onBlur' + >, + parent?: string +) { + const Comp = parent ? 'div' : React.Fragment; + const compProps = parent + ? { + className: 'flex flex-col gap-2 pl-4', + } + : {}; + return ( + + {options.map(({ children, label, ...option }) => ( + + + {label} + + {children && + children.length > 0 && + renderOptions(children, commonProps, option.value)} + + ))} + + ); +} diff --git a/app/components/form/form-field-controller.tsx b/app/components/form/form-field-controller.tsx index c0775f0aa..5531b4b77 100644 --- a/app/components/form/form-field-controller.tsx +++ b/app/components/form/form-field-controller.tsx @@ -14,6 +14,10 @@ import { FieldCheckboxGroup, FieldCheckboxGroupProps, } from '@/components/form/field-checkbox-group'; +import { + FieldNestedCheckboxGroup, + FieldNestedCheckboxGroupProps, +} from '@/components/form/field-nested-checkbox-group'; import { FieldNumber, FieldNumberProps } from '@/components/form/field-number'; import { FieldDate, FieldDateProps } from './field-date'; @@ -59,7 +63,8 @@ export type FormFieldControllerProps< | FieldOtpProps | FieldRadioGroupProps | FieldCheckboxProps - | FieldCheckboxGroupProps; + | FieldCheckboxGroupProps + | FieldNestedCheckboxGroupProps; export const FormFieldController = < TFieldValues extends FieldValues = FieldValues, @@ -104,6 +109,9 @@ export const FormFieldController = < case 'checkbox-group': return ; + + case 'nested-checkbox-group': + return ; // -- ADD NEW FIELD COMPONENT HERE -- } }; diff --git a/app/components/ui/nested-checkbox-group/docs.stories.tsx b/app/components/ui/nested-checkbox-group/docs.stories.tsx new file mode 100644 index 000000000..155c5d663 --- /dev/null +++ b/app/components/ui/nested-checkbox-group/docs.stories.tsx @@ -0,0 +1,258 @@ +import { Meta } from '@storybook/react-vite'; +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { NestedCheckbox as Checkbox } from '@/components/ui/nested-checkbox-group/nested-checkbox'; +import { NestedCheckboxGroup as CheckboxGroup } from '@/components/ui/nested-checkbox-group/nested-checkbox-group'; + +export default { + title: 'NestedCheckboxGroup', + component: CheckboxGroup, +} satisfies Meta; + +const astrobears = [ + { value: 'bearstrong', label: 'Bearstrong', children: undefined }, + { value: 'pawdrin', label: 'Buzz Pawdrin', children: undefined }, + { + value: 'grizzlyrin', + label: 'Yuri Grizzlyrin', + disabled: true, + children: [ + { + value: 'mini-grizzlyrin-1', + label: 'Mini Grizzlyrin 1', + }, + { + value: 'mini-grizzlyrin-2', + label: 'Mini Grizzlyrin 2', + }, + ], + }, +] as const; + +export const Default = () => { + return ( + + Astrobears +
+ + Bearstrong + + + Buzz Pawdrin + + + Yuri Grizzlyrin + +
+ + Mini Grizzlyrin 1 + + + Mini Grizzlyrin 2 + +
+
+
+ ); +}; + +export const DefaultValue = () => { + return ( + + Astrobears +
+ + Bearstrong + + + Buzz Pawdrin + + + Yuri Grizzlyrin + +
+ + Mini Grizzlyrin 1 + + + Mini Grizzlyrin 2 + +
+
+
+ ); +}; + +export const Disabled = () => { + return ( + + Astrobears +
+ + Bearstrong + + + Buzz Pawdrin + + + Yuri Grizzlyrin + +
+ + Mini Grizzlyrin 1 + + + Mini Grizzlyrin 2 + +
+
+
+ ); +}; + +export const DisabledOption = () => { + return ( + + Astrobears +
+ + Bearstrong + + + Buzz Pawdrin + + + Yuri Grizzlyrin + +
+ + Mini Grizzlyrin 1 + + + Mini Grizzlyrin 2 + +
+
+
+ ); +}; + +export const WithMappedOptions = () => { + return ( + + Astrobears +
+ {astrobears.map((bear) => { + return ( + + + {bear.label} + + {bear.children && ( +
+ {bear.children.map((miniBear) => ( + + {miniBear.label} + + ))} +
+ )} +
+ ); + })} +
+
+ ); +}; + +export const Complex = () => { + // eslint-disable-next-line @eslint-react/no-nested-component-definitions + const CheckboxAll = ({ + children, + value, + }: { + children: React.ReactNode; + value: string; + }) => { + return ( + { + return ( + + ); + }} + value={value} + noLabel + /> + ); + }; + return ( + +
+ Frontend + Backend + DevOps +
+ +
+ {/* Frontend tasks */} +
+

Frontend

+ + UI/UX Design + + + React Components + + + Animations (disabled) + +
+ + {/* Backend tasks */} +
+

Backend

+ {/* A nested subgroup under "backend" */} + + API Development + + +
+ + REST Endpoints + + + GraphQL Schema + +
+ + + Database Schema + +
+ + {/* DevOps tasks */} +
+

DevOps

+ + CI/CD Pipeline + + + Monitoring & Alerts + +
+
+
+ ); +}; diff --git a/app/components/ui/nested-checkbox-group/helpers.ts b/app/components/ui/nested-checkbox-group/helpers.ts new file mode 100644 index 000000000..cadd86728 --- /dev/null +++ b/app/components/ui/nested-checkbox-group/helpers.ts @@ -0,0 +1,11 @@ +import { CheckboxGroupStore } from '@/components/ui/nested-checkbox-group/store'; + +type Checkboxes = CheckboxGroupStore['checkboxes']; +export const allChecked = (checkboxes: Checkboxes) => + checkboxes.every((checkbox) => checkbox.checked === true); + +export const allUnchecked = (checkboxes: Checkboxes) => + checkboxes.every((checkbox) => checkbox.checked === false); + +export const getChildren = (checkboxes: Checkboxes, value: string) => + checkboxes.filter((cb) => cb.parent === value && !cb.disabled); diff --git a/app/components/ui/nested-checkbox-group/nested-checkbox-group.tsx b/app/components/ui/nested-checkbox-group/nested-checkbox-group.tsx new file mode 100644 index 000000000..4f798e7bd --- /dev/null +++ b/app/components/ui/nested-checkbox-group/nested-checkbox-group.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; + +import { cn } from '@/lib/tailwind/utils'; + +import { + CreateCheckboxGroupStoreOptions, + createCheckboxStore, + NestedCheckboxGroupContext, +} from '@/components/ui/nested-checkbox-group/store'; + +export type NestedCheckboxGroupProps = React.ComponentProps<'div'> & + CreateCheckboxGroupStoreOptions & { + /** Controlled list of checked values */ + value?: string[]; + /** Called whenever checked values change */ + onValueChange?: (values: string[]) => void; + }; +export function NestedCheckboxGroup({ + className, + disabled, + value, + onValueChange, + ...rest +}: NestedCheckboxGroupProps) { + const [checkboxStore] = React.useState(() => + createCheckboxStore({ disabled }) + ); + + // Sync store with value (controlled) + React.useEffect(() => { + if (!value) return; + checkboxStore.setState((state) => { + const updated = state.checkboxes.map((cb) => ({ + ...cb, + checked: value.includes(cb.value), + })); + return { checkboxes: updated }; + }); + }, [value, checkboxStore]); + + React.useEffect(() => { + if (!onValueChange) return; + + return checkboxStore.subscribe((state) => { + const newValues = state.checkboxes + .filter((cb) => cb.checked === true && !cb.disabled) + .map((cb) => cb.value); + + onValueChange(newValues); + }); + }, [onValueChange, checkboxStore]); + + return ( + +
+ + ); +} diff --git a/app/components/ui/nested-checkbox-group/nested-checkbox-interface.tsx b/app/components/ui/nested-checkbox-group/nested-checkbox-interface.tsx new file mode 100644 index 000000000..80a408bbe --- /dev/null +++ b/app/components/ui/nested-checkbox-group/nested-checkbox-interface.tsx @@ -0,0 +1,96 @@ +import { + allChecked, + allUnchecked, + getChildren, +} from '@/components/ui/nested-checkbox-group/helpers'; +import { CheckboxGroupStore } from '@/components/ui/nested-checkbox-group/store'; + +export type NestedCheckboxInterface = { + checkboxes: CheckboxGroupStore['checkboxes']; + checked: boolean; + indeterminate?: boolean; + disabled: boolean; + actions: CheckboxGroupStore['actions']; + onChange: (newChecked: boolean) => void; +}; + +export function createCheckboxInterfaceSelector({ value }: { value: string }) { + return (state: CheckboxGroupStore): NestedCheckboxInterface => { + const checkboxes = state.checkboxes; + const checkbox = checkboxes.find((c) => c.value === value); + const children = getChildren(checkboxes, value); + + const checked = checkbox?.checked === true; + const disabled = !!checkbox?.disabled; + const indeterminate = + checkbox?.checked === 'indeterminate' || + (children.length > 0 && !allChecked(children) && !allUnchecked(children)); + + const onChange = (newChecked: boolean) => { + let updated = [...checkboxes]; + + updateChildren(updated, value, disabled, newChecked); + + updated = updated.map((cb) => + cb.value === value ? { ...cb, checked: newChecked } : cb + ); + + updateParents(updated, value); + + state.actions.setCheckboxes(updated); + }; + + return { + checkboxes, + checked, + disabled, + indeterminate, + onChange, + actions: state.actions, + }; + }; +} + +function updateChildren( + checkboxes: CheckboxGroupStore['checkboxes'], + value: string, + disabled: boolean, + newChecked: boolean +) { + const children = getChildren(checkboxes, value); + + if (!children) return; + for (const child of children) { + if (disabled) child.disabled = true; + + if (!child.disabled) child.checked = newChecked; + + updateChildren( + checkboxes, + child.value, + child.disabled || disabled, + newChecked + ); + } +} + +function updateParents( + updated: CheckboxGroupStore['checkboxes'], + value: string +) { + const checkbox = updated.find((cb) => cb.value === value); + if (!checkbox) return; + + const children = getChildren(updated, value); + if (children.length !== 0) checkbox.checked = getNewChecked(children); + + if (!checkbox.parent) return; + + updateParents(updated, checkbox.parent); +} + +const getNewChecked = (children: CheckboxGroupStore['checkboxes']) => { + if (allChecked(children)) return true; + if (allUnchecked(children)) return false; + return 'indeterminate'; +}; diff --git a/app/components/ui/nested-checkbox-group/nested-checkbox.tsx b/app/components/ui/nested-checkbox-group/nested-checkbox.tsx new file mode 100644 index 000000000..07657c81f --- /dev/null +++ b/app/components/ui/nested-checkbox-group/nested-checkbox.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; + +import { Checkbox, CheckboxProps } from '@/components/ui/checkbox'; +import { createCheckboxInterfaceSelector } from '@/components/ui/nested-checkbox-group/nested-checkbox-interface'; +import { useCheckboxGroupStore } from '@/components/ui/nested-checkbox-group/store'; + +export type NestedCheckboxProps = Omit & { + parent?: string; + value: string; +}; + +export function NestedCheckbox({ + value, + parent, + disabled: disabledProp, + defaultChecked, + ...rest +}: NestedCheckboxProps) { + const { + checked: isChecked, + indeterminate: isIndeterminate, + disabled, + onChange, + actions, + } = useCheckboxGroupStore(createCheckboxInterfaceSelector({ value })); + + React.useEffect(() => { + if (!value) return; + + actions.register({ value, parent, disabled: disabledProp, defaultChecked }); + + return () => actions.unregister({ value }); + }, [value, parent, disabledProp, defaultChecked, actions]); + + return ( + { + onChange(value); + rest.onCheckedChange?.(value, event); + }} + /> + ); +} diff --git a/app/components/ui/nested-checkbox-group/store.ts b/app/components/ui/nested-checkbox-group/store.ts new file mode 100644 index 000000000..8fe8ceefd --- /dev/null +++ b/app/components/ui/nested-checkbox-group/store.ts @@ -0,0 +1,93 @@ +import { deepEqual } from 'fast-equals'; +import * as React from 'react'; +import { createStore, StoreApi } from 'zustand'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; + +type StoreCheckbox = { + parent?: string; + value: string; + checked?: boolean | 'indeterminate'; + disabled?: boolean; +}; + +export type CheckboxGroupStore = { + disabled?: boolean; + checkboxes: Array; + value: () => Array; + actions: { + register: (options: { + parent?: string; + value: string; + disabled?: boolean; + defaultChecked?: boolean; + }) => void; + unregister: (options: { value: string }) => void; + setCheckboxes: (checkboxes: StoreCheckbox[]) => void; + }; +}; + +export type CreateCheckboxGroupStoreOptions = { + disabled?: boolean; +}; + +export const createCheckboxStore = ({ + disabled, +}: CreateCheckboxGroupStoreOptions) => { + return createStore((set, get) => ({ + disabled, + checkboxes: [], + value: () => + get() + .checkboxes.filter((cb) => cb.checked === true && !cb.disabled) + .map((cb) => cb.value), + actions: { + register: ({ value, parent, disabled, defaultChecked }) => { + set((state) => { + const parentCheckbox = state.checkboxes.find( + (c) => c.value === parent + ); + const groupDisabled = state.disabled; + + return { + checkboxes: [ + ...state.checkboxes, + { + value, + parent, + disabled: parentCheckbox?.disabled || disabled || groupDisabled, + checked: parentCheckbox?.checked || defaultChecked || false, + }, + ], + }; + }); + }, + + unregister: ({ value }) => { + set((state) => ({ + checkboxes: state.checkboxes.filter((c) => c.value !== value), + })); + }, + + setCheckboxes: (checkboxes) => { + set({ checkboxes }); + }, + }, + })); +}; + +export const NestedCheckboxGroupContext = + React.createContext(null); + +export const useCheckboxGroupStore = ( + selector: (state: CheckboxGroupStore) => T +) => { + const context = React.use(NestedCheckboxGroupContext); + if (!context) + throw new Error( + 'useCheckboxGroupStore must be used within a ' + ); + + return useStoreWithEqualityFn(context, selector, deepEqual); +}; + +export type CheckboxGroupStoreApi = StoreApi; diff --git a/package.json b/package.json index 2fb9b7ffe..208474152 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "cmdk": "1.1.1", "colorette": "2.0.20", "dayjs": "1.11.13", + "fast-equals": "5.2.2", "i18next": "25.1.2", "i18next-browser-languagedetector": "8.1.0", "input-otp": "1.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66186f326..b91bf323d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: dayjs: specifier: 1.11.13 version: 1.11.13 + fast-equals: + specifier: 5.2.2 + version: 5.2.2 i18next: specifier: 25.1.2 version: 25.1.2(typescript@5.8.3) @@ -4519,6 +4522,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.2.2: + resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==} + engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -10677,7 +10684,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.27.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 chalk: 4.1.2 @@ -12552,6 +12559,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.2.2: {} + fast-fifo@1.3.2: {} fast-glob@3.3.3: From 62b2eb52c6687c2b20df2e902f913fb06ee2395a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sun, 27 Jul 2025 15:44:31 +0200 Subject: [PATCH 11/12] test(FieldNestedCheckboxGroup): main tests --- ...x => field-nested-checkbox-group.spec.tsx} | 188 ++++++++---------- .../field-nested-checkbox-group/index.tsx | 5 +- 2 files changed, 92 insertions(+), 101 deletions(-) rename app/components/form/field-nested-checkbox-group/{field-checkbox-group.spec.tsx => field-nested-checkbox-group.spec.tsx} (60%) diff --git a/app/components/form/field-nested-checkbox-group/field-checkbox-group.spec.tsx b/app/components/form/field-nested-checkbox-group/field-nested-checkbox-group.spec.tsx similarity index 60% rename from app/components/form/field-nested-checkbox-group/field-checkbox-group.spec.tsx rename to app/components/form/field-nested-checkbox-group/field-nested-checkbox-group.spec.tsx index eb63c2b7a..a8167646e 100644 --- a/app/components/form/field-nested-checkbox-group/field-checkbox-group.spec.tsx +++ b/app/components/form/field-nested-checkbox-group/field-nested-checkbox-group.spec.tsx @@ -1,47 +1,55 @@ import { expect, test, vi } from 'vitest'; -import { axe } from 'vitest-axe'; import { z } from 'zod'; +import { NestedCheckboxOption } from '@/components/form/field-nested-checkbox-group'; + import { render, screen, setupUser } from '@/tests/utils'; import { FormField, FormFieldController, FormFieldLabel } from '..'; import { FormMocked } from '../form-test-utils'; -const options = [ - { value: 'bearstrong', label: 'Bearstrong' }, - { value: 'pawdrin', label: 'Buzz Pawdrin' }, - { value: 'grizzlyrin', label: 'Yuri Grizzlyrin' }, - { value: 'jemibear', label: 'Mae Jemibear', disabled: true }, +const options: Array = [ + { + label: 'Astrobears', + value: 'astrobears', + children: [ + { + value: 'bearstrong', + label: 'Bearstrong', + }, + { + value: 'pawdrin', + label: 'Buzz Pawdrin', + children: [ + { + value: 'mini-pawdrin-1', + label: 'Mini Pawdrin 1', + }, + { + value: 'mini-pawdrin-2', + label: 'Mini Pawdrin 2', + }, + ], + }, + { + value: 'grizzlyrin', + label: 'Yuri Grizzlyrin', + disabled: true, + children: [ + { + value: 'mini-grizzlyrin-1', + label: 'Mini Grizzlyrin 1', + }, + { + value: 'mini-grizzlyrin-2', + label: 'Mini Grizzlyrin 2', + }, + ], + }, + ], + }, ]; -test('should have no a11y violations', async () => { - const mockedSubmit = vi.fn(); - HTMLCanvasElement.prototype.getContext = vi.fn(); - - const { container } = render( - - {({ form }) => ( - - Bearstronaut - - - )} - - ); - - const results = await axe(container); - expect(results).toHaveNoViolations(); -}); - test('should toggle checkbox on click', async () => { const user = setupUser(); const mockedSubmit = vi.fn(); @@ -56,7 +64,7 @@ test('should toggle checkbox on click', async () => { Bearstronaut { expect(checkbox).toBeChecked(); await user.click(screen.getByRole('button', { name: 'Submit' })); - expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['pawdrin'] }); -}); - -test('should toggle checkbox on label click', async () => { - const user = setupUser(); - const mockedSubmit = vi.fn(); - - render( - - {({ form }) => ( - - Bearstronaut - - - )} - - ); - - const checkbox = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); - const label = screen.getByText('Buzz Pawdrin'); - - expect(checkbox).not.toBeChecked(); - await user.click(label); - expect(checkbox).toBeChecked(); - - await user.click(screen.getByRole('button', { name: 'Submit' })); - expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['pawdrin'] }); + expect(mockedSubmit).toHaveBeenCalledWith({ + bears: ['pawdrin', 'mini-pawdrin-1', 'mini-pawdrin-2'], + }); }); -test('should allow selecting multiple checkboxes', async () => { +test('should check all non disabled checkboxes', async () => { const user = setupUser(); const mockedSubmit = vi.fn(); @@ -125,7 +100,7 @@ test('should allow selecting multiple checkboxes', async () => { Bearstronaut { ); - const cb1 = screen.getByRole('checkbox', { name: 'Bearstrong' }); - const cb2 = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); + const checkbox = screen.getByLabelText('Astrobears'); - await user.click(cb1); - await user.click(cb2); + await user.click(checkbox); - expect(cb1).toBeChecked(); - expect(cb2).toBeChecked(); + expect(checkbox).toBeChecked(); await user.click(screen.getByRole('button', { name: 'Submit' })); expect(mockedSubmit).toHaveBeenCalledWith({ - bears: ['bearstrong', 'pawdrin'], + bears: [ + 'astrobears', + 'bearstrong', + 'pawdrin', + 'mini-pawdrin-1', + 'mini-pawdrin-2', + ], }); }); @@ -164,7 +142,7 @@ test('keyboard interaction: toggle with space', async () => { Bearstronaut { ); - const cb1 = screen.getByRole('checkbox', { name: 'Bearstrong' }); + const cb1 = screen.getByLabelText('Bearstrong'); + await user.tab(); // Focus the 'check all' checkbox await user.tab(); expect(cb1).toHaveFocus(); @@ -194,7 +173,9 @@ test('default values', async () => { @@ -202,7 +183,7 @@ test('default values', async () => { Bearstronaut { expect(cb).toBeChecked(); await user.click(screen.getByRole('button', { name: 'Submit' })); - expect(mockedSubmit).toHaveBeenCalledWith({ bears: ['grizzlyrin'] }); + expect(mockedSubmit).toHaveBeenCalledWith({ + bears: ['grizzlyrin', 'mini-grizzlyrin-1', 'mini-grizzlyrin-2'], + }); }); test('disabled group', async () => { @@ -226,28 +209,29 @@ test('disabled group', async () => { render( {({ form }) => ( Bearstronaut )} ); - const cb = screen.getByRole('checkbox', { name: 'Buzz Pawdrin' }); - expect(cb).toBeDisabled(); + const checkAll = screen.getByLabelText('Astrobears'); + expect(checkAll).toBeDisabled(); + + await user.click(checkAll); + expect(checkAll).not.toBeChecked(); await user.click(screen.getByRole('button', { name: 'Submit' })); expect(mockedSubmit).toHaveBeenCalledWith({ bears: undefined }); @@ -260,16 +244,19 @@ test('disabled option', async () => { render( {({ form }) => ( Bearstronaut @@ -277,12 +264,13 @@ test('disabled option', async () => { ); - const disabledCb = screen.getByRole('checkbox', { name: 'Mae Jemibear' }); - expect(disabledCb).toBeDisabled(); - - await user.click(disabledCb); - expect(disabledCb).not.toBeChecked(); + const cb = screen.getByLabelText('Buzz Pawdrin'); + const subCb1 = screen.getByLabelText('Mini Pawdrin 1'); + const subCb2 = screen.getByLabelText('Mini Pawdrin 2'); + expect(cb).toBeDisabled(); + expect(subCb1).toBeDisabled(); + expect(subCb2).toBeDisabled(); await user.click(screen.getByRole('button', { name: 'Submit' })); - expect(mockedSubmit).toHaveBeenCalledWith({ bears: [] }); + expect(mockedSubmit).toHaveBeenCalledWith({ bears: undefined }); }); diff --git a/app/components/form/field-nested-checkbox-group/index.tsx b/app/components/form/field-nested-checkbox-group/index.tsx index 131f96518..98a469e31 100644 --- a/app/components/form/field-nested-checkbox-group/index.tsx +++ b/app/components/form/field-nested-checkbox-group/index.tsx @@ -65,7 +65,10 @@ export const FieldNestedCheckboxGroup = < disabled={disabled} defaultValue={defaultValue} shouldUnregister={shouldUnregister} - render={({ field: { onChange, value, ...field }, fieldState }) => { + render={({ + field: { onChange, value, onBlur, ...field }, + fieldState, + }) => { const isInvalid = fieldState.error ? true : undefined; return ( From f6f0c88708044c891d6d3ef9c88d9348d914e8be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sun, 27 Jul 2025 16:08:42 +0200 Subject: [PATCH 12/12] chore: clean code --- .../nested-checkbox-group.tsx | 15 ++++++--------- .../nested-checkbox-interface.tsx | 11 +++++++---- .../ui/nested-checkbox-group/nested-checkbox.tsx | 6 ++++-- app/components/ui/nested-checkbox-group/store.ts | 7 ++++--- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/app/components/ui/nested-checkbox-group/nested-checkbox-group.tsx b/app/components/ui/nested-checkbox-group/nested-checkbox-group.tsx index 4f798e7bd..e234260ce 100644 --- a/app/components/ui/nested-checkbox-group/nested-checkbox-group.tsx +++ b/app/components/ui/nested-checkbox-group/nested-checkbox-group.tsx @@ -3,9 +3,9 @@ import * as React from 'react'; import { cn } from '@/lib/tailwind/utils'; import { + CheckboxGroupContext, + createCheckboxGroupStore, CreateCheckboxGroupStoreOptions, - createCheckboxStore, - NestedCheckboxGroupContext, } from '@/components/ui/nested-checkbox-group/store'; export type NestedCheckboxGroupProps = React.ComponentProps<'div'> & @@ -23,7 +23,7 @@ export function NestedCheckboxGroup({ ...rest }: NestedCheckboxGroupProps) { const [checkboxStore] = React.useState(() => - createCheckboxStore({ disabled }) + createCheckboxGroupStore({ disabled }) ); // Sync store with value (controlled) @@ -42,17 +42,14 @@ export function NestedCheckboxGroup({ if (!onValueChange) return; return checkboxStore.subscribe((state) => { - const newValues = state.checkboxes - .filter((cb) => cb.checked === true && !cb.disabled) - .map((cb) => cb.value); - + const newValues = state.value(); onValueChange(newValues); }); }, [onValueChange, checkboxStore]); return ( - +
- + ); } diff --git a/app/components/ui/nested-checkbox-group/nested-checkbox-interface.tsx b/app/components/ui/nested-checkbox-group/nested-checkbox-interface.tsx index 80a408bbe..c8ab9dd56 100644 --- a/app/components/ui/nested-checkbox-group/nested-checkbox-interface.tsx +++ b/app/components/ui/nested-checkbox-group/nested-checkbox-interface.tsx @@ -5,7 +5,7 @@ import { } from '@/components/ui/nested-checkbox-group/helpers'; import { CheckboxGroupStore } from '@/components/ui/nested-checkbox-group/store'; -export type NestedCheckboxInterface = { +export type NestedCheckboxApi = { checkboxes: CheckboxGroupStore['checkboxes']; checked: boolean; indeterminate?: boolean; @@ -14,8 +14,10 @@ export type NestedCheckboxInterface = { onChange: (newChecked: boolean) => void; }; -export function createCheckboxInterfaceSelector({ value }: { value: string }) { - return (state: CheckboxGroupStore): NestedCheckboxInterface => { +export function createCheckboxApiSelector({ value }: { value: string }) { + return function checkboxApiSelector( + state: CheckboxGroupStore + ): NestedCheckboxApi { const checkboxes = state.checkboxes; const checkbox = checkboxes.find((c) => c.value === value); const children = getChildren(checkboxes, value); @@ -59,7 +61,8 @@ function updateChildren( ) { const children = getChildren(checkboxes, value); - if (!children) return; + if (!children.length) return; + for (const child of children) { if (disabled) child.disabled = true; diff --git a/app/components/ui/nested-checkbox-group/nested-checkbox.tsx b/app/components/ui/nested-checkbox-group/nested-checkbox.tsx index 07657c81f..a64c3afab 100644 --- a/app/components/ui/nested-checkbox-group/nested-checkbox.tsx +++ b/app/components/ui/nested-checkbox-group/nested-checkbox.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Checkbox, CheckboxProps } from '@/components/ui/checkbox'; -import { createCheckboxInterfaceSelector } from '@/components/ui/nested-checkbox-group/nested-checkbox-interface'; +import { createCheckboxApiSelector } from '@/components/ui/nested-checkbox-group/nested-checkbox-interface'; import { useCheckboxGroupStore } from '@/components/ui/nested-checkbox-group/store'; export type NestedCheckboxProps = Omit & { @@ -16,13 +16,14 @@ export function NestedCheckbox({ defaultChecked, ...rest }: NestedCheckboxProps) { + const checkboxApiSelector = createCheckboxApiSelector({ value }); const { checked: isChecked, indeterminate: isIndeterminate, disabled, onChange, actions, - } = useCheckboxGroupStore(createCheckboxInterfaceSelector({ value })); + } = useCheckboxGroupStore(checkboxApiSelector); React.useEffect(() => { if (!value) return; @@ -38,6 +39,7 @@ export function NestedCheckbox({ checked={isChecked} indeterminate={isIndeterminate} disabled={disabled} + defaultChecked={defaultChecked} {...rest} onCheckedChange={(value, event) => { onChange(value); diff --git a/app/components/ui/nested-checkbox-group/store.ts b/app/components/ui/nested-checkbox-group/store.ts index 8fe8ceefd..2adc9a602 100644 --- a/app/components/ui/nested-checkbox-group/store.ts +++ b/app/components/ui/nested-checkbox-group/store.ts @@ -30,7 +30,7 @@ export type CreateCheckboxGroupStoreOptions = { disabled?: boolean; }; -export const createCheckboxStore = ({ +export const createCheckboxGroupStore = ({ disabled, }: CreateCheckboxGroupStoreOptions) => { return createStore((set, get) => ({ @@ -75,13 +75,14 @@ export const createCheckboxStore = ({ })); }; -export const NestedCheckboxGroupContext = +export const CheckboxGroupContext = React.createContext(null); export const useCheckboxGroupStore = ( selector: (state: CheckboxGroupStore) => T ) => { - const context = React.use(NestedCheckboxGroupContext); + const context = React.use(CheckboxGroupContext); + if (!context) throw new Error( 'useCheckboxGroupStore must be used within a '