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

Form

폼 유효성 검증 컴포넌트

목차

여러 입력 필드를 묶어 유효성 검증을 수행하는 폼 컴포넌트입니다.

기본 사용법 (Basic Usage)#

Form(
  children: [
    FormField(
      label: '이름',
      required: true,
      child: TextField(placeholder: Text('홍길동')),
    ),
    FormField(
      label: '이메일',
      child: TextField(placeholder: Text('example@email.com')),
    ),
    Button(
      variant: CoreButtonVariant.primary,
      onPressed: handleSubmit,
      child: Text('제출'),
    ),
  ],
)
Form(
  onSubmit: handleSubmit,
  children: [
    FormField(
      label: '이름',
      required: true,
      child: TextField(placeholder: text('홍길동')),
    ),
    FormField(
      label: '이메일',
      child: TextField(placeholder: text('example@email.com')),
    ),
    Button(
      variant: CoreButtonVariant.primary,
      onPressed: handleSubmit,
      child: text('제출'),
    ),
  ],
)

Live Preview#

Web
Flutter
Loading Flutter...
코드 보기
class FormDefaultExample extends StatelessComponent {
  const FormDefaultExample({super.key});

  @override
  Component build(BuildContext context) {
    return Form(
      children: [
        FormField(
          label: 'Username',
          child: TextField(
            onChanged: (_) {},
            autofillHints: const ['username'],
            placeholder: Text('Username'),
          ),
        ),
        FormField(
          label: 'Email',
          child: TextField(
            onChanged: (_) {},
            autofillHints: const ['email'],
            placeholder: Text('Email'),
          ),
        ),
        Button(
          variant: CoreButtonVariant.primary,
          onPressed: () {},
          child: Text('Submit'),
        ),
      ],
    );
  }
}
class FormDefaultExample extends StatelessWidget {
  const FormDefaultExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Form(
      children: [
        FormField(
          label: 'Username',
          child: TextField(
            autofillHints: const [AutofillHints.username],
            placeholder: const Text('Username'),
          ),
        ),
        FormField(
          label: 'Email',
          child: TextField(
            autofillHints: const [AutofillHints.email],
            placeholder: const Text('Email'),
          ),
        ),
        Button(
          variant: CoreButtonVariant.primary,
          onPressed: () {},
          child: const Text('Submit'),
        ),
      ],
    );
  }
}

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 여러 입력 필드의 유효성 검증을 한꺼번에 관리할 때
  • 제출 전 사용자 입력을 검증하고 에러 피드백을 제공할 때
  • 필드 간 상호 의존적인 검증이 필요할 때 (비밀번호 확인 등)

대신 다른 컴포넌트를 사용하세요:

  • TextField: 단일 입력 필드만 필요하고 폼 검증이 불필요할 때
  • Fieldset: 검증 컨트롤러 없이 legend와 그룹 비활성 스코프만 필요할 때

Props / Parameters#

W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. W? 는 선택 슬롯, List<W> 는 콘텐츠 목록입니다.

Form#

속성타입기본값설명
children List<W>? null 폼 내용
onSubmit VoidCallback? null 폼 제출 콜백
rowSpacing double? CoreSpace.space24 (24.0) 필드 간 간격
controller FormController? null 폼 상태 컨트롤러 (Flutter·Web 공통)
showActions bool false 마지막 행에 액션 버튼 행(보조 · 주 버튼, 오른쪽 정렬)을 렌더할지
primaryAction W? null (기본 Button — 로케일의 Submit 라벨) 주 액션 버튼. null 이면 디자인 시스템 기본 버튼. onSubmit 과 연결되지 않으므로 동작하는 제출 버튼은 호출자가 onPressed 를 달아 넘긴다
secondaryAction W? null (기본 ghost Button — 로케일의 Cancel 라벨) 보조 액션 버튼. null 은 "기본 버튼"이지 "버튼 없음"이 아니다
showSecondaryAction bool true 보조 버튼을 렌더할지. false 면 주 버튼만 남는다 (Figma Show secondary action)
actionSpacing double? CoreSpace.space8 (8.0) 두 액션 버튼 사이 간격

FormField#

속성타입기본값설명
labelString?null필드 라벨 텍스트
labelMaxLines int? null 라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다
labelOverflow CoreTextOverflow? null labelMaxLines 에 도달한 라벨의 넘침 처리 ( clip / fade / ellipsis / visible )
labelWidget W? null 커스텀 라벨 위젯
childW?null입력 위젯
error String? null 수동 에러 메시지 (컨트롤러 검증 결과가 우선)
description String? null 도움말 텍스트
requiredboolfalse필수 입력 표시
enabledbooltrue활성화 여부
validator Validator<dynamic>? null 자식 입력값 검증기 (Flutter·Web 공통)
formKey FormKey<dynamic>? null (자동 생성) 컨트롤러 내 필드 식별자
formFieldStyle CoreFormFieldStyle? null chrome / gap 슬롯 스타일 (labelInputGapStyle 등)

