Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions app/components/form/field-checkbox-group/docs.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof FieldCheckboxGroup>;

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 (
<Form {...form} onSubmit={onSubmit}>
<div className="flex flex-col gap-4">
<FormField>
<FormFieldLabel>Bearstronaut</FormFieldLabel>
<FormFieldHelper>Select your favorite bearstronaut</FormFieldHelper>
<FormFieldController
type="checkbox-group"
control={form.control}
name="bears"
options={astrobears}
/>
</FormField>
<div>
<Button type="submit">Submit</Button>
</div>
</div>
</Form>
);
};
92 changes: 92 additions & 0 deletions app/components/form/field-checkbox-group/index.tsx
Original file line number Diff line number Diff line change
@@ -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<CheckboxProps, 'children' | 'render'> & {
label: ReactNode;
};
export type FieldCheckboxGroupProps<
TFIeldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFIeldValues> = FieldPath<TFIeldValues>,
> = FieldProps<
TFIeldValues,
TName,
{
type: 'checkbox-group';
options: Array<CheckboxOption>;
containerProps?: ComponentProps<'div'>;
} & ComponentProps<typeof CheckboxGroup>
>;

export const FieldCheckboxGroup = <
TFIeldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFIeldValues> = FieldPath<TFIeldValues>,
>(
props: FieldCheckboxGroupProps<TFIeldValues, TName>
) => {
const {
type,
name,
control,
defaultValue,
disabled,
shouldUnregister,
containerProps,
options,
...rest
} = props;

const ctx = useFormField();

return (
<Controller
name={name}
control={control}
defaultValue={defaultValue}
disabled={disabled}
shouldUnregister={shouldUnregister}
render={({ field: { value, onChange, ...field }, fieldState }) => {
const isInvalid = fieldState.error ? true : undefined;
return (
<div
{...containerProps}
className={cn('', containerProps?.className)}
>
<CheckboxGroup
id={ctx.id}
aria-invalid={isInvalid}
aria-labelledby={ctx.labelId}
aria-describedby={
!fieldState.error
? `${ctx.descriptionId}`
: `${ctx.descriptionId} ${ctx.errorId}`
}
value={value}
onValueChange={onChange}
{...rest}
>
{options.map(({ label, ...option }) => (
<Checkbox
key={option.value}
{...option}
aria-invalid={isInvalid}
{...field}
>
{label}
</Checkbox>
))}
</CheckboxGroup>
<FormFieldError />
</div>
);
}}
/>
);
};
12 changes: 10 additions & 2 deletions app/components/form/field-radio-group/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const FieldRadioGroup = <
defaultValue={defaultValue}
shouldUnregister={shouldUnregister}
render={({ field: { onChange, value, ...field }, fieldState }) => {
const isInvalid = fieldState.error ? true : undefined;
return (
<div
{...containerProps}
Expand All @@ -64,7 +65,7 @@ export const FieldRadioGroup = <
>
<RadioGroup
id={ctx.id}
aria-invalid={fieldState.error ? true : undefined}
aria-invalid={isInvalid}
aria-labelledby={ctx.labelId}
aria-describedby={
!fieldState.error
Expand All @@ -83,6 +84,7 @@ export const FieldRadioGroup = <
<React.Fragment key={radioId}>
{renderOption({
label,
'aria-invalid': isInvalid,
...field,
...option,
})}
Expand All @@ -91,7 +93,13 @@ export const FieldRadioGroup = <
}

return (
<Radio key={radioId} size={size} {...field} {...option}>
<Radio
key={radioId}
aria-invalid={isInvalid}
size={size}
{...field}
{...option}
>
{label}
</Radio>
);
Expand Down
10 changes: 9 additions & 1 deletion app/components/form/form-field-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -54,7 +58,8 @@ export type FormFieldControllerProps<
| FieldTextProps<TFieldValues, TName>
| FieldOtpProps<TFieldValues, TName>
| FieldRadioGroupProps<TFieldValues, TName>
| FieldCheckboxProps<TFieldValues, TName>;
| FieldCheckboxProps<TFieldValues, TName>
| FieldCheckboxGroupProps<TFieldValues, TName>;

export const FormFieldController = <
TFieldValues extends FieldValues = FieldValues,
Expand Down Expand Up @@ -96,6 +101,9 @@ export const FormFieldController = <

case 'checkbox':
return <FieldCheckbox {...props} />;

case 'checkbox-group':
return <FieldCheckboxGroup {...props} />;
// -- ADD NEW FIELD COMPONENT HERE --
}
};
Expand Down
149 changes: 149 additions & 0 deletions app/components/ui/checkbox-group.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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',
component: CheckboxGroup,
} satisfies Meta<typeof CheckboxGroup>;

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 (
<CheckboxGroup>
{astrobears.map((option) => (
<Checkbox key={option.value} value={option.value}>
{option.label}
</Checkbox>
))}
</CheckboxGroup>
);
};

export const DefaultValue = () => {
return (
<CheckboxGroup defaultValue={['bearstrong']}>
{astrobears.map((option) => (
<Checkbox key={option.value} value={option.value}>
{option.label}
</Checkbox>
))}
</CheckboxGroup>
);
};

export const Disabled = () => {
return (
<CheckboxGroup disabled>
{astrobears.map((option) => (
<Checkbox key={option.value} value={option.value}>
{option.label}
</Checkbox>
))}
</CheckboxGroup>
);
};

export const DisabledOption = () => {
return (
<CheckboxGroup>
{astrobears.map((option) => (
<Checkbox
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</Checkbox>
))}
</CheckboxGroup>
);
};

export const ParentCheckbox = () => {
const [value, setValue] = useState<string[]>([]);

return (
<CheckboxGroup
value={value}
onValueChange={setValue}
allValues={astrobears.map((bear) => bear.value)}
>
<Checkbox name="astrobears" parent>
Astrobears
</Checkbox>
<div className="flex flex-col gap-1 pl-4">
{astrobears.map((option) => (
<Checkbox key={option.value} value={option.value}>
{option.label}
</Checkbox>
))}
</div>
</CheckboxGroup>
);
};

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 (
<CheckboxGroup {...main}>
<Checkbox name="astrobears" parent indeterminate={indeterminate}>
Astrobears
</Checkbox>
<div className="space-y-1 pl-4">
{nestedBears.map((bear) => {
if (!bear.children) {
return (
<Checkbox key={bear.value} value={bear.value}>
{bear.label}
</Checkbox>
);
}

return (
<CheckboxGroup key={bear.value} {...nested}>
<Checkbox value={bear.value} parent>
{bear.label}
</Checkbox>
<div className="space-y-1 pl-4">
{bear.children.map((bear) => (
<Checkbox key={bear.value} value={bear.value}>
{bear.label}
</Checkbox>
))}
</div>
</CheckboxGroup>
);
})}
</div>
</CheckboxGroup>
);
};
14 changes: 14 additions & 0 deletions app/components/ui/checkbox-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CheckboxGroup as CheckboxGroupPrimitive } from '@base-ui-components/react/checkbox-group';

import { cn } from '@/lib/tailwind/utils';

export type BaseCheckboxGroupProps = CheckboxGroupPrimitive.Props;

export function CheckboxGroup(props: BaseCheckboxGroupProps) {
return (
<CheckboxGroupPrimitive
{...props}
className={cn('flex flex-col items-start gap-1', props?.className)}
/>
);
}
Loading
Loading