Skip to content

Commit

Permalink
feat(ui): add form component
Browse files Browse the repository at this point in the history
  • Loading branch information
tszhong0411 committed Aug 20, 2024
1 parent e046d64 commit 1b50560
Show file tree
Hide file tree
Showing 9 changed files with 354 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .changeset/great-impalas-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tszhong0411/ui': patch
---

add form component
5 changes: 4 additions & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@tszhong0411/mdx": "workspace:*",
"@tszhong0411/ui": "workspace:*",
"@tszhong0411/utils": "workspace:*",
Expand All @@ -26,10 +27,12 @@
"next-themes": "^0.3.0",
"react": "18.3.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.52.2",
"sharp": "^0.33.2",
"shiki": "^1.6.4",
"unified": "^11.0.4",
"unist-util-visit": "^5.0.0"
"unist-util-visit": "^5.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@next/bundle-analyzer": "^14.2.3",
Expand Down
39 changes: 39 additions & 0 deletions apps/docs/src/app/ui/components/form.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
title: Form
description: Building forms with React Hook Form and Zod.
---

<ComponentPreview name='form/form' />

## Usage

```tsx
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@tszhong0411/ui'
```

```tsx
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name='...'
render={({ field }) => (
<FormItem>
<FormLabel />
<FormControl />
<FormDescription />
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
```
59 changes: 59 additions & 0 deletions apps/docs/src/components/demos/form/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client'

import { zodResolver } from '@hookform/resolvers/zod'
import {
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input
} from '@tszhong0411/ui'
import { useForm } from 'react-hook-form'
import { z } from 'zod'

const formSchema = z.object({
username: z.string().min(2, {
message: 'Username must be at least 2 characters.'
})
})

const FormDemo = () => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: ''
}
})

const onSubmit = (values: z.infer<typeof formSchema>) => {
console.log(values)
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='w-full max-w-md space-y-8'>
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder='username' {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit'>Submit</Button>
</form>
</Form>
)
}

export default FormDemo
4 changes: 4 additions & 0 deletions apps/docs/src/config/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export const SIDEBAR_LINKS: SidebarLinks = [
href: '/ui/components/files',
text: 'Files'
},
{
href: '/ui/components/form',
text: 'Form'
},
{
href: '/ui/components/input',
text: 'Input'
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"cmdk": "^1.0.0",
"lucide-react": "^0.394.0",
"merge-refs": "^1.3.0",
"react-hook-form": "^7.52.2",
"react-textarea-autosize": "^8.5.3",
"sonner": "1.5.0"
},
Expand Down
162 changes: 162 additions & 0 deletions packages/ui/src/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import { cn } from '@tszhong0411/utils'
import { createContext, forwardRef, useContext, useId, useMemo } from 'react'
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext
} from 'react-hook-form'

import { Label } from './label'

export const Form = FormProvider

type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}

type FormItemContextValue = {
id: string
}

const FormFieldContext = createContext<FormFieldContextValue | undefined>(undefined)

const FormItemContext = createContext<FormItemContextValue | undefined>(undefined)

export const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>(
props: ControllerProps<TFieldValues, TName>
) => {
const context = useMemo(() => ({ name: props.name }), [props.name])

return (
<FormFieldContext.Provider value={context}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}

const useFormField = () => {
const fieldContext = useContext(FormFieldContext)
const itemContext = useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()

if (!fieldContext || !itemContext) {
throw new Error('useFormField must be used within a FormField and FormItem')
}

const fieldState = getFieldState(fieldContext.name, formState)

const { id } = itemContext

return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState
}
}

export const FormItem = forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(
(props, ref) => {
const { className, ...rest } = props
const id = useId()

const context = useMemo(() => ({ id }), [id])

return (
<FormItemContext.Provider value={context}>
<div ref={ref} className={cn('space-y-2', className)} {...rest} />
</FormItemContext.Provider>
)
}
)

export const FormLabel = forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>((props, ref) => {
const { className, ...rest } = props
const { error, formItemId } = useFormField()

return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...rest}
/>
)
})

export const FormControl = forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>((props, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()

return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={error ? `${formDescriptionId} ${formMessageId}` : formDescriptionId}
aria-invalid={!!error}
{...props}
/>
)
})

export const FormDescription = forwardRef<
HTMLParagraphElement,
React.ComponentPropsWithoutRef<'p'>
>((props, ref) => {
const { className, ...rest } = props
const { formDescriptionId } = useFormField()

return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...rest}
/>
)
})

export const FormMessage = forwardRef<HTMLParagraphElement, React.ComponentPropsWithoutRef<'p'>>(
(props, ref) => {
const { className, children, ...rest } = props
const { error, formMessageId } = useFormField()
const body = error ? String(error.message) : children

if (!body) return null

return (
<p
ref={ref}
id={formMessageId}
className={cn('text-destructive text-sm font-medium', className)}
{...rest}
>
{body}
</p>
)
}
)

FormItem.displayName = 'FormItem'
FormLabel.displayName = 'FormLabel'
FormControl.displayName = 'FormControl'
FormDescription.displayName = 'FormDescription'
FormMessage.displayName = 'FormMessage'
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './command'
export * from './dialog'
export * from './dropdown-menu'
export * from './files'
export * from './form'
export * from './input'
export * from './label'
export * from './link'
Expand Down
Loading

0 comments on commit 1b50560

Please sign in to comment.