FormField
라벨·에러·설명을 가진 폼 필드 래퍼 — Form 의 children 으로 직접 배치하는 통일 컴포넌트
목차
라벨 행(필수 표시 포함) · 입력 child · 그 아래 description 또는 error 를 세로로 쌓는 폼 필드 래퍼입니다.
Form 의 children 에 직접 배치해서 씁니다 — Form 은 필드를 자동으로 감싸지 않고
children 을 그대로 렌더하며, 자기 FormController 만 트리 아래로 내려보냅니다(Flutter Data.inherit
/ Web FormControllerScope). Gap 간격은 CoreFormFieldStyle 의 nested CoreGapStyle
슬롯으로 흐릅니다. Flutter / Web 1:1 동일 API 입니다.
Live Preview#
회사 이메일을 입력하세요.
코드 보기
class FormFieldDefaultExample extends StatelessComponent {
const FormFieldDefaultExample({super.key});
@override
Component build(BuildContext context) {
return div(
styles: Styles(raw: {'width': '320px'}),
[
FormField(
label: '이메일',
description: '회사 이메일을 입력하세요.',
required: true,
child: TextField(
autofillHints: const ['email'],
placeholder: Text('you@company.com'),
),
),
],
);
}
}
class FormFieldDefaultExample extends StatelessWidget {
const FormFieldDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 320,
child: FormField(
label: '이메일',
description: '회사 이메일을 입력하세요.',
required: true,
child: TextField(
autofillHints: const [AutofillHints.email],
placeholder: const Text('you@company.com'),
),
),
);
}
}
사용법#
// Flutter / Web 동일
FormField(
label: '이메일',
description: '회사 이메일을 입력하세요.',
required: true,
child: TextField(placeholder: const Text('you@company.com')),
)
Form + FormController 안에 두면 필드가 검증까지 담당합니다 — validator 를 넘기면 컨트롤러의 검증 결과가 그대로 이 필드의 에러로 표시되고,
formKey 로 그 값을 다시 읽을 수 있습니다.
child 입력(TextField · Checkbox · Toggle · RadioGroup
· PhoneInput 등 폼 입력)은 자기 값을 이 필드에 스스로 보고합니다 — 호출부에서 값을 손으로 잇지 않습니다. TextField
의 보고 규칙(빈 텍스트는 null, ReplaceResult 로 텍스트 교체)은 TextField
를 참고하세요.
// Flutter / Web 동일
final controller = FormController();
const emailKey = FormKey<String>('email');
Form(
controller: controller,
children: [
FormField(
label: '이메일',
required: true,
formKey: emailKey,
validator: const RequiredValidator<String>(),
child: TextField(placeholder: const Text('you@company.com')),
),
],
)
빠른 오버라이드 (Chain)#
이미 만든 FormField 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class FormFieldChainExample extends StatelessWidget {
const FormFieldChainExample({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 320,
child:
FormField(
label: '이메일',
description: '회사 이메일을 입력하세요.',
required: true,
child: TextField(
autofillHints: const [AutofillHints.email],
placeholder: const Text('you@company.com'),
),
).withStyle(
const CoreFormFieldStyle(
labelInputGapStyle: CoreGapStyle(size: CoreSpace.space24),
formLabelStyle: CoreFormLabelStyle(
labelStyle: CoreTextStyle.token(
CoreTextStyles.titleSmall,
color: CoreColor.token(CoreColors.primary),
),
requiredColor: CoreColor.token(CoreColors.tertiary),
requiredIndicatorGapStyle: CoreGapStyle(
size: CoreSpace.space8,
),
),
descriptionTextStyle: CoreTextStyle.token(
CoreTextStyles.labelSmall,
color: CoreColor.token(CoreColors.onSurfaceVariant),
),
),
),
);
}
}
class FormFieldChainExample extends StatelessComponent {
const FormFieldChainExample({super.key});
@override
Component build(BuildContext context) {
return div(
styles: Styles(raw: {'width': '320px'}),
[
FormField(
label: '이메일',
description: '회사 이메일을 입력하세요.',
required: true,
child: TextField(
autofillHints: const ['email'],
placeholder: Text('you@company.com'),
),
).withStyle(
const CoreFormFieldStyle(
labelInputGapStyle: CoreGapStyle(size: CoreSpace.space24),
formLabelStyle: CoreFormLabelStyle(
labelStyle: CoreTextStyle.token(
CoreTextStyles.titleSmall,
color: CoreColor.token(CoreColors.primary),
),
requiredColor: CoreColor.token(CoreColors.tertiary),
requiredIndicatorGapStyle: CoreGapStyle(size: CoreSpace.space8),
),
descriptionTextStyle: CoreTextStyle.token(
CoreTextStyles.labelSmall,
color: CoreColor.token(CoreColors.onSurfaceVariant),
),
),
),
],
);
}
}
Props / Parameters#
W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. W?
는 선택 슬롯입니다.
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
label | String? | null | 라벨 텍스트 |
labelMaxLines |
int? |
null |
라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다 |
labelOverflow |
CoreTextOverflow? |
null |
labelMaxLines
에 도달한 라벨의 넘침 처리 (
clip
/
fade
/
ellipsis
/
visible
)
|
labelWidget |
W? |
null |
커스텀 라벨 콘텐츠 (label 대신) |
labelAction |
W? |
null |
라벨 행 끝의 액션 |
child | W? | null | 입력 콘텐츠 |
counterText |
String? |
null |
보조 행의 표시용 카운터 |
error |
String? |
null |
에러 메시지 (있으면 description 대체) |
description |
String? |
null |
보조 설명 |
required |
bool |
false |
라벨에 필수 표시 |
enabled |
bool |
true |
활성/비활성 상태. 감싸는 Fieldset(enabled: false) 가 있으면 true 여도 비활성으로 읽힙니다 |
size |
CoreComponentSize |
md |
라벨 크기. 합성한
FormLabel
에 그대로 넘겨 그 크기 사다리(
xs
·
sm
·
md
=
label-medium
13,
lg
·
xl
·
xl2
=
label-large
14)를 따릅니다. 설명·카운터는 크기를 따라가지 않습니다
|
validator |
Validator<dynamic>? |
null |
child
입력값에 적용할 검증기.
Form
+
FormController
안에 있으면 설정된
FormValidationMode
에 따라 실행되고 그 메시지가 필드 에러로 표시됩니다 (
error
보다 우선).
null
이면 필드 단위 검증을 하지 않습니다
|
formKey |
FormKey<dynamic>? |
null → 필드마다 자동 생성 |
FormController
안에서 이 필드를 가리키는 안정 식별자.
controller.getValue(formKey)
로 값을 읽거나 교차 필드 검증(
shouldRevalidate
)에 씁니다
|
formFieldStyle |
CoreFormFieldStyle? |
null |
색·간격·타이포 단일 진입점 (labelInputGapStyle 등 nested Gap 슬롯) |
Form 의 필드 등록·검증에 참여할 때는 FormField를 씁니다. 필드 검증 없이 라벨·필수 표시만 필요하면 FormLabel 입니다.
동작 스펙 (Behavior)#
- labelWidget이 label 문자열보다 우선하며, labelAction은 같은 행 끝에 놓습니다. 오류가 있으면 description을 대신 표시하고 counterText는 표시만 합니다.
-
입력이 보고한 값과 validator를 조상의 FormController에 등록합니다. live 검증 오류가 수동
error보다 우선하며 검증 시점은 FormValidationMode가 정합니다. validator가 없으면 필드 단위 검증을 하지 않습니다. -
enabled와 조상 Fieldset의 disabled를 결합해 자식 FormFieldScope에 전달합니다. 임의 자식의 콜백을 모두 차단하는 래퍼가 아니라 스코프를 읽는 입력이 그 제약을 따릅니다. - 자체 입력·제출·선택 콜백은 없습니다. required는 라벨 표시이고 실제 필수 검증에는 validator가 필요합니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
label은 문자열로 전달 (labelWidget 단독 사용 지양)
// ✅ label 문자열을 주면 그룹에 접근 가능한 이름이 붙음
FormField(
label: '이메일',
required: true,
child: TextField(placeholder: const Text('you@company.com')),
)
labelWidget만 넘기면 가리킬 id가 없어 Web은 aria-labelledby를 생략하고 Flutter도 Semantics.label을 채우지 못합니다 — label 문자열을 전달해야 필드 그룹에 이름이 붙습니다.
❌ Don't#
error와 description이 동시에 보인다고 가정하지 않기
// ❌ error 가 있으면 description 은 렌더되지 않음
FormField(
label: '비밀번호',
description: '8자 이상 입력하세요.',
error: hasError ? '비밀번호가 너무 짧습니다.' : null,
child: TextField(obscureText: true),
)
에러가 표시되는 동안에는 description이 렌더되지 않고 에러 메시지가 그 자리를 대체합니다 — 도움말과 에러 안내를 동시에 보여주고 싶다면 에러 문구 안에 힌트를 포함시켜야 합니다.
접근성 (Accessibility)#
역할#
양 플랫폼 모두 필드 전체를 하나의 그룹으로 묶고, 이름·설명을 그 그룹에 진술합니다. 입력이 아니라 그룹에 진술하는 이유는 child
가 호출자 소유 컴포넌트라 그 안까지 손을 뻗어 속성을 넣을 수 없기 때문입니다.
Web 은 루트 <div> 에 role="group" 을 붙이고, 자기 부분을 가리킬 id 를 인스턴스마다 생성합니다(co-field-label-…
/ co-field-desc-… / co-field-error-…).
| 속성 | 붙는 조건 |
|---|---|
aria-labelledby |
label
문자열로 이 컴포넌트가
FormLabel
을 직접 렌더할 때만 — 그
<label>
의 id 를 가리킵니다
|
aria-describedby |
에러가 있으면
ValidatorHint
의 id, 없고
description
이 있으면 설명
<p>
의 id
|
aria-invalid="true" |
표시할 에러가 있을 때 (validator 의 검증 메시지 또는 error) |
aria-required="true" | required: true |
aria-disabled="true" | enabled: false |
Flutter 는 같은 내용을 Semantics(container: true, label: label, hint: error ?? description, enabled: enabled)
로 진술합니다. 이 Semantics 는 검증 결과 notifier 를 듣는 ValueListenableBuilder 안에서
만들어지므로, 제출 후 나타난 검증 메시지가 hint 를 따라 갱신됩니다.
필수 표시 * 는 양쪽 다 낭독에서 제외됩니다(Web aria-hidden="true", Flutter ExcludeSemantics). Web 은
aria-required 가 그 의미를 대신 전달하지만, Flutter 의 Semantics 는 required 를 진술하지 않아
필수 여부가 시각 표시로만 남습니다.
FormLabel 이 그리는 <label> 에는 for 가 없습니다. 라벨을 필드에 잇는 것은 네이티브 연결이 아니라 그룹의
aria-labelledby 입니다. 이 연결은 FormField 가 소유합니다 — 단독 Label 컴포넌트는 어느 플랫폼에서도 컨트롤과 연결되지 않습니다(그
htmlFor 파라미터는 deprecated).
키보드#
FormField 자체가 처리하는 키는 없습니다. 모든 키보드 동작은 child 로 넘긴 입력 컴포넌트의 것입니다.
포커스 관리#
FormField 는 포커스를 선언하지 않습니다 — FocusNode / tabindex / 포커스 링 / 트랩 / 복원이 모두 없습니다.
enabled: false 는 FormField 자신의 포인터를 차단하고(Flutter IgnorePointer, Web pointer-events: none), 비활성·에러 상태를
FormFieldScope 로 child 에 진술합니다. 그 스코프를 읽는 입력은 스스로 비활성·에러 chrome 으로 바뀝니다 —
TextField 는 편집을 막고(Flutter readOnly, Web <input disabled>) 에러 테두리를 그립니다.
TextField 를 합성하는 입력(Select 트리거 · Autocomplete · ChipInput
등)도 그 TextField 를 통해 같은 진술을 받습니다.
스코프는 감싼 서브트리 안에서만 흐릅니다 — child 입력이 여는 팝오버·다이얼로그의 콘텐츠는 스코프를 받지 않으므로, 에러가 있는 필드에서 연 다이얼로그의 입력이 에러 톤이 되거나 비활성 필드에서 연 패널의 입력이 막히지 않습니다.
TextField 를 합성하지 않는 입력(Checkbox · RadioGroup · TextArea
· DatePicker 트리거 등)은 이 진술을 받지 못하므로, 그런 입력을 child 로 넘기면 여전히 비활성으로 그려진 필드의 입력에 키보드로 Tab 해 들어가 타이핑할 수 있습니다
— 그 경우에는 입력 자체를 비활성화하세요.
스크린 리더#
그룹 경계를 지날 때 라벨이 이름으로, 설명 또는 에러가 설명으로 함께 읽힙니다. 에러가 표시되는 동안에는 설명이 렌더되지 않으므로, 설명 자리를 대체한 에러가 그대로 그룹의 설명이 됩니다.
다만 이 이름은 그룹의 것이지 입력의 것이 아닙니다. 리더가 그룹 안 입력으로 곧장 뛰어들면 입력이 스스로 제공하는 이름만 들릴 수 있습니다 — 입력 자신에게도 이름이 필요하면
child 에 직접 지정하세요.
에러는 합성된 ValidatorHint.error 가 그리고, 그 힌트가 나타나는 순간 통보됩니다 — Web 은 role="alert", Flutter 는 live region. 제출 직후 포커스가 옮겨지지 않아도 리더가 새 에러를 읽습니다. 설명(description)은 처음부터 있는 도움말이라 통보하지 않습니다.
알려진 제약#
-
labelWidget으로만 라벨을 넘기면 그룹에 이름이 붙지 않습니다. 가리킬 id 가 없어 Web 은aria-labelledby를 생략하고, Flutter 도label문자열만Semantics.label로 씁니다. 이름이 필요하면label을 쓰거나child입력에 직접 지정하세요. -
enabled: false가 키보드를 막는 것은 스코프를 읽는 입력뿐입니다.FormFieldScope를 읽는 것은TextField와 그것을 합성하는 입력이고,TextField를 합성하지 않는 입력은 비활성이라고 알려질 뿐 조작 가능 여부는 그대로입니다. -
필수 여부가 Flutter 에서는 낭독되지 않습니다. Web 의
aria-required에 대응하는 진술이 없어, 별표를 보지 못하는 사용자에게는 그 필드가 필수인지 전달되지 않습니다. 라벨 문구에 담거나child입력에 진술하세요. -
비활성 필드의 시각적 구분은 색 토큰에만 의존합니다 — 라벨은 활성
onSurfaceVariant에서 비활성onSurfaceSubtle로 한 단계 내려갑니다(disabledFormLabelStyle; 필드가 라벨에enabled를 넘기지 않아 이 덮어쓰기가 비활성 신호의 전부입니다). 그 축의 처리는 전역 접근성 축 소관입니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 값 보고 | FormFieldHandle / Data | CoreFormFieldHandle / InheritedComponent scope |
| 검증 갱신 | ValueNotifier·ValueListenableBuilder | 컨트롤러 알림 후 setState |
| 라벨·오류 관계 | 시맨틱 래퍼 | 그룹의 aria-labelledby / describedby 및 오류 live region |
| child | Widget 입력 | Jaspr 입력; native form 속성은 입력 자체가 제공 |