역할
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 내부 처리)

시험 카드의 학번 8자리

계정 복구에 사용됩니다.

가장 가까운 유형 1개

코드 보기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>
  );
}