FormField | CoUI
LogoCoUI

FormField

라벨·에러·설명을 가진 폼 필드 래퍼 — Form 의 children 으로 직접 배치하는 통일 컴포넌트

FormField#

라벨 행(필수 표시 포함) · 입력 child · 그 아래 description 또는 error 를 세로로 쌓는 폼 필드 래퍼입니다. Formchildren직접 배치해서 씁니다 — Form 은 필드를 자동으로 감싸지 않고 children 을 그대로 렌더하며, 자기 FormController 만 트리 아래로 내려보냅니다(Flutter Data.inherit / Web FormControllerScope). Gap 간격은 CoreFormFieldStyle 의 nested CoreGapStyle 슬롯으로 흐릅니다. Flutter / Web 1:1 동일 API 입니다.

Live Preview#

사용법#

// 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#

파라미터타입기본값설명
labelString?null라벨 텍스트
labelWidget Widget / Component? null 커스텀 라벨 위젯 (label 대신)
child Widget / Component? null 입력 위젯
error String? null 에러 메시지 (있으면 description 대체)
description String? null 보조 설명
required bool false 라벨에 필수 표시
enabledbooltrue활성/비활성 상태
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 도 유사한 라벨·에러·설명 래퍼지만, FormFieldForm 컴포넌트의 실제 필드 래퍼입니다.

사용 가이드라인 (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#

errordescription이 동시에 보인다고 가정하지 않기

// ❌ 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 입력에 진술하세요.
  • 비활성 필드의 시각적 구분은 색 토큰에만 의존합니다 — 그 축의 처리는 전역 접근성 축 소관입니다.