FormField#
라벨 행(필수 표시 포함) · 입력 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'),
),
),
);
}
}
회사 이메일을 입력하세요.
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),
),
),
),
],
);
}
}
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),
),
),
),
);
}
}
사용법#
// Flutter / Web 동일
FormField(
label: '이메일',
description: '회사 이메일을 입력하세요.',
required: true,
child: TextField(placeholder: const Text('you@company.com')),
)
Form + FormController 안에 두면 필드가 검증까지 담당합니다 — validator 를 넘기면 컨트롤러의 검증 결과가 그대로 이 필드의 에러로 표시되고,
formKey 로 그 값을 다시 읽을 수 있습니다.
// 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#
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
label | String? | null | 라벨 텍스트 |
labelWidget |
Widget / Component? |
null |
커스텀 라벨 위젯 (label 대신) |
child |
Widget / Component? |
null |
입력 위젯 |
error |
String? |
null |
에러 메시지 (있으면 description 대체) |
description |
String? |
null |
보조 설명 |
required |
bool |
false |
라벨에 필수 표시 |
enabled | bool | true | 활성/비활성 상태 |
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 슬롯) |
FormControl도 유사한 라벨·에러·설명 래퍼지만,FormField가Form컴포넌트의 실제 필드 래퍼입니다.
사용 가이드라인 (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 자체가 처리하는 키는 없습니다. 모든 키보드 동작은 child 로 넘긴 입력 컴포넌트의 것입니다.
포커스#
FormField 는 포커스를 선언하지 않습니다 — FocusNode / tabindex / 포커스 링 / 트랩 / 복원이 모두 없습니다.
enabled: false 는 포인터만 차단합니다. Flutter 는 IgnorePointer 로 hit-test 를 막고, Web 은
pointer-events: none 을 emit 하며, 어느 쪽도 inert 를 붙이거나 child 입력 자체를 비활성화하지 않습니다. 비활성 상태가 보조 기술에 진술되기는 하지만(aria-disabled
/ Semantics(enabled:)) 조작이 막히지는 않으므로, 비활성으로 그려진 필드의 입력에 키보드로 Tab 해 들어가 타이핑할 수 있습니다
— 실제로 막으려면 child 로 넘기는 입력 자체를 비활성화하세요.
스크린 리더#
그룹 경계를 지날 때 라벨이 이름으로, 설명 또는 에러가 설명으로 함께 읽힙니다. 에러가 표시되는 동안에는 설명이 렌더되지 않으므로, 설명 자리를 대체한 에러가 그대로 그룹의 설명이 됩니다.
다만 이 이름은 그룹의 것이지 입력의 것이 아닙니다. 리더가 그룹 안 입력으로 곧장 뛰어들면 입력이 스스로 제공하는 이름만 들릴 수 있습니다 — 입력 자신에게도 이름이 필요하면
child 에 직접 지정하세요.
ValidatorHint 에는 role="alert" 도 aria-live 도 없습니다. 따라서 검증 메시지는 나타나는 순간이 아니라, 포커스나 읽기 커서가 거기 닿을 때 낭독됩니다.
알려진 제약#
- 검증 메시지에 live region 이 없습니다. 필드가 검증 파이프라인을 소유하고 그 결과를 그룹 설명으로 반영하는데도, 제출 직후 나타난 에러가 스스로 통보되지는 않습니다. 즉시 알려야 하는 흐름이라면 호출자가 live region 을 감싸야 합니다.
-
labelWidget으로만 라벨을 넘기면 그룹에 이름이 붙지 않습니다. 가리킬 id 가 없어 Web 은aria-labelledby를 생략하고, Flutter 도label문자열만Semantics.label로 씁니다. 이름이 필요하면label을 쓰거나child입력에 직접 지정하세요. enabled: false가 키보드를 막지 않습니다. 비활성이라고 알려주기는 하지만 조작 가능 여부는 그대로입니다.-
필수 여부가 Flutter 에서는 낭독되지 않습니다. Web 의
aria-required에 대응하는 진술이 없어, 별표를 보지 못하는 사용자에게는 그 필드가 필수인지 전달되지 않습니다. 라벨 문구에 담거나child입력에 진술하세요. - 비활성 필드의 시각적 구분은 색 토큰에만 의존합니다 — 그 축의 처리는 전역 접근성 축 소관입니다.