Skip to content

Commit 0ed2d75

Browse files
feat: add field select (wip)
1 parent 4aca502 commit 0ed2d75

4 files changed

Lines changed: 231 additions & 15 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { Meta } from '@storybook/react';
3+
import { useForm } from 'react-hook-form';
4+
import { z } from 'zod';
5+
6+
import {
7+
Form,
8+
FormField,
9+
FormFieldController,
10+
FormFieldLabel,
11+
} from '@/components/form';
12+
import { onSubmit } from '@/components/form/docs.utils';
13+
import { Button } from '@/components/ui/button';
14+
import { Select } from '@/components/ui/select';
15+
16+
export default {
17+
title: 'Form/FieldSelect',
18+
} satisfies Meta<typeof Select>;
19+
20+
const zFormSchema = () =>
21+
z.object({
22+
color: z.enum(['red', 'green', 'blue']),
23+
});
24+
25+
const options = [
26+
{ label: 'Red', value: 'red' },
27+
{ label: 'Green', value: 'green' },
28+
{ label: 'Blue', value: 'blue' },
29+
] as const;
30+
31+
const formOptions = {
32+
mode: 'onBlur',
33+
resolver: zodResolver(zFormSchema()),
34+
} as const;
35+
36+
export const Default = () => {
37+
const form = useForm(formOptions);
38+
39+
return (
40+
<Form {...form} onSubmit={onSubmit}>
41+
<div className="flex flex-col gap-4">
42+
<FormField>
43+
<FormFieldLabel>Colors</FormFieldLabel>
44+
<FormFieldController
45+
control={form.control}
46+
type="select"
47+
name="color"
48+
placeholder="Placeholder"
49+
options={options}
50+
/>
51+
</FormField>
52+
<Button type="submit">Submit</Button>
53+
</div>
54+
</Form>
55+
);
56+
};
57+
58+
export const DefaultValue = () => {
59+
const form = useForm<z.infer<ReturnType<typeof zFormSchema>>>({
60+
mode: 'onBlur',
61+
resolver: zodResolver(zFormSchema()),
62+
defaultValues: {
63+
color: 'blue',
64+
},
65+
});
66+
67+
// TODO This story does not work as expected yet (it should fill the input)
68+
return (
69+
<Form {...form} onSubmit={onSubmit}>
70+
<div className="flex flex-col gap-4">
71+
<FormField>
72+
<FormFieldLabel>Colors</FormFieldLabel>
73+
<FormFieldController
74+
control={form.control}
75+
type="select"
76+
name="color"
77+
placeholder="Placeholder"
78+
options={options}
79+
/>
80+
</FormField>
81+
82+
<Button type="submit">Submit</Button>
83+
</div>
84+
</Form>
85+
);
86+
};
87+
88+
// TODO This story does not work as expected yet (it should not block the form submit)
89+
export const Disabled = () => {
90+
const form = useForm(formOptions);
91+
92+
return (
93+
<Form {...form} onSubmit={(values) => console.log(values)}>
94+
<div className="flex flex-col gap-4">
95+
<FormField>
96+
<FormFieldLabel>Colors</FormFieldLabel>
97+
<FormFieldController
98+
control={form.control}
99+
type="select"
100+
name="color"
101+
placeholder="Placeholder"
102+
disabled
103+
options={options}
104+
/>
105+
</FormField>
106+
107+
<Button type="submit">Submit</Button>
108+
</div>
109+
</Form>
110+
);
111+
};
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { ComponentProps } from 'react';
2+
import {
3+
Controller,
4+
ControllerRenderProps,
5+
FieldPath,
6+
FieldValues,
7+
} from 'react-hook-form';
8+
9+
import { cn } from '@/lib/tailwind/utils';
10+
11+
import { useFormField } from '@/components/form/form-field';
12+
import { FieldCommonProps } from '@/components/form/form-field-controller';
13+
import { Select } from '@/components/ui/select';
14+
15+
export type FieldSelectProps<
16+
TFieldValues extends FieldValues = FieldValues,
17+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
18+
> = FieldCommonProps<TFieldValues, TName> & {
19+
type: 'select';
20+
containerProps?: ComponentProps<'div'>;
21+
} & RemoveFromType<
22+
Omit<
23+
ComponentProps<typeof Select>,
24+
'id' | 'aria-invalid' | 'aria-describedby'
25+
>,
26+
ControllerRenderProps
27+
>;
28+
29+
export const FieldSelect = <
30+
TFieldValues extends FieldValues = FieldValues,
31+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
32+
>(
33+
props: FieldSelectProps<TFieldValues, TName>
34+
) => {
35+
const {
36+
name,
37+
control,
38+
disabled,
39+
defaultValue,
40+
shouldUnregister,
41+
type,
42+
containerProps,
43+
...rest
44+
} = props;
45+
46+
const ctx = useFormField();
47+
48+
return (
49+
<Controller
50+
name={name}
51+
control={control}
52+
disabled={disabled}
53+
defaultValue={defaultValue}
54+
shouldUnregister={shouldUnregister}
55+
render={({ field, fieldState }) => (
56+
<div
57+
{...containerProps}
58+
className={cn(
59+
'flex flex-1 flex-col gap-1',
60+
containerProps?.className
61+
)}
62+
>
63+
<Select
64+
ids={{ input: ctx.id }}
65+
invalid={fieldState.error ? true : undefined}
66+
aria-invalid={fieldState.error ? true : undefined}
67+
aria-describedby={
68+
!fieldState.error
69+
? ctx.descriptionId
70+
: `${ctx.descriptionId} ${ctx.errorId}`
71+
}
72+
{...rest}
73+
{...field}
74+
/>
75+
</div>
76+
)}
77+
/>
78+
);
79+
};

