Form
제출하기 전에 안의 컨트롤을 검사하고, 각 문제를 페이지의 언어로 보여 주는 네이티브 <form>입니다. 컨트롤에는 네이티브 규칙(required, type="email", minLength, min, pattern)을 두세요. 브라우저가 내린 판정은 Todae가 읽고, 나머지를 맡습니다. 언제 보여 줄지, 문구, aria-invalid, aria-describedby, 포커스가 갈 곳입니다.
이 페이지의 내용
Form.Root 안의 각 Field.Group이나 Fieldset.Root는 검증 범위(validation scope)입니다. 그 안에서 id 없이 쓴 ErrorMessage.Root가 범위의 첫 문제를 보여 주고 컨트롤을 거기에 연결합니다.
const nameId = useId();
const emailId = useId();
<Form.Root errors={errors} onSubmit={onSubmit}>
<Form.Summary />
<Field.Group>
<Label.Root htmlFor={nameId}>Name</Label.Root>
<Field.Root id={nameId} name="name">
<Field.TextInput required minLength={2} value={name} onValueChange={setName} />
</Field.Root>
<ErrorMessage.Root />
</Field.Group>
<Field.Group>
<Label.Root htmlFor={emailId}>Work email</Label.Root>
<Field.Root id={emailId} name="email">
<Field.TextInput type="email" required value={email} onValueChange={setEmail} />
</Field.Root>
<ErrorMessage.Root />
</Field.Group>
<Button.Root type="submit">Create account</Button.Root>
</Form.Root>
아래 빈 폼을 제출해 보거나, 가짜 서버가 이미 아는 주소인 ben@example.com을 입력해 보세요.
문제가 표시되는 때
범위는 안에서 값이 바뀐 뒤 포커스가 범위를 벗어나거나, 제출을 시도하면(유효하든 아니든) 문제를 보여 줍니다. 사용자가 필드를 만지기 전에는 아무것도 보이지 않습니다. 한 번 표시되면 문제는 양방향으로 실시간입니다. 값이 유효해지면 사라지고, 다시 유효하지 않으면 돌아옵니다. 리셋하면 모든 범위가 다시 숨겨집니다. :user-invalid와 같은 타이밍입니다.
타이밍은 하나뿐이고 mode prop은 없습니다. 폼 안의 일반 <input>처럼 범위 밖에 있는 컨트롤도 제출을 막고 포커스를 받지만, 메시지는 보여 주지 않습니다.
문제의 문구
문구는 StringsProvider에서 오며, 컨트롤의 ValidityState에서 처음 실패한 플래그로 고릅니다. valueMissing, typeMismatch, badInput, patternMismatch, tooShort, tooLong, rangeUnderflow, rangeOverflow, stepMismatch, customError입니다. 범위 값은 컨트롤 자신의 속성에서 읽으므로 minLength={2}는 "Use at least 2 characters"라고 말합니다. 브라우저 자체의 validationMessage는 브라우저의 언어로 나오므로, Todae는 customError에만 그것을 씁니다. 이는 직접 쓴 텍스트이고 번역되지 않습니다.
한 필드의 문구만 다르게 하려면 ErrorMessage.Root에 함수를 주세요. 함수는 오류의 kind와 message를 받습니다.
<ErrorMessage.Root>
{(error) => (error.kind === 'valueMissing' ? 'Add at least one tag.' : error.message)}
</ErrorMessage.Root>
제출
Form.Root는 noValidate를 설정하므로 브라우저는 말풍선을 그리지 않고, onSubmit보다 먼저 자체 검사를 합니다. 유효하지 않은 컨트롤이 있는 제출은 preventDefault()를 호출하고, onSubmit을 건너뛰며, 포커스를 옮깁니다. onSubmit은 모든 컨트롤이 유효할 때 실행됩니다. "임시 저장"처럼 formNoValidate가 있는 버튼의 제출은 네이티브처럼 검사를 건너뜁니다. 이는 시도가 아니라 서버로 보내는 일입니다. 사용자가 아직 닿지 않은 것은 드러내지 않고 요약의 목록도 그대로 두지만, 숨겨진 서버 오류를 다시 표시하고 유효한 제출처럼 응답을 기다립니다. requestSubmit()도 같은 검사를 거칩니다. onReset은 onSubmit이 제출만 듣듯 이 폼의 리셋만 듣습니다. 안에 포털로 넣은 폼은 그 폼 자신의 것입니다.
포커스는 Form.Summary가 보이면 그곳으로, 아니면 페이지에서 처음 나타나는 문제로 가며 화면 안으로 스크롤됩니다. 범위 안에서는 범위가 보여 주는 컨트롤이므로, 그룹의 뒤쪽 컨트롤에 서버 오류가 있으면 앞쪽 컨트롤의 네이티브 문제보다 먼저 포커스를 받습니다. 모든 범위 밖의 컨트롤은 자신의 네이티브 문제로 포커스를 받습니다.
요약
Form.Summary는 마지막 제출에서 실패한 것을 범위마다 링크 하나로, 레이블과 메시지와 함께, id가 없는 컨트롤은 링크 대신 버튼으로, 포커스를 받을 수 없는 비활성화된 컨트롤은 일반 텍스트로 나열합니다. 제출이 실패하면 포커스를 받으므로 스크린 리더가 개수와 목록을 읽고, 링크는 해당 컨트롤로 포커스를 옮깁니다. 고친 컨트롤은 목록에서 빠지며, 제출이 실패하기 전이나 남은 문제가 없을 때는 아무것도 렌더링하지 않습니다. 함수 자식은 목록을 대신합니다.
<Form.Summary title="Check these fields">
{(fields) => (
<ul>
{fields.map((field) => (
<li key={field.label}>
<Button.Root variant="ghost" onClick={field.focus}>
{field.label}
</Button.Root>
{field.error.message}
</li>
))}
</ul>
)}
</Form.Summary>
서버 오류
서버 응답처럼 직접 찾은 오류는 컨트롤의 name을 키로 errors에 넣으세요. Todae는 여기에 절대 쓰지 않습니다. 보낼 때(onSubmit) errors를 비우고, 응답이 오면 채우세요.
const [errors, setErrors] = useState<FormErrors>({});
<Form.Root
errors={errors}
onSubmit={async (event) => {
event.preventDefault();
setErrors({});
setErrors(await createAccount(new FormData(event.currentTarget)));
}}
>
…
</Form.Root>
서버 오류는 즉시 표시되고, 브라우저는 모르는 것을 서버는 알기에 표시되는 내용에서 네이티브 문제보다 우선합니다. 제출을 막지는 않습니다. 컨트롤을 편집하면 다음 제출까지, 또는 errors에서 그 name이 빠지거나 텍스트가 바뀔 때까지 그 항목이 숨겨집니다. 숨긴 항목이 errors에서 사라지면 Todae는 곧바로 잊으므로, errors를 비웠다가 같은 "Taken."을 다시 넣는 확인은 그 텍스트를 다시 표시합니다. 리셋하면 현재 항목이 모두 숨겨집니다. 서버 오류는 비활성화되었거나 읽기 전용인 컨트롤에도 표시되지만, 요약의 해당 행은 누를 것이 없는 일반 텍스트이고 포커스는 그 컨트롤을 건너뜁니다. 범위는 보여 주는 컨트롤 하나로 모든 컨트롤을 대변하므로, 그 컨트롤이 비활성화되어 있으면 같은 범위의 다른 컨트롤로는 포커스가 가지 않습니다.
보낸 뒤의 응답은 바로 앞 커밋과 errors 내용이 다르고 항목이 있는 커밋입니다. 비운 뒤 같은 텍스트를 다시 넣는 것도 비우기가 별도의 커밋이므로 응답입니다. 같은 내용이 새 객체로 다시 만들어지는 것은 아닙니다. 응답은 포커스를 요약이나 서버 오류가 있는 첫 활성 컨트롤로 옮기며, 요약에는 서버 오류만 나열하고 사용자가 아직 닿지 않은 네이티브 문제는 나열하지 않습니다. 포커스는 응답을 가져온 커밋에서 React가 제어를 돌려주기 전에 옮겨지므로, 그 커밋의 레이아웃 이펙트에서 건 마이크로태스크는 포커스가 간 곳을 볼 수 있습니다. 편집, 리셋, 실패한 제출은 기다림을 끝냅니다.
브라우저가 검사할 수 없는 규칙
Field.Root, Fieldset.Root, Checkbox.Root, RadioGroup.Root의 customValidity는 브라우저의 setCustomValidity로 메시지를 설정하므로 제출을 막고 위의 타이밍을 따릅니다. 빈 문자열은 규칙이 지켜졌다는 뜻입니다. 코드가 setCustomValidity로 메시지를 바꾸더라도 규칙이 유지되는 동안은 다음 커밋에서 Todae가 자신의 메시지를 다시 설정하고, 규칙이 사라지면 Todae가 설정한 메시지가 그대로일 때만 지웁니다. Field.Root에서는 필드의 컨트롤에, Fieldset.Root에서는 자기 컨트롤 중 검증에 참여하는 첫 컨트롤에, 컨트롤이 모두 중첩된 그룹 안에 있으면 어느 깊이든 첫 컨트롤에(메시지는 그 컨트롤의 오류가 보이는 곳에 나타납니다), RadioGroup.Root에서는 검증에 참여하는 첫 라디오에 적용됩니다. 한 요소를 함께 쓰는 소유자끼리는 덮어쓰지 않습니다. 요소는 자기 부분에서 시작해 바깥 fieldset 순으로 비어 있지 않은 첫 메시지를 받으므로, 빈 customValidity를 가진 Fieldset.Root는 안쪽 필드의 메시지를 그대로 둡니다. Form.Root 없이도 네이티브 API로 동작하며 같은 컨트롤을 고릅니다.
범위
범위는 자기 컨트롤만 책임집니다. 안에 있는 input, select, textarea 중 검증에 참여하는 것이며, 안에 중첩된 범위의 컨트롤은 제외됩니다. 범위의 문제는 이름이 있는 컨트롤(비활성화되었거나 읽기 전용인 것 포함)의 첫 서버 오류, 없으면 첫 네이티브 문제이며, 각각 페이지 순서를 따릅니다. 범위가 문제를 보여 주면 안의 모든 Todae 컨트롤이 invalid로 표시되고, 컨트롤 자신의 invalid가 여전히 우선합니다. invalid={false}로 컨트롤을 뺄 수 있습니다. invalid가 있는 Fieldset.Root는 범위가 무엇을 보여 주든 invalid로 남습니다.
범위는 ErrorMessage.Root가 렌더링되는 동안 그 id를 컨트롤의 aria-describedby에 더합니다. RadioGroup.Root는 각 라디오가 아니라 그룹에, Fieldset.Root 범위는 <fieldset>에 더하며, 그 안의 컨트롤은 invalid로 표시되지만 같은 설명이 한 번 더 붙지는 않습니다. ErrorMessage.Root가 없는 범위도 컨트롤을 invalid로 표시하지만 id는 더하지 않으므로, 없는 요소를 가리키는 설명이 생기지 않습니다. 범위마다 ErrorMessage.Root는 하나만 쓰세요.
Form.Root 밖에서는 Field.Group과 Fieldset.Root가 이전과 같고, id 없는 ErrorMessage.Root는 개발 중에 오류를 기록합니다. id가 있는 ErrorMessage.Root는 이전처럼 어디서나 동작하며 연결은 직접 합니다.
Field.TextInput, Field.Textarea, Field.Select, Checkbox.Root, Switch.Root, RadioGroup.Root는 네이티브 컨트롤이므로 참여합니다. 피커와 Field.NumberInput, Field.ColorPicker, Field.ChipInput은 버튼이나 텍스트 입력 옆에 네이티브 값 입력을 렌더링하므로, 피커, Field.NumberInput, Field.ChipInput의 required와 피커와 Field.NumberInput의 min, max도 검증에 참여합니다. 값 입력은 숨겨져 있으므로 그 문제는 필드에 나타나고, 포커스는 필드의 컨트롤로 갑니다. 피커의 버튼, 또는 Field.NumberInput이나 Field.ChipInput의 텍스트 입력입니다. Field.NumberInput은 min과 max를 자신의 형식으로 씁니다. 예를 들어 "Enter 1,000 or more"처럼 읽힙니다. 직접 만든 컨트롤은 useField의 getValueInputProps로 값 입력을 렌더링할 수 있습니다.
접근성을 지키는 조합
Todae가 맡는 것: noValidate와 제출 검사. 실패한 폼은 onSubmit에 닿지 않습니다. 브라우저의 ValidityState에서 온, 페이지 언어의 문구. 문제를 보여 주는 범위의 컨트롤에 aria-invalid를, 그리고 ErrorMessage.Root가 렌더링되는 동안 그 id를 aria-describedby에 설정합니다. 제출이 실패하면 Form.Summary나 처음 유효하지 않은 컨트롤로, 요약 링크에서는 해당 컨트롤로 포커스를 옮깁니다.
직접 제공할 것: 컨트롤의 네이티브 규칙, 메시지를 보여 줄 각 Field.Group이나 Fieldset.Root 안의 id 없는 ErrorMessage.Root, 요약이 읽는 모든 컨트롤의 레이블. 서버 오류를 받을 컨트롤마다 name. StringsProvider로 번역한 문자열. customValidity에 쓸 직접 쓴 텍스트.
주의할 점: 레이블이 없는 컨트롤은 요약에서 name으로 읽힙니다. 모든 범위 밖의 컨트롤은 제출을 막지만 아무것도 보여 주지 않습니다. readOnly와 disabled 컨트롤은 검증되지 않지만 서버 오류는 표시되며, Field.TextInput은 onChange나 onValueChange가 없으면 읽기 전용입니다. 범위 안의 어떤 컨트롤도 갖고 있지 않은 name의 서버 오류는 어디에도 표시되지 않습니다. 전송에 대한 응답이 그런 컨트롤을 가리키면 Form.Root가 개발 중에 경고하며, 아직 마운트되지 않은 컨트롤은 한 태스크 뒤에 다시 확인합니다. 확인은 렌더가 아니라 응답 시점에 실행됩니다. 성공적으로 보낸 뒤에도 폼은 응답을 계속 기다리므로, 사용자가 일으키지 않은 errors 변경(예: 폴링)도 응답으로 간주되어 포커스를 옮깁니다. errors는 전송에 대한 응답으로만 설정하세요. 메시지는 나타날 때 낭독되지 않습니다. 포커스를 옮기고 말하는 것은 요약입니다.
언제 쓰나요
Form.Root
언제: 네이티브 규칙이 있는 폼에서 각 필드가 페이지의 언어로 문제를 보여 주고, 포커스와 ARIA가 연결되길 원할 때. 값은 갖지 않고 컨트롤에 남습니다.
쓰지 않을 때: 검사할 규칙이 없는 폼 → 일반 <form>. Enter로 보내는 컴포저 → 자체가 폼인 Composer. 스키마나 서버가 정하는 규칙 → onSubmit에서 실행하고 결과를 errors로 넘기세요.
Form.Summary
언제: 긴 폼, 또는 사용자가 고칠 것을 한 곳에서 보아야 하는 폼. 스크린 리더가 가장 먼저 읽습니다.
쓰지 않을 때: 처음 유효하지 않은 컨트롤로 가는 포커스로 충분한 짧은 폼은 빼세요. 포커스가 그곳으로 갑니다.
Props
| 이름 | 타입 | 설명 |
|---|---|---|
errors | Readonly<Record<string, string>> | 서버 응답처럼 직접 찾은 문제를 컨트롤의 name을 키로 넣습니다. Todae는 여기에 절대 쓰지 않습니다. 보낼 때(onSubmit) 비우고, 응답이 오면 채우세요. 항목은 컨트롤의 네이티브 문제 대신 즉시 표시되고 제출을 막지 않습니다. 컨트롤을 편집하면 다음 제출까지, 또는 errors에서 그 name이 빠지거나 텍스트가 바뀔 때까지 그 항목이 숨겨집니다. 보낸 뒤의 응답은 바로 앞 커밋과 내용이 다르고 항목이 있는 커밋이며(비운 뒤 다시 채우는 것은 해당, 같은 내용을 다시 만드는 것은 해당하지 않음), 포커스를 요약이나 서버 오류가 있는 첫 활성 컨트롤로 옮기고 서버 오류만 나열합니다. |
onSubmit | React.FormEventHandler<HTMLFormElement> | 모든 컨트롤이 유효한 제출, 또는 formNoValidate가 있는 제출 버튼이 보낸 제출(검사 없이 보내는 것이며 새로 드러나는 것은 없음)에서 실행됩니다. Form.Root가 먼저 검사하며, 문제가 있는 제출은 여기에 닿지 않습니다. |
onReset | React.FormEventHandler<HTMLFormElement> | 포털로 넣은 폼이 아니라 이 폼의 리셋에서 실행되며, onSubmit과 같습니다. 리셋하면 표시된 모든 문제가 다시 숨겨집니다. |
children | React.ReactNode | 컨트롤(보통 Field.Group과 Fieldset.Root 안)과 선택적인 Form.Summary. |
| 이름 | 타입 | 설명 |
|---|---|---|
title | React.ReactNode | 제목을 대신합니다. 기본 제목은 문제 개수가 들어간 formErrors 문자열입니다. |
children | (fields: readonly FormInvalidField[]) => React.ReactNode | 링크 목록을 대신합니다. 각 필드는 name, label, error, 비활성 여부와, 컨트롤로 포커스를 옮기는 focus()를 가집니다. |