🚀 Compound Component Pattern과 내장 검증, TypeScript 지원을 갖춘 강력한 React 폼 라이브러리
- 🎯 Compound Component Pattern - 깔끔하고 조합 가능한 API
- 🔄 React Hook Form 통합 - 성능 최적화
- 🛡️ Zod 스키마 지원 - 타입 안전 검증
- ♿ 접근성 우선 - ARIA 준수
- 🎨 켜고 끄는 기본 테마 - 스타일시트를 불러오면 바로 쓸 만한 모양, 안 불러오면 순수 HTML
- 📝 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'이 줄을 넣지 않으면 컴포넌트는 아무 스타일도 얹지 않은 순수 HTML로 그려집니다. 처음부터 직접 그리고 싶으면 불러오지 않으면 됩니다. 스타일시트는 Tailwind를 요구하지 않으므로 어떤 앱에서도 동작합니다.
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.Description - 입력 예시·형식 안내
- 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 | ❌ | 기본 폼 값 |
| locale | 'ko' | 'en' |
❌ | 자동 노출 문구(Select placeholder·비밀번호 aria-label)의 기본 언어. 기본값 'en' |
| messages | Partial<FormMessages> |
❌ | 개별 문구만 덮어쓰기. locale 기본값 위에 얕게 병합됨 |
// 폼 전체의 기본 문구를 한국어로
<FormKit.Root formId="signup" locale="ko" onSubmit={onSubmit}>
{/* Select 기본 placeholder가 '옵션을 선택하세요'로 나온다 */}
<FormKit.Select name="genre" options={options} />
</FormKit.Root>
// 특정 문구만 커스텀
<FormKit.Root
formId="signup"
locale="ko"
messages={{ selectPlaceholder: '장르를 골라주세요' }}
onSubmit={onSubmit}
/>자동 레이블-입력 연결이 있는 폼 입력용 컨테이너입니다.
| 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 | ❌ | 플레이스홀더 텍스트. 넘기지 않으면 FormKit.Root의 locale에 맞는 기본값 사용 |
| 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 | ✅ | 레이블 텍스트 |
입력 예시나 형식을 안내하는 설명입니다. 같은 Field 안의 입력이 aria-describedby로 이 설명을 가리키므로, 스크린리더는 칸에 들어갔을 때 설명을 함께 읽습니다. 오류가 함께 있으면 설명과 오류를 모두 가리킵니다.
설명은 칸 이름에는 섞이지 않습니다. Field가 label이라 그냥 두면 이름이 "예상 인원 수업에 참여하실 어르신 수"처럼 길어져, 칸 목록을 훑을 때 방해가 되기 때문입니다.
<FormKit.Field>
<FormKit.Legend required>예상 인원</FormKit.Legend>
<FormKit.Description>수업에 참여하실 어르신 수</FormKit.Description>
<FormKit.Input name="headcount" type="number" required />
</FormKit.Field>| 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의 오류 객체 |
기본 테마는 호스트 앱의 CSS 변수를 먼저 읽고, 없을 때만 자체 기본값으로 떨어집니다. :root에
아래 변수 중 있는 것만 정의하면 그대로 반영됩니다. 값은 완전한 색상값(oklch(...)·hsl(...)·
#hex)으로 씁니다.
:root {
--primary: oklch(0.53 0.15 215); /* 강조색: 기본 버튼·초점 테두리·필수 표시 */
--primary-foreground: oklch(0.99 0 0); /* 강조색 위 글자 */
--background: oklch(1 0 0); /* 초점이 온 입력칸 배경 */
--foreground: oklch(0.24 0.015 270); /* 본문 글자 */
--muted-foreground: oklch(0.52 0.012 270); /* 안내문·플레이스홀더 */
--input: oklch(0.9 0.006 270); /* 테두리 */
--destructive: oklch(0.55 0.2 25); /* 오류 */
}곡률·컨트롤 높이는 --fk- 변수로 바꿉니다.
:root {
--fk-radius: 0.625rem;
--fk-control-height: 2.75rem;
}컴포넌트는 className을 그대로 흘려보내고, 기본 모양은 @layer formkit 안에서만 그립니다.
레이어에 든 규칙은 레이어 밖 규칙과 프레임워크 유틸리티에 항상 지므로, 넘긴 클래스는 이름이
무엇이든 특이성이 얼마든 기본값을 이깁니다.
<FormKit.Input name="email" className="rounded-none border-2 border-black" />Tailwind를 쓰지 않는 앱이면 평범한 CSS 클래스를 넘겨도 같습니다.
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]