app/components/form/form-field-controller.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,10 @@ import {
66
FieldValues,
77
} from 'react-hook-form';
88

9-
import {
10-
FieldDateInput,
11-
FieldDateInputProps,
12-
} from '@/components/form/field-date-input';
13-
import {
14-
FieldDatePicker,
15-
FieldDatePickerProps,
16-
} from '@/components/form/field-date-picker';
17-
9+
import { FieldDateInput, FieldDateInputProps } from './field-date-input';
10+
import { FieldDatePicker, FieldDatePickerProps } from './field-date-picker';
1811
import { FieldOtp, FieldOtpProps } from './field-otp';
12+
import { FieldSelect, FieldSelectProps } from './field-select';
1913
import { FieldText, FieldTextProps } from './field-text';
2014
import { useFormField } from './form-field';
2115

@@ -46,6 +40,7 @@ export type FormFieldControllerProps<
4640
> =
4741
| FieldCustomProps<TFieldValues, TName>
4842
// -- ADD NEW FIELD PROPS TYPE HERE --
43+
| FieldSelectProps<TFieldValues, TName>
4944
| FieldDateInputProps<TFieldValues, TName>
5045
| FieldDatePickerProps<TFieldValues, TName>
5146
| FieldTextProps<TFieldValues, TName>
@@ -83,6 +78,8 @@ export const FormFieldController = <
8378
case 'date-picker':
8479
return <FieldDatePicker {...props} />;
8580

81+
case 'select':
82+
return <FieldSelect {...props} />;
8683
// -- ADD NEW FIELD COMPONENT HERE --
8784
}
8885
};

app/components/ui/select.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,23 @@ type OptionBase = { value: string; label: string };
2020
type InputProps = ComponentProps<typeof Input>;
2121
type InputPropsRoot = Pick<InputProps, 'placeholder'>;
2222

23-
type SelectProps<Option extends OptionBase> = Omit<
24-
ComboboxRootProps<Option>,
25-
'collection'
23+
/**
24+
* We override how Ark UI handle select, has this is a "single" select and it is
25+
* easier to plug as is with React Hook Form
26+
*/
27+
type ControlProps = {
28+
value?: string;
29+
defaultValue?: string;
30+
/** Listen for all the changes: new value, value change, clear */
31+
onChange?: (value: string | null | undefined) => void;
32+
};
33+
34+
type SelectProps<Option extends OptionBase> = Overwrite<
35+
Omit<ComboboxRootProps<Option>, 'collection'>,
36+
ControlProps
2637
> &
2738
InputPropsRoot & {
28-
options: Array<Option>;
39+
options: ReadonlyArray<Option>;
2940
inputProps?: RemoveFromType<InputProps, InputPropsRoot>;
3041
createListCollectionOptions?: Omit<
3142
Parameters<typeof createListCollection<Option>>[0],
@@ -42,6 +53,9 @@ export const Select = <Option extends OptionBase>({
4253
onOpenChange,
4354
onInputValueChange,
4455
renderEmpty,
56+
onChange,
57+
value,
58+
defaultValue,
4559
...props
4660
}: SelectProps<Option>) => {
4761
const [items, setItems] = useState(options);
@@ -72,6 +86,14 @@ export const Select = <Option extends OptionBase>({
7286
}
7387
};
7488

89+
const handleOnValueChange = (details: Combobox.ValueChangeDetails) => {
90+
if (details.value.length) {
91+
onChange?.(details.value.at(0));
92+
}
93+
94+
props.onValueChange?.(details);
95+
};
96+
7597
const ui = getUiState((set) => {
7698
if (collection.items.length === 0 && isNullish(renderEmpty))
7799
return set('empty');
@@ -86,16 +108,23 @@ export const Select = <Option extends OptionBase>({
86108
collection={collection}
87109
onInputValueChange={handleInputChange}
88110
onOpenChange={handleOpenChange}
89-
onValueChange={console.log}
111+
onValueChange={handleOnValueChange}
90112
openOnClick
113+
value={value ? [value] : undefined}
114+
defaultValue={defaultValue ? [defaultValue] : undefined}
91115
{...props}
92116
>
93117
<Combobox.Control>
94118
<Combobox.Input asChild>
95119
<Input
96120
endElement={
97121
<div className="flex gap-1">
98-
<Combobox.ClearTrigger asChild>
122+
<Combobox.ClearTrigger
123+
onClick={() => {
124+
onChange?.(null);
125+
}}
126+
asChild
127+
>
99128
<Button variant="ghost" size="icon-xs">
100129
<X />
101130
</Button>

0 commit comments

Comments
 (0)