@olundot/ui · Form
FormField
레이블, 컨트롤, 도움말 텍스트, 카운터, 유효성 검사 메시지를 묶는 컴포지션 래퍼입니다.
- 역할
- label · description · error · 자식 control 묶기
- API
- render-prop ({controlProps}) → id/aria-* 자동 전파
- 접근성 기준
- aria-describedby 머지 + aria-invalid는 자식에
기본
interactive (wrapper 단순 · Input 입력 라이브)공식 기관명을 입력합니다.
코드 보기tsx
import { FormField, Input } from "@olundot/ui";
export function OrganizationField() {
return (
<FormField
id="organization"
label="소속 기관"
description="공식 기관명을 입력합니다."
>
{({ controlProps }) => (
<Input {...controlProps} name="organization" placeholder="전북대학교 의과대학" />
)}
</FormField>
);
}라이브 검증 form
required(*) · submit 검증 · error 시 description auto-hide (FormField 내부 처리)코드 보기tsx
import { useState, type FormEvent } from "react";
import { Button, FormField, Input, Select } from "@olundot/ui";
// 응시 등록 form — submit 시 검증. FormField가 error 시 description 자동 hide.
// a11y 표준 (WCAG 3.3.1): description은 항상 전달, error 발생 시 FormField 내부가
// description을 숨기고 error만 노출. `*` 표시는 required prop 시에만.
// Select: `clearable` prop이 내부 sentinel을 자동 처리한다 — value=""
// 표준 모델 + onValueChange는 단순 setter로 충분.
const SELECT_OPTIONS = [
{ value: "answer-key", label: "정답 키" },
{ value: "score", label: "점수" },
{ value: "metadata", label: "메타데이터" },
];
export function ExamRegistrationForm() {
const [studentNumber, setStudentNumber] = useState("");
const [email, setEmail] = useState("");
const [correctionType, setCorrectionType] = useState("");
const [errors, setErrors] = useState<{ studentNumber?: string; email?: string; correctionType?: string }>({});
const [submitted, setSubmitted] = useState(false);
const clearError = (key: keyof typeof errors) => {
if (errors[key]) setErrors({ ...errors, [key]: undefined });
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const next: typeof errors = {};
if (!/^\d{8}$/.test(studentNumber)) next.studentNumber = "8자리 숫자(예: 20260001).";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) next.email = "올바른 이메일 형식이 아닙니다.";
if (!correctionType) next.correctionType = "정정 유형을 선택합니다.";
setErrors(next);
if (Object.keys(next).length === 0) setSubmitted(true);
};
if (submitted) {
return (
<div style={{ display: "grid", gap: "8px" }}>
<p>등록이 완료되었습니다.</p>
<Button type="button" variant="ghost" onClick={() => setSubmitted(false)}>다시 작성</Button>
</div>
);
}
return (
<form onSubmit={handleSubmit} style={{ display: "grid", gap: "16px" }} noValidate>
<FormField
id="reg-sn"
label="학번"
required
description="시험 카드의 학번 8자리"
error={errors.studentNumber}
>
{({ controlProps }) => (
<Input {...controlProps} name="studentNumber" placeholder="20260001" maxLength={8}
value={studentNumber}
onChange={(e) => { setStudentNumber(e.target.value); clearError("studentNumber"); }} />
)}
</FormField>
<FormField
id="reg-email"
label="이메일"
required
description="계정 복구에 사용됩니다."
error={errors.email}
>
{({ controlProps }) => (
<Input {...controlProps} name="email" type="email" placeholder="you@example.com"
value={email}
onChange={(e) => { setEmail(e.target.value); clearError("email"); }} />
)}
</FormField>
<FormField
id="reg-type"
label="정정 유형"
required
description="가장 가까운 유형 1개"
error={errors.correctionType}
>
{({ controlProps }) => (
<Select {...controlProps} name="correctionType" placeholder="선택"
options={SELECT_OPTIONS}
clearable
value={correctionType}
onValueChange={(v) => { setCorrectionType(v); clearError("correctionType"); }} />
)}
</FormField>
<Button type="submit">등록</Button>
</form>
);
}