FormField | CoUI
본문으로 건너뛰기
LogoCoUI

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#

사용법#

// 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? 는 선택 슬롯입니다.

파라미터타입기본값설명
labelString?null라벨 텍스트
labelMaxLines int? null 라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다
labelOverflow CoreTextOverflow? null labelMaxLines 에 도달한 라벨의 넘침 처리 ( clip / fade / ellipsis / visible )
labelWidget W? null 커스텀 라벨 콘텐츠 (label 대신)
labelAction W? null 라벨 행 끝의 액션
childW?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)#

항목FlutterWeb
값 보고FormFieldHandle / DataCoreFormFieldHandle / InheritedComponent scope
검증 갱신ValueNotifier·ValueListenableBuilder컨트롤러 알림 후 setState
라벨·오류 관계시맨틱 래퍼그룹의 aria-labelledby / describedby 및 오류 live region
childWidget 입력Jaspr 입력; native form 속성은 입력 자체가 제공