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..c2e3d6917 --- /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({ + 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 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: { + bears: ['pawdrin'], + }, + }); + + return ( +
+
+ + Bearstronaut + Select your favorite bearstronaut + + +
+ +
+
+
+ ); +}; + +export const Disabled = () => { + const form = useForm({ + ...formOptions, + defaultValues: { + bears: ['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..232b2bf3a --- /dev/null +++ b/app/components/form/field-checkbox-group/index.tsx @@ -0,0 +1,101 @@ +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(); + + return ( + { + const isInvalid = fieldState.error ? true : undefined; + + return ( +
+ { + onChange?.(value); + rest.onValueChange?.(value, event); + }} + {...rest} + > + {options.map(({ label, ...option }) => ( + + {label} + + ))} + + +
+ ); + }} + /> + ); +}; 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-nested-checkbox-group.spec.tsx b/app/components/form/field-nested-checkbox-group/field-nested-checkbox-group.spec.tsx new file mode 100644 index 000000000..a8167646e --- /dev/null +++ b/app/components/form/field-nested-checkbox-group/field-nested-checkbox-group.spec.tsx @@ -0,0 +1,276 @@ +import { expect, test, vi } from 'vitest'; +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: 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 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', 'mini-pawdrin-1', 'mini-pawdrin-2'], + }); +}); + +test('should check all non disabled checkboxes', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const checkbox = screen.getByLabelText('Astrobears'); + + await user.click(checkbox); + + expect(checkbox).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ + bears: [ + 'astrobears', + 'bearstrong', + 'pawdrin', + 'mini-pawdrin-1', + 'mini-pawdrin-2', + ], + }); +}); + +test('keyboard interaction: toggle with space', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + const cb1 = screen.getByLabelText('Bearstrong'); + + await user.tab(); // Focus the 'check all' checkbox + 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', 'mini-grizzlyrin-1', 'mini-grizzlyrin-2'], + }); +}); + +test('disabled group', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + 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 }); +}); + +test('disabled option', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + Bearstronaut + + + )} + + ); + + 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: undefined }); +}); 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..98a469e31 --- /dev/null +++ b/app/components/form/field-nested-checkbox-group/index.tsx @@ -0,0 +1,142 @@ +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/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..5531b4b77 100644 --- a/app/components/form/form-field-controller.tsx +++ b/app/components/form/form-field-controller.tsx @@ -10,6 +10,14 @@ import { FieldCheckbox, FieldCheckboxProps, } from '@/components/form/field-checkbox'; +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'; @@ -54,7 +62,9 @@ export type FormFieldControllerProps< | FieldTextProps | FieldOtpProps | FieldRadioGroupProps - | FieldCheckboxProps; + | FieldCheckboxProps + | FieldCheckboxGroupProps + | FieldNestedCheckboxGroupProps; export const FormFieldController = < TFieldValues extends FieldValues = FieldValues, @@ -96,6 +106,12 @@ export const FormFieldController = < case 'checkbox': return ; + + case 'checkbox-group': + return ; + + case 'nested-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..c86d88948 --- /dev/null +++ b/app/components/ui/checkbox-group.stories.tsx @@ -0,0 +1,66 @@ +import { Meta } from '@storybook/react-vite'; + +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} + + ))} + + ); +}; diff --git a/app/components/ui/checkbox-group.tsx b/app/components/ui/checkbox-group.tsx new file mode 100644 index 000000000..cd9c198e7 --- /dev/null +++ b/app/components/ui/checkbox-group.tsx @@ -0,0 +1,13 @@ +import { CheckboxGroup as CheckboxGroupPrimitive } from '@base-ui-components/react/checkbox-group'; + +import { cn } from '@/lib/tailwind/utils'; + +type CheckboxGroupProps = CheckboxGroupPrimitive.Props; +export function CheckboxGroup({ className, ...props }: CheckboxGroupProps) { + return ( + + ); +} diff --git a/app/components/ui/checkbox.tsx b/app/components/ui/checkbox.tsx index 31b242b6b..4010e49ab 100644 --- a/app/components/ui/checkbox.tsx +++ b/app/components/ui/checkbox.tsx @@ -19,7 +19,7 @@ const labelVariants = cva('flex items-start gap-2.5 text-primary', { }); const checkboxVariants = cva( - 'flex flex-none cursor-pointer items-center justify-center rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-muted-foreground disabled:opacity-20 data-checked:bg-primary data-checked:text-primary-foreground data-indeterminate:border data-indeterminate:border-primary/20 data-unchecked:border data-unchecked:border-primary/20', + 'flex flex-none cursor-pointer items-center justify-center rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-muted-foreground disabled:opacity-20 aria-invalid:focus-visible:ring-destructive/50 data-checked:bg-primary data-checked:text-primary-foreground data-indeterminate:border data-indeterminate:border-primary/20 data-unchecked:border data-unchecked:border-primary/20 aria-invalid:data-unchecked:border-destructive', { variants: { size: { 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..e234260ce --- /dev/null +++ b/app/components/ui/nested-checkbox-group/nested-checkbox-group.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; + +import { cn } from '@/lib/tailwind/utils'; + +import { + CheckboxGroupContext, + createCheckboxGroupStore, + CreateCheckboxGroupStoreOptions, +} 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(() => + createCheckboxGroupStore({ 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.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..c8ab9dd56 --- /dev/null +++ b/app/components/ui/nested-checkbox-group/nested-checkbox-interface.tsx @@ -0,0 +1,99 @@ +import { + allChecked, + allUnchecked, + getChildren, +} from '@/components/ui/nested-checkbox-group/helpers'; +import { CheckboxGroupStore } from '@/components/ui/nested-checkbox-group/store'; + +export type NestedCheckboxApi = { + checkboxes: CheckboxGroupStore['checkboxes']; + checked: boolean; + indeterminate?: boolean; + disabled: boolean; + actions: CheckboxGroupStore['actions']; + onChange: (newChecked: boolean) => void; +}; + +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); + + 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.length) 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..a64c3afab --- /dev/null +++ b/app/components/ui/nested-checkbox-group/nested-checkbox.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; + +import { Checkbox, CheckboxProps } from '@/components/ui/checkbox'; +import { createCheckboxApiSelector } 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 checkboxApiSelector = createCheckboxApiSelector({ value }); + const { + checked: isChecked, + indeterminate: isIndeterminate, + disabled, + onChange, + actions, + } = useCheckboxGroupStore(checkboxApiSelector); + + 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..2adc9a602 --- /dev/null +++ b/app/components/ui/nested-checkbox-group/store.ts @@ -0,0 +1,94 @@ +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 createCheckboxGroupStore = ({ + 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 CheckboxGroupContext = + React.createContext(null); + +export const useCheckboxGroupStore = ( + selector: (state: CheckboxGroupStore) => T +) => { + const context = React.use(CheckboxGroupContext); + + if (!context) + throw new Error( + 'useCheckboxGroupStore must be used within a ' + ); + + return useStoreWithEqualityFn(context, selector, deepEqual); +}; + +export type CheckboxGroupStoreApi = StoreApi; diff --git a/app/components/ui/radio-group.tsx b/app/components/ui/radio-group.tsx index 0ce6154c2..c6d1ac671 100644 --- a/app/components/ui/radio-group.tsx +++ b/app/components/ui/radio-group.tsx @@ -20,7 +20,7 @@ const labelVariants = cva('flex items-start gap-2.5 text-primary', { }); const radioVariants = cva( - 'flex flex-none cursor-pointer items-center justify-center rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-muted-foreground disabled:opacity-20 data-checked:bg-primary data-checked:text-primary-foreground data-indeterminate:border data-indeterminate:border-primary/20 data-unchecked:border data-unchecked:border-primary/20', + 'flex flex-none cursor-pointer items-center justify-center rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-muted-foreground disabled:opacity-20 aria-invalid:focus-visible:ring-destructive/50 data-checked:bg-primary data-checked:text-primary-foreground data-indeterminate:border data-indeterminate:border-primary/20 data-unchecked:border data-unchecked:border-primary/20 aria-invalid:data-unchecked:border-destructive', { variants: { size: { 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 */ 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: