Skip to content

Commit

Permalink
feat(ui): Form 컴포넌트 구현 (#66)
Browse files Browse the repository at this point in the history
* docs: add changeset

* docs: Form stories 작성

* chore: dependencies 업데이트

* feat: Form 컴포넌트 구현

* docs: changeset 수정
  • Loading branch information
yeeezae authored Dec 12, 2024
1 parent 73cfc72 commit 73e32d8
Show file tree
Hide file tree
Showing 8 changed files with 710 additions and 430 deletions.
5 changes: 5 additions & 0 deletions .changeset/brown-squids-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@freemed-kit/ui': minor
---

Form 컴포넌트 구현
7 changes: 5 additions & 2 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
},
"dependencies": {
"@freemed-kit/ui": "workspace:*",
"@hookform/resolvers": "^3.9.1",
"@storybook/preview-api": "^8.4.3",
"lucide-react": "^0.453.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@freemed-kit/eslint-config": "workspace:*",
Expand Down
65 changes: 65 additions & 0 deletions apps/docs/stories/form.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Button,
} from '@freemed-kit/ui'

const meta: Meta<typeof Form> = {
title: 'Components/Form',
component: Form,
tags: ['autodocs'],
}

export default meta
type Story = StoryObj<typeof Form>

const formSchema = z.object({
username: z.string().min(2, {
message: 'Username must be at least 2 characters.',
}),
})
export const Docs: Story = {
render: function Render(args) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
},
})

function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}
return (
<Form {...form}>
<form className="space-y-8" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
},
}
3 changes: 3 additions & 0 deletions packages/eslint-config/storybook.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,8 @@ module.exports = {
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-shadow': 'off',
'no-console': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-misused-promises': 'off',
},
}
131 changes: 131 additions & 0 deletions packages/ui/components/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
'use client'

import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form'

import { cn } from '@/lib/utils'
import { Label } from '@/components/label'

const Form = FormProvider

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

const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)

const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}

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

const fieldState = getFieldState(fieldContext.name, formState)

if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}

const { id } = itemContext

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

type FormItemContextValue = {
id: string
}

const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)

const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId()

return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
)
}
)
FormItem.displayName = 'FormItem'

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

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

const FormControl = React.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}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
)
}
)
FormControl.displayName = 'FormControl'

const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()

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

const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
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)} {...props}>
{body}
</p>
)
}
)
FormMessage.displayName = 'FormMessage'

export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField }
1 change: 1 addition & 0 deletions packages/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,4 @@ export {
SidebarTrigger,
useSidebar,
} from './components/sidebar'
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from './components/form'
7 changes: 5 additions & 2 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"typescript": "^5.3.3"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
Expand All @@ -55,11 +56,13 @@
"cmdk": "1.0.0",
"date-fns": "^4.1.0",
"lucide-react": "^0.453.0",
"react": "^18.2.0",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-hook-form": "^7.54.0",
"react-resizable-panels": "^2.1.6",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"
},
"publishConfig": {
"access": "public"
Expand Down
Loading

0 comments on commit 73e32d8

Please sign in to comment.