Skip to content

Commit ed10deb

Browse files
committed
feat: field textarea
1 parent f3f9ef4 commit ed10deb

File tree

5 files changed

+374
-2
lines changed

5 files changed

+374
-2
lines changed

src/components/form/docs.stories.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ export const Default = () => {
4747
<div className="flex flex-col gap-4">
4848
<FormField size="lg">
4949
<FormFieldLabel>Name</FormFieldLabel>
50-
<FormFieldController control={form.control} type="text" name="name" />
50+
<FormFieldController
51+
control={form.control}
52+
type="textarea"
53+
name="name"
54+
/>
5155
<FormFieldHelper>This is an helper text</FormFieldHelper>
5256
</FormField>
5357
<FormField>
@@ -95,7 +99,7 @@ export const NoHtmlForm = () => {
9599
<FormFieldLabel>Name</FormFieldLabel>
96100
<FormFieldController
97101
control={form.control}
98-
type="text"
102+
type="textarea"
99103
name="name"
100104
/>
101105
<FormFieldHelper>This is an helper text</FormFieldHelper>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { useForm } from 'react-hook-form';
3+
import { z } from 'zod';
4+
5+
import { zu } from '@/lib/zod/zod-utils';
6+
7+
import { FormFieldController } from '@/components/form';
8+
import { onSubmit } from '@/components/form/docs.utils';
9+
import { Button } from '@/components/ui/button';
10+
11+
import { Form, FormField, FormFieldHelper, FormFieldLabel } from '../';
12+
13+
export default {
14+
title: 'Form/FieldTextarea',
15+
};
16+
17+
const zFormSchema = () =>
18+
z.object({
19+
description: zu.fieldText.required({ error: 'Description is required' }),
20+
});
21+
22+
const formOptions = {
23+
mode: 'onBlur',
24+
resolver: zodResolver(zFormSchema()),
25+
defaultValues: {
26+
description: '',
27+
},
28+
} as const;
29+
30+
export const Default = () => {
31+
const form = useForm(formOptions);
32+
33+
return (
34+
<Form {...form} onSubmit={onSubmit}>
35+
<div className="flex flex-col gap-4">
36+
<FormField>
37+
<FormFieldLabel>Description</FormFieldLabel>
38+
<FormFieldController
39+
type="textarea"
40+
control={form.control}
41+
name="description"
42+
placeholder="Buzz Pawdrin"
43+
/>
44+
<FormFieldHelper>Help</FormFieldHelper>
45+
</FormField>
46+
<div>
47+
<Button type="submit">Submit</Button>
48+
</div>
49+
</div>
50+
</Form>
51+
);
52+
};
53+
54+
export const DefaultValue = () => {
55+
const form = useForm({
56+
...formOptions,
57+
defaultValues: {
58+
description: 'Default description',
59+
},
60+
});
61+
62+
return (
63+
<Form {...form} onSubmit={onSubmit}>
64+
<div className="flex flex-col gap-4">
65+
<FormField>
66+
<FormFieldLabel>Description</FormFieldLabel>
67+
<FormFieldController
68+
control={form.control}
69+
type="textarea"
70+
name="description"
71+
placeholder="Buzz Pawdrin"
72+
/>
73+
<FormFieldHelper>Help</FormFieldHelper>
74+
</FormField>
75+
<div>
76+
<Button type="submit">Submit</Button>
77+
</div>
78+
</div>
79+
</Form>
80+
);
81+
};
82+
83+
export const Disabled = () => {
84+
const form = useForm({
85+
...formOptions,
86+
defaultValues: {
87+
description: 'Default Value',
88+
},
89+
});
90+
91+
return (
92+
<Form {...form} onSubmit={onSubmit}>
93+
<div className="flex flex-col gap-4">
94+
<FormField>
95+
<FormFieldLabel>Description</FormFieldLabel>
96+
<FormFieldController
97+
control={form.control}
98+
type="textarea"
99+
name="description"
100+
placeholder="Buzz Pawdrin"
101+
disabled
102+
/>
103+
<FormFieldHelper>Help</FormFieldHelper>
104+
</FormField>
105+
<div>
106+
<Button type="submit">Submit</Button>
107+
</div>
108+
</div>
109+
</Form>
110+
);
111+
};
112+
113+
export const ReadOnly = () => {
114+
const form = useForm({
115+
...formOptions,
116+
defaultValues: {
117+
description: 'Default Value',
118+
},
119+
});
120+
121+
return (
122+
<Form {...form} onSubmit={onSubmit}>
123+
<div className="flex flex-col gap-4">
124+
<FormField>
125+
<FormFieldLabel>Description</FormFieldLabel>
126+
<FormFieldController
127+
control={form.control}
128+
type="textarea"
129+
name="description"
130+
placeholder="Buzz Pawdrin"
131+
readOnly
132+
/>
133+
<FormFieldHelper>Help</FormFieldHelper>
134+
</FormField>
135+
<div>
136+
<Button type="submit">Submit</Button>
137+
</div>
138+
</div>
139+
</Form>
140+
);
141+
};
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { expect, test, vi } from 'vitest';
2+
import { z } from 'zod';
3+
4+
import { page, render, setupUser } from '@/tests/utils';
5+
6+
import { FormField, FormFieldLabel } from '..';
7+
import { FormFieldController } from '../form-field-controller';
8+
import { FormMocked } from '../form-test-utils';
9+
10+
test('update value', async () => {
11+
const user = setupUser();
12+
const mockedSubmit = vi.fn();
13+
14+
render(
15+
<FormMocked
16+
schema={z.object({ name: z.string() })}
17+
useFormOptions={{ defaultValues: { name: '' } }}
18+
onSubmit={mockedSubmit}
19+
>
20+
{({ form }) => (
21+
<FormField>
22+
<FormFieldLabel>Name</FormFieldLabel>
23+
<FormFieldController
24+
type="textarea"
25+
control={form.control}
26+
name="name"
27+
/>
28+
</FormField>
29+
)}
30+
</FormMocked>
31+
);
32+
const input = page.getByLabelText('Name').element() as HTMLInputElement;
33+
34+
await user.type(input, 'new value');
35+
expect(input.value).toBe('new value');
36+
await user.click(page.getByRole('button', { name: 'Submit' }));
37+
expect(mockedSubmit).toHaveBeenCalledWith({ name: 'new value' });
38+
});
39+
40+
test('default value', async () => {
41+
const user = setupUser();
42+
const mockedSubmit = vi.fn();
43+
render(
44+
<FormMocked
45+
schema={z.object({ name: z.string() })}
46+
useFormOptions={{
47+
defaultValues: {
48+
name: 'default value',
49+
},
50+
}}
51+
onSubmit={mockedSubmit}
52+
>
53+
{({ form }) => (
54+
<FormField>
55+
<FormFieldLabel>Name</FormFieldLabel>
56+
<FormFieldController
57+
type="textarea"
58+
control={form.control}
59+
name="name"
60+
/>
61+
</FormField>
62+
)}
63+
</FormMocked>
64+
);
65+
const input = page.getByLabelText('Name').element() as HTMLInputElement;
66+
expect(input.value).toBe('default value');
67+
await user.click(page.getByRole('button', { name: 'Submit' }));
68+
expect(mockedSubmit).toHaveBeenCalledWith({ name: 'default value' });
69+
});
70+
71+
test('disabled', async () => {
72+
const user = setupUser();
73+
const mockedSubmit = vi.fn();
74+
75+
render(
76+
<FormMocked
77+
schema={z.object({ name: z.string() })}
78+
useFormOptions={{ defaultValues: { name: 'new value' } }}
79+
onSubmit={mockedSubmit}
80+
>
81+
{({ form }) => (
82+
<FormField>
83+
<FormFieldLabel>Name</FormFieldLabel>
84+
<FormFieldController
85+
type="textarea"
86+
control={form.control}
87+
name="name"
88+
disabled
89+
/>
90+
</FormField>
91+
)}
92+
</FormMocked>
93+
);
94+
const input = page.getByLabelText('Name');
95+
try {
96+
await user.type(input, 'another value');
97+
} catch {
98+
// Expected to fail since input is disabled
99+
}
100+
await user.click(page.getByRole('button', { name: 'Submit' }));
101+
expect(mockedSubmit).toHaveBeenCalledWith({ name: undefined });
102+
});
103+
104+
test('readOnly', async () => {
105+
const user = setupUser();
106+
const mockedSubmit = vi.fn();
107+
108+
render(
109+
<FormMocked
110+
schema={z.object({ name: z.string() })}
111+
useFormOptions={{ defaultValues: { name: 'new value' } }}
112+
onSubmit={mockedSubmit}
113+
>
114+
{({ form }) => (
115+
<FormField>
116+
<FormFieldLabel>Name</FormFieldLabel>
117+
<FormFieldController
118+
type="textarea"
119+
control={form.control}
120+
name="name"
121+
readOnly
122+
/>
123+
</FormField>
124+
)}
125+
</FormMocked>
126+
);
127+
const input = page.getByLabelText('Name');
128+
try {
129+
await user.type(input, 'another value');
130+
} catch {
131+
// Expected to fail since input is readOnly
132+
}
133+
await user.click(page.getByRole('button', { name: 'Submit' }));
134+
expect(mockedSubmit).toHaveBeenCalledWith({ name: 'new value' });
135+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { ComponentProps } from 'react';
2+
import { Controller, FieldPath, FieldValues } from 'react-hook-form';
3+
4+
import { cn } from '@/lib/tailwind/utils';
5+
6+
import { Textarea } from '@/components/ui/textarea';
7+
8+
import { useFormField } from '../form-field';
9+
import { FieldProps } from '../form-field-controller';
10+
import { FormFieldError } from '../form-field-error';
11+
12+
export type FieldTextareaProps<
13+
TFieldValues extends FieldValues = FieldValues,
14+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
15+
TTransformedValues = TFieldValues,
16+
> = FieldProps<
17+
TFieldValues,
18+
TName,
19+
TTransformedValues,
20+
{
21+
type: 'textarea';
22+
containerProps?: ComponentProps<'div'>;
23+
} & ComponentProps<typeof Textarea>
24+
>;
25+
26+
export const FieldTextarea = <
27+
TFieldValues extends FieldValues = FieldValues,
28+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
29+
TTransformedValues = TFieldValues,
30+
>(
31+
props: FieldTextareaProps<TFieldValues, TName, TTransformedValues>
32+
) => {
33+
const {
34+
name,
35+
type,
36+
disabled,
37+
defaultValue,
38+
shouldUnregister,
39+
control,
40+
containerProps,
41+
...rest
42+
} = props;
43+
44+
const ctx = useFormField();
45+
return (
46+
<Controller
47+
name={name}
48+
control={control}
49+
disabled={disabled}
50+
defaultValue={defaultValue}
51+
shouldUnregister={shouldUnregister}
52+
render={({ field, fieldState }) => (
53+
<div
54+
{...containerProps}
55+
className={cn(
56+
'flex flex-1 flex-col gap-1',
57+
containerProps?.className
58+
)}
59+
>
60+
<Textarea
61+
id={ctx.id}
62+
aria-invalid={fieldState.error ? true : undefined}
63+
aria-describedby={
64+
!fieldState.error
65+
? `${ctx.descriptionId}`
66+
: `${ctx.descriptionId} ${ctx.errorId}`
67+
}
68+
{...rest}
69+
{...field}
70+
onChange={(e) => {
71+
field.onChange(e);
72+
rest.onChange?.(e);
73+
}}
74+
onBlur={(e) => {
75+
field.onBlur();
76+
rest.onBlur?.(e);
77+
}}
78+
/>
79+
<FormFieldError />
80+
</div>
81+
)}
82+
/>
83+
);
84+
};

0 commit comments

Comments
 (0)