폼 조합
폼은 Field 를 쌓아 만듭니다. 라벨은 칸 위, 필수는 *, 힌트와 오류 문장은 칸 아래에 aria-describedby 로 잇습니다. 칸을 떠날 때 검사하고, 제출하면 위에 오류 요약을 띄우고 첫 오류 칸으로 포커스를 옮깁니다.
- import
import { FieldGroup, FormErrorSummary, SaveStatus, SubmitBar } from "@uyoolabs/ui/form";- 쓰는 경우
- 칸 하나는 Field, 컨트롤 여러 개의 묶음은 FieldGroup, 저장 버튼이 있는 설정 · 편집 폼의 아래는 SubmitBar, 제출 뒤 고칠 칸 목록은 FormErrorSummary, 즉시 저장 · 자동 저장의 결과는 SaveStatus 입니다.
- 쓰지 않는 경우
- 다 채울 때까지 저장 버튼을 비활성으로 두지 않습니다(무엇이 빠졌는지 모릅니다). 스위치와 저장 버튼을 한 카드에 두지 않습니다. 빨간 상자 하나에 “입력값을 확인하세요” 만 쓰지 않고, 요약 항목은 칸 이름으로 시작하는 링크로 둡니다. 폼 위에 h1 을 두지 않습니다(이름은 브레드크럼, 섹션은 카드 + h2). 저장 중에 폼 전체를 흐리게 하지 않습니다.
- 검증 시점
- 입력하는 동안에는 검사하지 않고 글자 수만 셉니다. 칸을 떠날 때 형식 · 길이를 검사하고, 고치는 동안에는 맞는 값이 되는 즉시 오류를 지웁니다. 제출할 때 모두 검사해 요약 · 첫 오류 칸 포커스 · 저장 바의 “n곳”으로 알립니다. 서버 실패는 입력값을 지우지 않고 바에 문장 + 다시 저장을 둡니다.
- 폐기 예고
- FormRow 는 Field layout="start", FormField 는 SubmitBar 로 바뀝니다. v2.x 에서 폐기를 예고합니다.
설정 폼
주소를 지우고 저장해 보세요. 요약이 뜨고 첫 오류 칸으로 포커스가 갑니다.
Field — 글자 수 · 라벨 왼쪽
Field 에 count · layout 이 더해졌습니다(결정 #14).
멤버에게 보이는 이름입니다.
3 / 40채널 · 멘션에 보이는 이름입니다.
FieldGroup
fieldset · legend 묶음입니다. 묶음 오류 문장은 묶음 아래에 한 번만 둡니다.
SubmitBar · SaveStatus
저장 바는 폼 열 아래에 붙어 뜹니다(sticky, z 10). 문서에서는 sticky 를 껐습니다.
저장했습니다 ·
키보드
| 키 | 동작 |
|---|---|
| Tab | 읽는 순서(왼쪽 → 오른쪽, 위 → 아래)대로 칸을 옮깁니다 |
| Enter | 한 줄 입력에서 제출합니다(저장 바의 저장 버튼). 한 · 일 IME 조합 중의 Enter 는 제출하지 않습니다(isComposing). 저장 중에는 막힙니다 |
| Enter (요약 링크) | 그 칸으로 포커스를 옮기고 화면 가운데로 스크롤합니다 |
| Esc | 대화상자 안의 폼이면 취소입니다. 변경이 있으면 떠날 때 경고합니다(Dialog) |
내장 문구
lang(ko · en · ja)으로 고르고 labels 로 일부를 바꿉니다. 개수가 들어가는 문장은 함수입니다.
| 컴포넌트 · 키 | ko | en | ja |
|---|---|---|---|
| SubmitBar.dirty | 저장하지 않은 변경 사항이 있습니다 | You have unsaved changes | 保存していない変更があります |
| SubmitBar.invalid(n) | n곳을 고쳐야 저장할 수 있습니다 | Fix n fields to save | n か所を修正すると保存できます |
| SubmitBar.save · retry · cancel | 저장 · 다시 저장 · 취소 | Save changes · Save again · Cancel | 保存 · もう一度保存 · キャンセル |
| FormErrorSummary.title(n) | 저장하지 못했습니다. n곳을 고칩니다. | Couldn’t save. Fix n fields. | 保存できませんでした。n か所を修正してください。 |
| SaveStatus.saving · saved · error · retry | 저장 중 · 저장했습니다 · 저장하지 못했습니다 · 다시 시도 | Saving… · Saved · Couldn’t save · Try again | 保存中… · 保存しました · 保存できませんでした · 再試行 |
props
소스의 타입과 JSDoc 에서 생성한 표입니다. 컴포넌트를 고치면 pnpm gen 으로 다시 만듭니다.
Field
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| label필수 | ReactNode | — | |
| hint | ReactNode | — | 무엇을 넣는지 · 어디에 쓰이는지. 컨트롤 아래에 두고 aria-describedby 로 연결한다 |
| error | ReactNode | — | 무엇이 문제인지 + 어떻게 고치는지 한 문장(스펙 보드 C-Input). 있으면 aria-invalid 를 단다 |
| required | boolean | — | 라벨 옆에 * 를 보이고 aria-required 를 단다. 네이티브 required 는 브라우저 말풍선이 오류 요약과 겹쳐 쓰지 않는다 |
| announce | boolean | — | 오류가 생기는 즉시 읽어 준다(role="alert"). 기본값은 끈다. 스펙 보드는 칸을 떠날 때 검사하므로, alert 를 켜 두면 Tab 으로 다음 칸에 들어가는 순간 그 칸의 라벨 대신 앞 칸의 오류를 끼어들어 읽는다. 오류 안내는 제출할 때 오류 요약이 한 번에 맡는다. 제출 없이 서버 확인(주소 중복 등)만으로 오류가 생기는 칸에만 켠다. |
| count | { value: number; max: number } | — | 글자 수. 힌트 오른쪽에 “3 / 40” 을 보이고 aria-describedby 로 잇는다. 넘으면 숫자만 오류 색 500 이 된다. 입력하는 동안 검사하지 않는다(C-Form 검증 시점). 넘은 것은 칸을 떠날 때 error 로 알린다 |
| layout | "top" | "start" | "top" | 라벨 자리. top(기본)은 칸 위, start 는 왼쪽 144px 열(칸이 많은 설정 화면, FormRow 대신). start 는 폼 폭 480 미만(uyoo-form 컨테이너, 없으면 화면 폭)에서 위로 접힌다 |
| id | string | — | 컨트롤 id. 주지 않으면 useId 로 만든다 |
| className | string | — | |
| children필수 | (control: FieldControlProps) => ReactNode | — | 컨트롤을 그리는 함수. cloneElement 대신 render-prop 을 고른 이유: - 검색 · 단위 · 복사 버튼이 붙은 입력처럼 컨트롤이 감싸져 있어도 props 가 정확히 그 입력에 간다. cloneElement 는 바깥 div 에 id 를 붙인다. - 받는 props 가 타입으로 드러나고, 어떤 컨트롤(Input · NativeSelect · textarea)이든 같은 방식이다. |
FieldGroup
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| legend필수 | ReactNode | — | 묶음 이름(“새 멤버의 기본 역할”). legend 로 그린다 |
| hint | ReactNode | — | 묶음 아래 도움말. aria-describedby 로 잇는다 |
| error | ReactNode | — | 무엇이 문제인지 + 어떻게 고치는지 한 문장. 묶음 아래에 한 번만 보인다 |
| required | boolean | — | legend 옆에 * 를 보이고 위젯에 aria-required 를 단다 |
| disabled | boolean | — | 묶음 전체 비활성(fieldset disabled). 안의 컨트롤이 모두 꺼진다 |
| id | string | — | legend id 의 바탕. 주지 않으면 useId 로 만든다 |
| className | string | — | |
| children필수 | ReactNode | ((group: FieldGroupControlProps) => ReactNode) | — | 묶음 안. 두 가지로 쓴다. - 함수: 자기 역할이 있는 위젯 하나(RadioGroup · SegmentedControl)에 묶음 aria 를 펼친다. 이때 fieldset 은 모양만 남기고(role="presentation") 이름 · 설명 · 오류 · 필수는 그 위젯이 갖는다. 묶음이 두 번 읽히지 않는다. - 노드: 체크박스 · 스위치 여러 개. fieldset 이 묶음(group)이 되고 legend 가 이름이다. |
FormErrorSummary
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| errors필수 | ReadonlyArray<FormError> | — | 화면 순서대로의 오류. 비어 있으면 아무것도 그리지 않는다 |
| attempt | number | — | 제출 횟수. 처음 나타날 때와 이 값이 바뀔 때마다 첫 오류 칸으로 포커스를 옮긴다 |
| lang | "ko" | "en" | "ja" | — | 내장 문구의 언어 |
| labels | Partial<FormErrorSummaryLabels> | — | 내장 문구 중 바꿀 것만 |
| className | string | — |
SubmitBar
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| dirty | boolean | true | 바뀐 것이 있다. false 이고 오류도 없으면 바를 그리지 않는다(저장되면 바가 내려간다) |
| pending | boolean | false | 저장 중. 저장 버튼 폭 그대로 스피너, 취소 잠금, 두 번 제출을 막는다 |
| invalidCount | number | 0 | 제출했는데 고칠 칸이 남은 수. 바의 문장이 오류 톤이 된다 |
| error | ReactNode | — | 칸과 무관한 서버 오류(무엇이 문제인지 + 할 일). role="alert" 로 읽고 저장 버튼이 labels.retry 가 된다 |
| onCancel | () => void | — | 취소. 없으면 취소 버튼을 그리지 않는다 |
| form | string | — | 바가 form 밖에 있을 때 저장 버튼이 제출할 form 의 id |
| sticky | boolean | true | 폼 열 아래에 붙어 뜬다(sticky). 정적인 자리(문서 · 대화상자 안)에서는 끈다 |
| lang | "ko" | "en" | "ja" | — | 내장 문구의 언어 |
| labels | Partial<SubmitBarLabels> | — | 내장 문구 중 바꿀 것만 |
| className | string | — |
SaveStatus
| prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| state필수 | "idle" | "saving" | "saved" | "error" | — | idle 은 아무 글자도 보이지 않는다(자리만 둔다) |
| at | string | Date | — | 저장한 시각(UTC ISO 문자열 또는 Date). saved 옆에 “· 14:32” 로 보인다 |
| timeZone | string | "Asia/Seoul" | 시각을 보일 IANA 시간대. 없으면 서울. 서버 · 클라이언트가 같은 글자를 그리도록 시간대를 정해 넘긴다 |
| onRetry | () => void | — | 있으면 error 에서 “다시 시도” 링크 버튼을 보인다. 실패 이유 문장은 그 줄(Switch 의 error)이 맡는다 |
| lang | "ko" | "en" | "ja" | "ko" | 내장 문구의 언어 |
| labels | Partial<SaveStatusLabels> | — | 내장 문구 중 바꿀 것만 |
| className | string | — |
import
FieldGroup · FormErrorSummary · SubmitBar · SaveStatus 는 ./form 에 있어 radix-ui 가 peer 로 필요합니다. Field · Input 은 ./ui 입니다.