스타일 시스템 (Style System)#

CoreFormStyle 필드#

필드타입설명
rowSpacing double? Inter-item spacing (logical px, pre-scaling) between adjacent form rows. Flows directly into Column.spacing (Flutter) or flex gap CSS (Web) — both are native paint-API arguments that consume raw doubles, so this slot stays scalar ( double? ). null defers to [defaultRowSpacing].
actionSpacing double? Spacing between the two action buttons (logical px, pre-scaling). Same native-paint-API scalar shape as [rowSpacing]. null defers to [defaultActionSpacing].

변형 (Variants)#

에러 상태#

FormField(
  label: '이메일',
  error: '올바른 이메일을 입력하세요',
  required: true,
  child: TextField(placeholder: Text('이메일')),
)

설명 텍스트#

FormField(
  label: '비밀번호',
  description: '8자 이상, 대소문자 및 숫자를 포함하세요',
  child: TextField(placeholder: Text('비밀번호')),
)

비활성화#

FormField(
  label: '이름',
  enabled: false,
  child: TextField(placeholder: Text('수정 불가')),
)

동작 스펙 (Behavior)#

Form은 자식 필드 배치와 컨트롤러 스코프를 제공합니다. 기본 액션 버튼은 제출·취소 콜백이 배선되지 않은 표시용 버튼이므로 동작하는 primaryAction / secondaryAction을 직접 넣어야 합니다. Web은 native submit 이벤트를 받지만 Flutter는 현재 onSubmit 을 선언해도 호출하지 않는 배선 결손이 있습니다. controller가 있다고 버튼 활성화나 서버 제출을 자동 수행하지는 않습니다.

controlled-state 는 Flutter·Web 공통입니다. FormController / FormKey / Validator / FormValidationMode 계약과 검증 알고리즘이 coui_core 단일소스에 있고, 두 플랫폼이 같은 API 로 동작합니다 (메커니즘만 다름 — Flutter ChangeNotifier, Web listener bag).

유효성 검증 모드 (FormValidationMode)#

모드설명
initial필드 생성 시 즉시 검증
changed사용자가 값을 변경할 때 검증
submitted폼 제출 시 검증

controlled form (Flutter·Web 공통)#

Form(controller:) 안에서 FormField(validator:) 를 쓰면, 자식 입력(Checkbox / Toggle / RadioGroup 등)이 값을 자동 보고하고 검증 결과가 필드에 표시됩니다. caller 가 필드별로 배선할 필요가 없습니다.

final controller = FormController();

Form(
  controller: controller,
  children: [
    FormField(
      label: '약관 동의',
      required: true,
      formKey: agreeKey,
      validator: const RequiredValidator<bool>(),
      child: Checkbox(checked: agreed, onChanged: setAgreed),
    ),
  ],
)

// 값 접근 / 에러 확인 (양 플랫폼 동일)
final agreed = controller.getValue(agreeKey);
final allValues = controller.values;
final errors = controller.errors;
final agreeError = controller.getSyncError(agreeKey);

// 재검증 — 플랫폼별 검증 컨텍스트로 트리거
//   Flutter: controller.revalidate(CoUIValidationContext(context), .submitted)
//   Web:     controller.revalidate(CoUIWebValidationContext(), .submitted)

Validator 시스템 (Flutter·Web 공통)#

검증기는 coui_core 에 정의되어 양 플랫폼이 공유합니다. 연산자로 조합합니다.

// AND: 모든 조건 충족 (& / + / combine)
final validator = const RequiredValidator<String>() & customValidator;

// OR: 하나 이상 충족
final validator = aValidator | bValidator;

// NOT: 조건 반전 (생성자로 — `~` 연산자는 없습니다)
final validator = NotValidator(customValidator);

내장 검증기: RequiredValidator<T> (필수 — null/빈 문자열 거부), NotValidator<T> (반전), OrValidator<T> (OR), CompositeValidator<T> (AND). 커스텀 검증은 Validator<T> 를 상속해 validate(CoreValidationContext, value, mode) 를 구현합니다.

사용 가이드라인 (Usage Guidelines)#

✅ Do#

필수 필드를 명확히 표시하세요.

FormField(
  label: '이름',
  required: true,
  child: TextField(placeholder: Text('홍길동')),
)

어떤 필드가 필수인지 미리 알려주면 제출 실패를 줄일 수 있습니다.


❌ Don't#

필수/선택 구분 없이 필드를 나열하지 마세요.

