Skip to content

Commit 4309ab5

Browse files
authored
✨ feat: form support (#147)
* 🚧 feat: add form components with input, textarea and validation using conform-to/react * 🚧 feat: add form components with Zod and Conform validation to Storybook * 🚧 feat: add Alert component and update Form story with improved success message styling * 🚧 feat: adding form components to remix starter * 🎨 feat: format fix and demo form story refactor * 🚧 feat: add form components to remix starter * 🚧 feat: implement ContactForm component with validation and submission handling * 💩 feat: enhance ContactForm action to return payload data and improve success message handling * 🚧 feat: update ContactForm and resolver to submit webform response * 🚧 feat: removing card on contact form and working styles * 🚧 feat: remove Card component from DemoForm and update layout structure * 🚧 feat: copy components, storybook in react-router starter * 🎨 feat: update ContactForm to handle confirmation messages
1 parent fe372c4 commit 4309ab5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+3057
-87
lines changed

scripts/components-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export const componentsConfig = {
2525
path: "components/tokens",
2626
type: "components",
2727
},
28+
{
29+
name: "form",
30+
path: "components/form",
31+
type: "components",
32+
},
2833
{
2934
name: "static",
3035
path: "static",

scripts/copy-components.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const STARTERS = {
77
STORYBOOK: "starters/storybook/app",
88
REMIX: "starters/remix/app",
99
NEXT: "starters/next",
10-
REACT_ROUTER: "starters/react-router",
10+
REACT_ROUTER: "starters/react-router/app",
1111
} as const;
1212

1313
const NEXT_CLIENT_COMPONENTS = ["Header"];
@@ -195,6 +195,9 @@ class ComponentSync {
195195
console.log("\n=== Copying components for Remix ===");
196196
sync.copyDesignSystem(STARTERS.STORYBOOK, STARTERS.REMIX);
197197

198+
console.log("\n=== Copying components for React Router ===");
199+
sync.copyDesignSystem(STARTERS.STORYBOOK, STARTERS.REACT_ROUTER);
200+
198201
console.log("\n=== Copying components for Next.js ===");
199202
sync.copyDesignSystem(STARTERS.STORYBOOK, STARTERS.NEXT);
200203

starters/next/.storybook/preview.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ import '../app/globals.css'
44

55
const preview: Preview = {
66
parameters: {
7+
options: {
8+
storySort: {
9+
order: [
10+
'Tokens',
11+
'Primitives',
12+
'Blocks',
13+
'Pages',
14+
'Form',
15+
['*', 'Form - Demo'],
16+
],
17+
},
18+
},
719
controls: {
820
matchers: {
921
color: /(background|color)$/i,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Meta } from '@storybook/blocks';
2+
3+
<Meta title="Form/Docs" />
4+
5+
# Forms
6+
7+
## Overview
8+
9+
Our form components provide a robust foundation for building accessible, validated forms across your application. These components are designed to work seamlessly with modern form validation libraries and follow best practices for user experience.
10+
11+
## Recommended Implementation
12+
13+
### Libraries
14+
15+
We recommend using the following libraries for form implementation:
16+
17+
- **[Zod](https://zod.dev/)**: A TypeScript-first schema validation library that enables you to define the shape and validation rules for your form data.
18+
- **[Conform](https://conform.guide/)**: A form validation library designed to work with native HTML form elements, providing a great developer experience without sacrificing user experience.
19+
20+
### Components
21+
22+
The form components in this library are wrapper components built on top of [shadcn/ui](https://ui.shadcn.com/) components, specifically designed to work with Conform. These include:
23+
24+
- `Input`: A text input component with validation support
25+
- `Textarea`: A multi-line text input component with validation support
26+
- Additional form components like checkboxes, radio buttons, and select inputs
27+
28+
Each component is pre-configured to work with Conform's field metadata, making it simple to implement form validation.
29+
30+
## Client-Side vs. Server-Side Validation
31+
32+
The form stories in Storybook demonstrate client-side validation implementations. However, in production applications, **server-side validation is essential** for security and data integrity.
33+
34+
### Full Stack Components
35+
36+
We recommend implementing forms as full stack components, following the approach described in [Epic Web's Full Stack Components](https://www.epicweb.dev/full-stack-components). This approach ensures:
37+
38+
1. Consistent validation logic between client and server
39+
2. Progressive enhancement for users without JavaScript
40+
3. Better security by not relying solely on client-side validation
41+
42+
## Framework-Specific Implementation
43+
44+
Forms should be implemented according to the recommendations of each framework:
45+
46+
## Best Practices
47+
48+
1. **Progressive Enhancement**: Ensure forms work without JavaScript
49+
2. **Accessibility**: Use proper labels, error messages, and ARIA attributes
50+
3. **Validation Feedback**: Provide clear, immediate feedback for validation errors
51+
4. **Security**: Always validate data on the server, regardless of client-side validation
52+
5. **Type Safety**: Leverage TypeScript and Zod to ensure type safety throughout your form handling
53+
54+
## Example Implementation
55+
56+
See the Form.stories.tsx file for a complete example of a contact form implementation using Zod and Conform.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/* eslint-disable react-hooks/rules-of-hooks */
2+
import { useForm } from '@conform-to/react'
3+
import { parseWithZod } from '@conform-to/zod'
4+
import type { Meta, StoryObj } from '@storybook/react'
5+
import { CheckCircle2 } from 'lucide-react'
6+
import { useState } from 'react'
7+
import { z } from 'zod'
8+
import { Input, Textarea } from '@/components/form'
9+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
10+
import { Button } from '@/components/ui/button'
11+
import { Label } from '@/components/ui/label'
12+
13+
// Define the DemoForm component
14+
const DemoForm = () => {
15+
// This is the main component that will be used as the default story
16+
const [submitted, setSubmitted] = useState(false)
17+
const [formValues, setFormValues] = useState<ContactFormValues | null>(null)
18+
19+
const [form, fields] = useForm({
20+
id: 'contact-form',
21+
onValidate({ formData }) {
22+
return parseWithZod(formData, { schema: contactFormSchema })
23+
},
24+
shouldValidate: 'onBlur',
25+
onSubmit(event, { submission }) {
26+
if (submission?.status === 'success') {
27+
setFormValues(submission.value)
28+
setSubmitted(true)
29+
}
30+
},
31+
})
32+
33+
const handleReset = () => {
34+
setSubmitted(false)
35+
setFormValues(null)
36+
}
37+
38+
return (
39+
<div className="flex items-center justify-center">
40+
<div className="container mx-auto max-w-xl">
41+
{submitted ? (
42+
<div className="space-y-4">
43+
<Alert>
44+
<CheckCircle2 className="text-green-500" />
45+
<AlertTitle>Form submitted successfully!</AlertTitle>
46+
<AlertDescription>
47+
Thank you for your message. We&apos;ll get back to you soon.
48+
</AlertDescription>
49+
</Alert>
50+
51+
{formValues && (
52+
<div className="rounded-md border p-4">
53+
<h4 className="mb-2 font-medium">Submitted Values:</h4>
54+
<pre className="text-xs whitespace-pre-wrap">
55+
{JSON.stringify(formValues, null, 2)}
56+
</pre>
57+
</div>
58+
)}
59+
60+
<Button onClick={handleReset} variant="outline" className="w-full">
61+
Reset Form
62+
</Button>
63+
</div>
64+
) : (
65+
<form
66+
id={form.id}
67+
onSubmit={form.onSubmit}
68+
className="space-y-4"
69+
noValidate
70+
>
71+
<div className="space-y-2">
72+
<Label htmlFor={fields.name.id}>Name</Label>
73+
<Input
74+
meta={fields.name}
75+
type="text"
76+
placeholder="Enter your name"
77+
/>
78+
{fields.name.errors && (
79+
<p className="text-sm text-red-500">{fields.name.errors}</p>
80+
)}
81+
</div>
82+
83+
<div className="space-y-2">
84+
<Label htmlFor={fields.email.id}>Email</Label>
85+
<Input
86+
meta={fields.email}
87+
type="email"
88+
placeholder="Enter your email"
89+
/>
90+
{fields.email.errors && (
91+
<p className="text-sm text-red-500">{fields.email.errors}</p>
92+
)}
93+
</div>
94+
95+
<div className="space-y-2">
96+
<Label htmlFor={fields.message.id}>Message</Label>
97+
<Textarea
98+
meta={fields.message}
99+
placeholder="Enter your message"
100+
className="min-h-[120px]"
101+
/>
102+
{fields.message.errors ? (
103+
<p className="text-sm text-red-500">{fields.message.errors}</p>
104+
) : (
105+
<p className="text-xs text-gray-500">
106+
{fields.message.value?.length || 0}/20-500 characters
107+
</p>
108+
)}
109+
</div>
110+
<Button type="submit" className="w-full">
111+
Submit
112+
</Button>
113+
</form>
114+
)}
115+
</div>
116+
</div>
117+
)
118+
}
119+
120+
const meta = {
121+
title: 'Form/Form - Demo',
122+
} satisfies Meta<typeof DemoForm>
123+
124+
export default meta
125+
126+
type Story = StoryObj<typeof DemoForm>
127+
128+
const contactFormSchema = z.object({
129+
name: z
130+
.string()
131+
.min(2, 'Name must be at least 2 characters')
132+
.max(50, 'Name cannot exceed 50 characters'),
133+
email: z
134+
.string()
135+
.email('Please enter a valid email address')
136+
.min(1, 'Email is required'),
137+
message: z
138+
.string()
139+
.min(20, 'Message must be at least 20 characters')
140+
.max(500, 'Message cannot exceed 500 characters'),
141+
})
142+
143+
type ContactFormValues = z.infer<typeof contactFormSchema>
144+
145+
// Define DemoForm as the default story
146+
export const Default: Story = {
147+
render: () => <DemoForm />,
148+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { Meta, StoryObj } from '@storybook/react'
2+
import { Input } from './Input'
3+
import { Label } from '@/components/ui/label'
4+
import type { FieldMetadata } from '@conform-to/react'
5+
6+
// Create a mock field metadata that conforms to the expected shape
7+
const createMockFieldMeta = (
8+
name: string,
9+
value = '',
10+
error?: string
11+
): FieldMetadata<string> => {
12+
// Cast to unknown first to avoid TypeScript errors
13+
return {
14+
id: name,
15+
name,
16+
value,
17+
initialValue: value,
18+
errors: error ? [error] : undefined,
19+
key: name,
20+
descriptionId: `${name}-description`,
21+
errorId: `${name}-error`,
22+
// The actual FieldMetadata expects allErrors to be Record<string, string[]>
23+
allErrors: error ? { [name]: [error] } : {},
24+
valid: !error,
25+
dirty: false,
26+
} as unknown as FieldMetadata<string>
27+
}
28+
29+
const meta: Meta<typeof Input> = {
30+
title: 'Form/Input',
31+
component: Input,
32+
tags: ['autodocs'],
33+
decorators: [
34+
(Story) => (
35+
<div className="w-full max-w-sm space-y-4 p-4">
36+
<Story />
37+
</div>
38+
),
39+
],
40+
}
41+
42+
export default meta
43+
44+
type Story = StoryObj<typeof Input>
45+
46+
export const Default: Story = {
47+
args: {
48+
meta: createMockFieldMeta('name'),
49+
type: 'text',
50+
placeholder: 'Enter your name',
51+
},
52+
decorators: [
53+
(Story) => (
54+
<div className="space-y-2">
55+
<Label htmlFor="name">Name</Label>
56+
<Story />
57+
</div>
58+
),
59+
],
60+
}
61+
62+
export const WithValidation: Story = {
63+
args: {
64+
meta: createMockFieldMeta('email'),
65+
type: 'email',
66+
placeholder: 'Enter your email',
67+
},
68+
decorators: [
69+
(Story) => (
70+
<div className="space-y-2">
71+
<Label htmlFor="email">Email</Label>
72+
<Story />
73+
</div>
74+
),
75+
],
76+
}
77+
78+
export const WithError: Story = {
79+
args: {
80+
meta: createMockFieldMeta('phone', '', 'Invalid phone number'),
81+
type: 'tel',
82+
placeholder: 'Enter your phone number',
83+
},
84+
decorators: [
85+
(Story) => (
86+
<div className="space-y-4">
87+
<div className="space-y-2">
88+
<Label htmlFor="phone">Phone</Label>
89+
<Story />
90+
<p className="text-sm text-red-500">Invalid phone number</p>
91+
</div>
92+
</div>
93+
),
94+
],
95+
}
96+
97+
export const Disabled: Story = {
98+
args: {
99+
meta: createMockFieldMeta('username'),
100+
type: 'text',
101+
placeholder: 'Username',
102+
disabled: true,
103+
},
104+
decorators: [
105+
(Story) => (
106+
<div className="space-y-2">
107+
<Label htmlFor="username">Username</Label>
108+
<Story />
109+
</div>
110+
),
111+
],
112+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { type FieldMetadata, getInputProps } from '@conform-to/react'
2+
import type { ComponentProps } from 'react'
3+
import { Input as InputShadcn } from '@/components/ui/input'
4+
5+
export const Input = ({
6+
meta,
7+
type,
8+
id,
9+
key,
10+
...props
11+
}: {
12+
meta: FieldMetadata<string>
13+
type: Parameters<typeof getInputProps>[1]['type']
14+
} & ComponentProps<typeof InputShadcn>) => {
15+
return (
16+
<InputShadcn
17+
{...getInputProps(meta, { type, ariaAttributes: true })}
18+
{...props}
19+
id={id}
20+
key={key}
21+
/>
22+
)
23+
}

0 commit comments

Comments
 (0)