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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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 09c02e99e49358059b9d07406a50c6537fad5ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20TATOUD?= Date: Sat, 26 Jul 2025 23:16:11 +0200 Subject: [PATCH 10/10] fix: remove unused types --- app/types/utilities.d.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/app/types/utilities.d.ts b/app/types/utilities.d.ts index 0b9dbab19..92e5be77b 100644 --- a/app/types/utilities.d.ts +++ b/app/types/utilities.d.ts @@ -29,27 +29,6 @@ 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 */