FormField(
  label: '이름',
  // required 표시 없음
  child: TextField(placeholder: Text('이름')),
)

사용자가 어떤 필드를 채워야 하는지 알 수 없어 제출 실패가 반복됩니다.

✅ Do#

에러 메시지에 수정 방법을 안내하세요.

FormField(
  label: '비밀번호',
  error: '8자 이상, 대소문자 및 숫자를 포함하세요',
  child: TextField(placeholder: Text('비밀번호')),
)

구체적인 기준을 알려주면 사용자가 즉시 수정할 수 있습니다.


❌ Don't#

모호한 에러 메시지를 사용하지 마세요.

FormField(
  label: '비밀번호',
  error: '유효하지 않은 비밀번호',
  child: TextField(placeholder: Text('비밀번호')),
)

무엇이 틀렸는지 알 수 없으면 여러 번 시도해야 합니다.

접근성 (Accessibility)#

역할#

  • Web Form: onSubmit 을 넘기면 native <form>, 없으면 <div> 로 렌더됩니다(생성자의 tag 인자가 이 기본값을 양방향으로 덮습니다). 컨테이너 자체에 추가하는 role / aria-* 는 없고, attributes 파라미터도 노출하지 않아 호출부가 폼에 이름을 실을 수 없습니다.
  • Web FormField: 필드 하나를 감싸는 <div> 에 role="group" 을 두고, 그 그룹에 aria-labelledby(자기가 만든 FormLabel 을 가리킴) · aria-describedby(설명 또는 에러 노드) · 에러가 있으면 aria-invalid="true" · required: true 면 aria-required="true" · enabled: false 면 aria-disabled="true" 를 함께 실습니다. 입력 자체는 호출부가 넘긴 컴포넌트라 안쪽에 속성을 넣을 수 없어, 그룹이 대신 진술하는 구조입니다.
  • Web FormLabel: <label> 태그로 렌더되지만 for 속성이 없고 입력을 감싸지도 않습니다 — 연결은 위 aria-labelledby 가 담당합니다. 필수 표시 * 는 aria-hidden="true" 입니다.
  • Flutter Form: 시맨틱을 설정하지 않는 Column 입니다(선택적으로 FormController 를 Data.inherit 로 내려보낼 뿐입니다).
  • Flutter FormField: Semantics(container: true, enabled:, label:, hint:) 로 Web 그룹과 같은 진술을 합니다 — label 이 필드 이름, hint 가 에러(없으면 설명)입니다. required 를 알리는 축은 없습니다.

키보드#

키동작
Tab다음 포커스 가능 요소(대개 다음 폼 필드)로 이동
Shift+Tab이전 포커스 가능 요소로 이동
Enter Web 에서 루트가 <form> 일 때 브라우저의 암묵적 제출 — 성립 조건은 아래 제약 참조

Form 과 FormField 는 어떤 키도 직접 가로채지 않습니다. 위 이동은 전부 플랫폼 기본 동작이며, 필드 사이를 건너뛰거나 그룹 단위로 이동하는 단축키는 없습니다.

스크린 리더#

  • 접근 가능한 이름: FormField(label: '...') 문자열이 유일한 출처입니다. Web 은 FormLabel 에 생성된 id 를 붙이고 그룹의 aria-labelledby 가 그것을 가리키며, Flutter 는 같은 문자열을 Semantics.label 로 둡니다. labelWidget 으로 라벨을 직접 넘기면 Web 은 가리킬 id 가 없어 aria-labelledby 를 아예 붙이지 않고 Flutter 의 Semantics.label 도 label 문자열만 읽으므로, 양 플랫폼 모두 그룹이 이름 없이 남습니다.
  • 설명 / 에러: description 은 aria-describedby(Web) / Semantics.hint(Flutter)로 필드에 연결되고, 에러가 생기면 같은 자리를 에러 메시지가 대체합니다(설명과 에러는 동시에 표시되지 않습니다). 에러 아이콘은 메시지를 되풀이하므로 양 플랫폼 모두 시맨틱에서 제외됩니다(aria-hidden / ExcludeSemantics).
  • 필수 표시: Web 은 그룹의 aria-required 로 전달되지만, 시각적 * 자체는 양 플랫폼 모두 읽히지 않게 제외되어 있습니다. Flutter 에는 필수 여부를 알리는 경로가 없습니다.
  • 상태 통보: 값이나 검증 결과가 바뀌어도 통보되지 않습니다 — 아래 제약 참조.

포커스 관리#

  • 진입: 오버레이가 아니어서 렌더 시 포커스를 옮기지 않습니다. 첫 필드에 자동으로 들어가지 않습니다.
  • 이탈: 되돌아갈 트리거가 없습니다.
  • 트랩: 없습니다. 마지막 필드에서 Tab 을 누르면 폼 바깥으로 나갑니다. 순서는 children 선언 순서 그대로이며 폼이 재정렬하지 않습니다.

