π Compound Component Patternκ³Ό λ΄μ₯ κ²μ¦, TypeScript μ§μμ κ°μΆ κ°λ ₯ν React νΌ λΌμ΄λΈλ¬λ¦¬
- π― Compound Component Pattern - κΉλνκ³ μ‘°ν© κ°λ₯ν API
- π React Hook Form ν΅ν© - μ±λ₯ μ΅μ ν
- π‘οΈ Zod μ€ν€λ§ μ§μ - νμ μμ κ²μ¦
- βΏ μ κ·Όμ± μ°μ - ARIA μ€μ
- π¨ Tailwind CSS μ€νμΌλ§ - κΈ°λ³Έμ μΌλ‘ μλ¦λ€μ΄ λμμΈ
- π TypeScript - μμ ν νμ μμ μ±
- π λΉλ°λ²νΈ ν κΈ - λ΄μ₯ κ°μμ± ν κΈ
- ποΈ Select μ»΄ν¬λνΈ - Radix UI κΈ°λ°
npm install @jiin.seok/formkit-react
# λλ
yarn add @jiin.seok/formkit-react
# λλ
pnpm add @jiin.seok/formkit-reactnpm install react react-dom
react-hook-form,zod,@hookform/resolversλ±μ ν¨ν€μ§ μμ‘΄μ±μΌλ‘ μλ μ€μΉλ©λλ€.
μ± μ§μ
μ (μ: main.tsx, layout.tsx)μμ κΈ°λ³Έ μ€νμΌμ ν λ² λΆλ¬μ΅λλ€.
import '@jiin.seok/formkit-react/styles.css'μμμ CSS λ³μ κΈ°λ°μ΄λΌ :rootμμ λ³μλ§ μ€λ²λΌμ΄λνλ©΄ ν
λ§λ₯Ό λ°κΏ μ μμ΅λλ€.
:root {
--primary: 222.2 47.4% 11.2%; /* hsl κ° (μΌν μμ΄) */
--destructive: 0 84.2% 60.2%;
}import FormKit from '@jiin.seok/formkit-react'
function LoginForm() {
const handleSubmit = (data) => {
console.log('νΌ λ°μ΄ν°:', data)
}
return (
<FormKit.Root formId="login" onSubmit={handleSubmit}>
<FormKit.Title>λ‘κ·ΈμΈ</FormKit.Title>
<FormKit.Field>
<FormKit.Label>μ΄λ©μΌ</FormKit.Label>
<FormKit.Input name="email" type="email" required />
</FormKit.Field>
<FormKit.Field>
<FormKit.Label>λΉλ°λ²νΈ</FormKit.Label>
<FormKit.Input name="password" type="password" required />
</FormKit.Field>
<FormKit.SubmitButton>λ‘κ·ΈμΈ</FormKit.SubmitButton>
</FormKit.Root>
)
}import FormKit from '@jiin.seok/formkit-react'
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email('μ ν¨νμ§ μμ μ΄λ©μΌ μ£Όμμ
λλ€'),
password: z.string().min(8, 'λΉλ°λ²νΈλ μ΅μ 8μ μ΄μμ΄μ΄μΌ ν©λλ€')
})
function LoginForm() {
const handleSubmit = (data) => {
console.log('κ²μ¦λ λ°μ΄ν°:', data)
}
return (
<FormKit.Root
formId="login"
schema={loginSchema}
onSubmit={handleSubmit}
>
<FormKit.Field>
<FormKit.Label>μ΄λ©μΌ</FormKit.Label>
<FormKit.Input name="email" type="email" />
</FormKit.Field>
<FormKit.Field>
<FormKit.Label>λΉλ°λ²νΈ</FormKit.Label>
<FormKit.Input name="password" type="password" />
</FormKit.Field>
<FormKit.SubmitButton>λ‘κ·ΈμΈ</FormKit.SubmitButton>
</FormKit.Root>
)
}import FormKit from '@jiin.seok/formkit-react'
function RegistrationForm() {
const countries = [
{ value: 'kr', label: 'λνλ―Όκ΅' },
{ value: 'us', label: 'λ―Έκ΅' },
{ value: 'jp', label: 'μΌλ³Έ' },
]
return (
<FormKit.Root formId="registration" onSubmit={handleSubmit}>
<FormKit.Fieldset>
<FormKit.Legend required>κ°μΈ μ 보</FormKit.Legend>
<FormKit.Field>
<FormKit.Label>μ΄λ¦</FormKit.Label>
<FormKit.Input name="fullName" required />
</FormKit.Field>
<FormKit.Field>
<FormKit.Label>κ΅κ°</FormKit.Label>
<FormKit.Select
name="country"
options={countries}
placeholder="κ΅κ°λ₯Ό μ ννμΈμ"
required
/>
</FormKit.Field>
<FormKit.Field>
<FormKit.Label>μκΈ°μκ°</FormKit.Label>
<FormKit.Textarea
name="bio"
placeholder="κ°λ¨ν μκΈ°μκ°λ₯Ό μμ±ν΄μ£ΌμΈμ"
maxLength={500}
/>
</FormKit.Field>
</FormKit.Fieldset>
<FormKit.SubmitButton>κ°μ
νκΈ°</FormKit.SubmitButton>
</FormKit.Root>
)
}FormKitμ ν¬κ΄μ μΈ νΌ μ»΄ν¬λνΈ μΈνΈλ₯Ό μ 곡ν©λλ€:
- FormKit.Root - κ²μ¦ 컨ν μ€νΈλ₯Ό ν¬ν¨ν λ©μΈ νΌ μ»¨ν μ΄λ
- FormKit.Field - λ μ΄λΈ-μ λ ₯ μ°κ²°μ΄ μλ νλ λνΌ
- FormKit.Fieldset - κ΄λ ¨λ νλ κ·Έλ£Ήν
- FormKit.Legend - νμ νμκ° μ νμ μΌλ‘ ν¬ν¨λ νλμ μ λͺ©
- FormKit.Input - λΉλ°λ²νΈ ν κΈ, μ΄λ©μΌ, μ«μ λ±μ μ§μνλ ν μ€νΈ μ λ ₯
- FormKit.Textarea - μ¬λ¬ μ€ ν μ€νΈ μ λ ₯
- FormKit.Select - κ²μ κΈ°λ₯μ΄ μλ λλ‘λ€μ΄ μ ν (Radix UI κΈ°λ°)
- FormKit.Label - νλ λ μ΄λΈ
- FormKit.Title - νΌ μ λͺ©
- FormKit.Wrapper - 컀μ€ν λ μ΄μμμ© μ»¨ν μ΄λ
- FormKit.Unit - λ¨μ νμ (μ: "μ", "kg")
- FormKit.Error - μ€λ₯ λ©μμ§ νμ
- FormKit.SubmitButton - λ‘λ© μνκ° μλ μ μΆ λ²νΌ
- FormKit.ResetButton - νΌμ μ΄κΈ°κ°μΌλ‘ μ¬μ€μ
λͺ¨λ μμ μ»΄ν¬λνΈμ 컨ν μ€νΈλ₯Ό μ 곡νλ λ©μΈ νΌ μ»¨ν μ΄λμ λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| formId | string | β | νΌμ κ³ μ μλ³μ |
| onSubmit | (data) => void | β | νΌ μ μΆ νΈλ€λ¬ |
| schema | ZodSchema | β | κ²μ¦μ μν Zod μ€ν€λ§ |
| defaultValues | object | β | κΈ°λ³Έ νΌ κ° |
μλ λ μ΄λΈ-μ λ ₯ μ°κ²°μ΄ μλ νΌ μ λ ₯μ© μ»¨ν μ΄λμ λλ€.
| Prop | Type | κΈ°λ³Έκ° | μ€λͺ |
|---|---|---|---|
| isInline | boolean | false | λ μ΄λΈκ³Ό μ λ ₯μ κ°λ‘λ‘ νμ |
| hidden | boolean | false | νλ μ¨κΈ°κΈ° |
| htmlFor | string | auto | λ μ΄λΈ-μ λ ₯ μ°κ²°μ μν 컀μ€ν ID |
λ΄μ₯ κΈ°λ₯μ΄ μλ ν₯μλ μ λ ₯ μ»΄ν¬λνΈμ λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| name | string | β | νλ μ΄λ¦ |
| type | string | β | μ λ ₯ νμ (text, email, password λ±) |
| required | boolean | β | νλλ₯Ό νμλ‘ νμ |
| minLength | number | β | μ΅μ λ¬Έμ κΈΈμ΄ |
| maxLength | number | β | μ΅λ λ¬Έμ κΈΈμ΄ |
κΈ°λ₯:
- π
type="password"μ λν μλ λΉλ°λ²νΈ κ°μμ± ν κΈ - β
νμΈ νλμ λν μλ κ²μ¦ (μ:
confirmPassword) - π― μμ ν TypeScript μ§μ
Radix UIλ₯Ό μ¬μ©ν λλ‘λ€μ΄ μ ν μ»΄ν¬λνΈμ λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| name | string | β | νλ μ΄λ¦ |
| options | Array<{value, label}> | β | μ ν μ΅μ |
| placeholder | string | β | νλ μ΄μ€νλ ν μ€νΈ |
| required | boolean | β | νλλ₯Ό νμλ‘ νμ |
μ¬λ¬ μ€ ν μ€νΈ μ λ ₯ μ»΄ν¬λνΈμ λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| name | string | β | νλ μ΄λ¦ |
| required | boolean | β | νλλ₯Ό νμλ‘ νμ |
| minLength | number | β | μ΅μ λ¬Έμ κΈΈμ΄ |
| maxLength | number | β | μ΅λ λ¬Έμ κΈΈμ΄ |
| rows | number | β | νμλλ ν μ€νΈ μ€ μ (κΈ°λ³Έκ°: 4) |
κ΄λ ¨λ νΌ νλλ₯Ό ν¨κ» κ·Έλ£Ήνν©λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| className | string | β | 컀μ€ν CSS ν΄λμ€ |
| children | ReactNode | β | μμ μ»΄ν¬λνΈ |
νμ νμκ° μ νμ μΌλ‘ μλ νλμ μ λͺ©μ λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| required | boolean | β | λΉ¨κ°μ λ³ν(*) νμ |
| className | string | β | 컀μ€ν CSS ν΄λμ€ |
| children | ReactNode | β | λ²λ‘ ν μ€νΈ |
νΌ μ λ ₯μ μν μ κ·Ό κ°λ₯ν λ μ΄λΈμ λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| className | string | β | 컀μ€ν CSS ν΄λμ€ |
| children | ReactNode | β | λ μ΄λΈ ν μ€νΈ |
νΌ μ λͺ©/ν€λ μ»΄ν¬λνΈμ λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| className | string | β | 컀μ€ν CSS ν΄λμ€ |
| children | ReactNode | β | μ λͺ© ν μ€νΈ |
λ΄μ₯ λ‘λ© μνκ° μλ μ μΆ λ²νΌμ λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| variant | string | β | λ²νΌ μ€νμΌ λ³ν |
| disabled | boolean | β | λ²νΌ λΉνμ±ν |
| className | string | β | 컀μ€ν CSS ν΄λμ€ |
| children | ReactNode | β | λ²νΌ ν μ€νΈ |
νΌμ μ΄κΈ°κ°μΌλ‘ μ¬μ€μ ν©λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| onClick | function | β | μΆκ° ν΄λ¦ νΈλ€λ¬ |
| className | string | β | 컀μ€ν CSS ν΄λμ€ |
| children | ReactNode | β | λ²νΌ ν μ€νΈ |
컀μ€ν λ μ΄μμμ© μ»¨ν μ΄λ μ»΄ν¬λνΈμ λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| className | string | β | 컀μ€ν CSS ν΄λμ€ |
| children | ReactNode | β | μμ μ»΄ν¬λνΈ |
μ λ ₯ νλ μμ λ¨μλ₯Ό νμν©λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| unit | string | β | λ¨μ ν μ€νΈ (μ: "μ", "kg", "%") |
κ²μ¦ μ€λ₯ λ©μμ§λ₯Ό νμν©λλ€.
| Prop | Type | νμ | μ€λͺ |
|---|---|---|---|
| error | FieldError | β | react-hook-formμ μ€λ₯ κ°μ²΄ |
FormKitμ ν λ§λ₯Ό μν΄ CSS λ³μμ ν¨κ» Tailwind CSSλ₯Ό μ¬μ©ν©λλ€. CSSμ λ€μ λ³μλ₯Ό μΆκ°νμΈμ:
:root {
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
}
.dark {
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... λ€λ₯Έ λ€ν¬ λͺ¨λ λ³μλ€ */
}import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import FormKit from '@jiin.seok/formkit-react'
test('νΌ λ°μ΄ν° μ μΆ', async () => {
const handleSubmit = jest.fn()
render(
<FormKit.Root formId="test" onSubmit={handleSubmit}>
<FormKit.Field>
<FormKit.Input name="username" />
</FormKit.Field>
<FormKit.SubmitButton>μ μΆ</FormKit.SubmitButton>
</FormKit.Root>
)
await userEvent.type(screen.getByRole('textbox'), 'john')
await userEvent.click(screen.getByRole('button'))
expect(handleSubmit).toHaveBeenCalledWith({ username: 'john' })
})MIT Β© [Jiin Seok]