알려진 제약#

  • 검증 에러가 나타나도 통보되지 않습니다(양 플랫폼). 에러를 그리는 ValidatorHint 는 Web 에서 role="alert" / aria-live 를 붙이지 않고(그 파일의 유일한 aria 는 아이콘의 aria-hidden), Flutter 에서도 liveRegion 을 설정하지 않습니다. 에러는 aria-describedby + aria-invalid(Web, 입력이 아니라 필드 그룹 컨테이너에 진술됩니다) / Semantics.hint(Flutter)로 필드에 연결돼 있으므로 그 필드에 포커스가 갈 때만 읽힙니다 — 제출 직후 화면에 뜬 메시지는 아무도 읽어주지 않습니다. 에러 노드는 FormField 내부에서 생성되고 Web Form / FormField 는 attributes 파라미터를 노출하지 않아 호출부가 라이브 리전을 얹을 방법이 없습니다.
  • 제출이 실패해도 첫 번째 오류 필드로 포커스가 이동하지 않습니다. 폼 컨트롤러에는 포커스/스크롤을 다루는 코드가 없어, 제출 후 포커스는 제출 버튼에 그대로 남고 화면 밖에 있는 오류 필드는 발견되지 않은 채로 남습니다. 호출부가 자기 입력에 focusNode(Flutter) / id(Web)를 쥐고 있다면 controller.revalidate(context, FormValidationMode.submitted) 결과를 보고 직접 포커스를 옮길 수 있습니다.
  • Enter 제출은 Web 에서 루트가 <form> 일 때만, 그것도 조건부로 성립합니다. 태그는 onSubmit 이 기본값을 정할 뿐이고 생성자의 tag 인자가 그 판정을 양방향으로 덮으므로, tag: 'form' 이면 onSubmit 없이도 <form> 이 되고 tag: 'div' 면 onSubmit 이 있어도 <div> 가 됩니다. 그리고 CoUI Button 의 기본 type 은 "button" 이라 아무것도 하지 않으면 폼 안에 제출 버튼이 없고, 제출 버튼이 없는 폼의 암묵적 제출은 텍스트 계열 입력이 정확히 하나일 때만 일어납니다 — 호출부가 attributes: {'type': 'submit'} 을 넘기면 그 값이 기본값을 덮습니다(caller 속성이 나중에 병합됩니다). 게다가 submit 핸들러가 브라우저 기본 동작을 막지 않아, 실제로 제출이 발생하면 페이지 전송까지 이어집니다. Flutter 의 Form.onSubmit 은 필드로 선언만 되어 있고 build 어디에서도 호출되지 않습니다. 양 플랫폼 모두 제출 버튼의 onPressed 에서 검증과 제출을 직접 부르는 구성이 안전합니다.

크로스 플랫폼 차이점 (Platform Differences)#

controlled-state 의 capability·API 는 양 플랫폼 동일합니다 (계약·검증 로직이 coui_core 단일소스). 차이는 메커니즘(런타임) 뿐입니다.

항목FlutterWeb
클래스명 Form + FormField Form + FormField
상태 관리 FormController (capability 동일) FormController (capability 동일)
컨트롤러 메커니즘 ChangeNotifier + post-frame notify listener bag + microtask notify
컨트롤러 공유 Data.inherit FormControllerScope (InheritedComponent)
유효성 검증 Validator<T> 시스템 (공유) Validator<T> 시스템 (공유)
크로스 필드 검증shouldRevalidateshouldRevalidate
검증기 조합 연산자 (&, ` , +) + NotValidator`
검증 컨텍스트 CoUIValidationContext(context) CoUIWebValidationContext()
  • TextField: 텍스트 입력 필드. FormField로 감싸 라벨/에러 표시
  • Select: 드롭다운 선택. 폼 필드로 사용 가능
  • Checkbox: 체크박스 입력. 약관 동의 등에 활용
  • Button: 폼 제출 버튼

조합 예제#

// 회원가입 폼 패턴
Form(
  children: [
    FormField(
      label: '이름',
      required: true,
      child: TextField(placeholder: Text('홍길동')),
    ),
    FormField(
      label: '이메일',
      required: true,
      child: TextField(placeholder: Text('example@email.com')),
    ),
    FormField(
      label: '비밀번호',
      required: true,
      description: '8자 이상, 대소문자 및 숫자를 포함하세요',
      child: TextField(placeholder: Text('비밀번호')),
    ),
    Button(
      variant: CoreButtonVariant.primary,
      onPressed: handleSignUp,
      child: Text('가입하기'),
    ),
  ],
)