FormattedInput | CoUI
LogoCoUI

FormattedInput

정적 구분자와 편집 필드가 섞인 마스크 입력 컴포넌트

FormattedInput#

전화번호·신용카드·날짜처럼 고정된 형식을 가진 데이터를 입력받는 마스크 입력 컴포넌트입니다. 정적 구분자와 편집 가능한 자리가 [parts]로 정의되지만, 화면에는 하나의 실제 입력 필드만 렌더링됩니다 — 리터럴((, ), /, -)은 입력하는 동안 자동으로 끼워지고, Backspace는 커서 위치와 무관하게 자연스럽게 앞 숫자를 지웁니다. 하나의 진짜 필드라 브라우저 자동완성·OS 자동완성·비밀번호 관리자 확장(1Password 등)이 일반 텍스트 필드와 동일하게 인식하고 채울 수 있습니다. .phone / .koreanPhone / .creditCard / .date 팩토리로 흔한 마스크를 바로 사용할 수 있습니다.

Live Preview#

사용 시기 (When to Use)#

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

  • 전화번호·카드번호·날짜 등 형식이 정해진 입력
  • 입력 위치에 구분자((, ), /, -)가 자동으로 표시되어야 하는 경우
  • 브라우저/OS 자동완성이나 비밀번호 관리자가 값을 채워야 하는 경우 (autofillHints)

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

  • TextField: 자유 형식 텍스트 입력
  • InputOtp: 일회용 인증 코드 입력

기본 사용법 (Basic Usage)#

// 전화번호 마스크
FormattedInput.phone(
  onChanged: (value) => print(value),
)

// 커스텀 마스크 — parts 를 몇 개 조립하든 렌더링은 항상 필드 하나
FormattedInput(
  parts: const [
    CoreFormattedInputPart.editable(length: 2, pattern: r'\\d'),
    CoreFormattedInputPart.static('/'),
    CoreFormattedInputPart.editable(length: 2, pattern: r'\\d'),
  ],
  onChanged: (value) => print(value),
)
// 전화번호 마스크
FormattedInput.phone(
  onChanged: (value) => print(value),
)

// 커스텀 마스크 — parts 를 몇 개 조립하든 렌더링은 항상 필드 하나
FormattedInput(
  parts: const [
    CoreFormattedInputPart.editable(length: 2, pattern: r'\\d'),
    CoreFormattedInputPart.static('/'),
    CoreFormattedInputPart.editable(length: 2, pattern: r'\\d'),
  ],
  onChanged: (value) => print(value),
)

parts 의 개수·길이·정적 구분자 위치는 완전히 자유입니다 — 몇 개의 편집 자리로 나누든, CoreFormattedInputMaskEngine(플랫폼 중립 AsYouType 엔진)이 항상 하나의 실제 필드 위에서 리터럴 자동 삽입·Backspace·붙여넣기·임의 위치 편집을 처리합니다. 세그먼트별로 갈라진 별도 필드는 없습니다.

한국 휴대폰 프리셋#

010-1234-5678(3-4-4, 11자리) 형식은 .koreanPhone 팩토리로 바로 씁니다. .phone 과 동일하게 autofillHints 기본값이 전화번호로 설정됩니다.

FormattedInput.koreanPhone(
  onChanged: (value) => print(value),
)
FormattedInput.koreanPhone(
  onChanged: (value) => print(value),
)

leading / trailing 조합 — 인증번호 발송 버튼#

trailing 슬롯에 임의 위젯을 넣어 필드 안쪽에 인라인 액션을 붙일 수 있습니다. 전화번호 인증 화면처럼 "필드 + 버튼"을 별도 줄 레이아웃 없이 한 컨트롤로 묶을 때 씁니다.

FormattedInput.koreanPhone(
  onChanged: (value) => print(value),
  trailing: Button(
    onPressed: () => print('Send verification code'),
    variant: .link,
    size: .sm,
    child: Text('인증번호 보내기').labelMedium,
  ),
)
FormattedInput.koreanPhone(
  onChanged: (value) => print(value),
  trailing: Button(
    onPressed: () => print('Send verification code'),
    variant: .link,
    size: .sm,
    child: Text('인증번호 보내기').labelMedium,
  ),
)

빠른 오버라이드 (Chain)#

이미 만든 FormattedInput 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius12처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius12 == CoreRadius.radius12) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class FormattedInputChainExample extends StatelessWidget {
  const FormattedInputChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        FormattedInput.phone(
          onChanged: (value) => debugPrint('Phone: $value'),
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
        FormattedInput.phone(
          onChanged: (value) => debugPrint('Phone: $value'),
        ).withStyle(
          const CoreFormattedInputStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
    );
  }
}
class FormattedInputChainExample extends StatelessComponent {
  const FormattedInputChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        FormattedInput.phone(
          onChanged: (value) => print('Phone: $value'),
        ).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
        FormattedInput.phone(
          onChanged: (value) => print('Phone: $value'),
        ).withStyle(
          const CoreFormattedInputStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

Props / Parameters#

속성타입기본값설명
parts List<CoreFormattedInputPart> 마스크 자리 목록 (필수)
value String? null 초기 값 (편집 자리들을 이어붙인 문자열)
enabledbooltrue상호작용 가능 여부
onChanged ValueChanged<String>? null 값 변경 콜백
leading Widget? / Component? null 필드 앞 위젯
trailing Widget? / Component? null 필드 뒤 위젯
formattedInputStyle CoreFormattedInputStyle? null 컨테이너 색상 / 테두리 / padding 오버라이드
autofillHints Iterable<String>? 팩토리별 기본값 자동완성 힌트. Flutter 는 AutofillHints.* 값, Web 은 HTML autocomplete 토큰 — 이름/타입은 TextField.autofillHints 와 동일

팩토리 생성자#

팩토리마스크autofillHints 기본값
FormattedInput.phone(___) ___-____전화번호
FormattedInput.koreanPhone ___-____-____ (010-1234-5678) 전화번호
FormattedInput.creditCard ____ ____ ____ ____ 카드번호
FormattedInput.date__/__/____없음

스타일 시스템 (Style System)#

segmentSpacing / charWidth / separatorColor / separatorTextStyle / shortSegmentWidth / mediumSegmentWidth / longSegmentWidth / extraLongSegmentWidth 8개 필드는 세그먼트별 박스가 있던 옛 아키텍처의 흔적으로 @Deprecated 처리돼 더 이상 적용되지 않습니다 — 하나의 필드만 렌더링하는 지금은 세그먼트 간격도, 세그먼트별 너비도, 구분자 전용 색·타이포도 의미가 없습니다(리터럴이 필드 자신의 값 텍스트 일부이기 때문입니다). segmentInputStyle 만 유지되며, 이제 그 하나의 실제 필드에 그대로 raw-forward 됩니다.

CoreFormattedInputStyle 필드#

필드타입설명
backgroundColor CoreColor? Container background colour override.
borderColor CoreColor? Container border colour override.
disabledBackgroundColor CoreColor? Container background colour while the field is disabled. A token rather than an opacity over the enabled fill. An alpha is not a forced value in a colour-forcing accessibility mode — the colour is substituted and the alpha survives — so a disabled field dimmed by opacity reads as enabled there. See theme-axis-composition.md 's "상태 구분도 색만으로 하지 않는다" corollary. null → [defaultDisabledBackgroundColor].
disabledBorderColor CoreColor? Container border colour while the field is disabled. null → [defaultDisabledBorderColor]. Both disabled-colour fields on this class landed in #4505, whose BREAKING CHANGE: footer for this addition was one of several squashed into a single commit and dropped by the release tooling (only the first footer is read). The restatement in #4616 was itself squash-merged with four sibling footers and lost the same way ( ea3efc985 ) — this is the second restatement, on its own commit, not squashed with anything else. They are == / hashCode participants like every field here, so a cache or snapshot keyed on style equality recomputes.
focusBorderColor CoreColor? Container focus-ring border colour override.
borderRadius CoreBorderRadius? Container border radius override.
borderWidth double? Container border width override (logical px).
segmentSpacing double? Horizontal spacing padding inside each segment. null → [defaultSegmentSpacing]. Resolver pre-applies scaling.
charWidth double? Per-character width used to size each segment box. null → [defaultCharWidth]. Resolver pre-applies scaling.
padding CoreEdgeInsets? Container padding override.
separatorColor CoreColor? Static separator text colour override.
separatorTextStyle CoreTextStyle? Static separator text style override (typography role / font / colour).
segmentInputStyle CoreTextFieldStyle? Editable-segment TextField style override (chrome + value / placeholder typography). Nested child-component slot forwarded to the single composed TextField on both platforms.
shortSegmentWidth double? Segment width override for short segments (1–2 chars), logical px.
mediumSegmentWidth double? Segment width override for medium segments (3 chars), logical px.
longSegmentWidth double? Segment width override for long segments (4 chars), logical px.
extraLongSegmentWidth double? Segment width override for extra-long segments (5+ chars), logical px.

동작 스펙 (Behavior)#

인터랙션#

  • AsYouType 포맷팅: 편집 가능한 자리를 채우면 다음 리터럴이 자동으로 끼워집니다 — 아직 입력하지 않은 자리 뒤의 리터럴은 표시되지 않습니다.
  • 자연스러운 Backspace: 커서가 리터럴 바로 뒤에 있어도 Backspace 는 그 리터럴이 아니라 앞의 숫자를 지웁니다 — 세그먼트를 옮겨 다닐 필요가 없습니다.
  • 붙여넣기 / 임의 위치 편집: 클립보드 붙여넣기나 중간 위치 삽입·삭제 모두 같은 엔진이 한 번에 재포맷합니다.
  • 패턴 제한: pattern 이 지정된 자리는 일치하는 문자만 입력을 허용합니다.
  • enabledfalse 이면 비활성.

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

✅ Do#

표준 마스크는 팩토리 생성자로

// ✅ 표준 마스크는 팩토리로
FormattedInput.phone(onChanged: (value) => print(value))

.phone / .koreanPhone / .creditCard / .date 팩토리가 이미 검증된 parts 조합(구분자 위치·자릿수·digit-only pattern)과 알맞은 autofillHints 기본값을 제공하므로, 손으로 CoreFormattedInputPart 리스트를 조립할 때 흔한 실수(패턴 누락, 길이 오류)를 피할 수 있습니다.


❌ Don't#

접근 가능한 이름이 자동으로 붙는다고 가정하지 않기

// ❌ FormattedInput 자체에는 라벨을 줄 방법이 없음
FormattedInput.phone(onChanged: handlePhoneChanged)

// ✅ Flutter 는 바깥에서 Semantics 로 감싸야 함
Semantics(
  label: '전화번호',
  child: FormattedInput.phone(onChanged: handlePhoneChanged),
)

contract 에 semanticLabel / label 파라미터가 없어 필드에 이름을 붙일 수 없습니다. Web은 attributesaria-label을 우회 전달할 수 있지만, Flutter에는 대응하는 탈출구가 없어 호출자가 직접 Semantics로 감싸야 합니다.

접근성 (Accessibility)#

이 절은 FormattedInput 자신의 동작만 다룹니다. 전 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축을 참고하세요.

역할 / Semantics#

FormattedInput 자신은 role 도 Semantics 도 내보내지 않습니다. Flutter 구현에는 Semantics 호출이 없고, Web 루트 <div> 는 호출자가 넘긴 attributes 만 통과시킵니다.

실제 시맨틱은 전부 컴포넌트가 렌더링하는 단 하나의 실제 입력 필드에서 나옵니다 — Web 은 native <input>, Flutter 는 TextField / EditableText 의 텍스트 필드 시맨틱입니다. 세그먼트별로 갈라진 여러 필드가 아니라 필드 하나이므로, 값은 한 번에 온전하게 읽힙니다 — 예전처럼 "이름 없는 필드 3개"로 쪼개져 읽히지 않습니다. 다만 그 하나의 필드에도 label 이나 aria-label 은 전달되지 않으므로 여전히 이름 없는 텍스트 필드로 남습니다.

자동완성 (Autofill)#

autofillHints 가 그 하나의 실제 필드에 그대로 전달됩니다 — Flutter 는 AutofillHints.* 값, Web 은 HTML autocomplete 토큰(예: tel, cc-number)입니다. 세그먼트로 쪼개져 있던 예전 아키텍처에서는 브라우저/OS 자동완성이나 1Password 같은 비밀번호 관리자 확장이 값을 채울 곳이 없었지만, 지금은 일반 텍스트 필드와 똑같이 인식되고 채워집니다. .phone / .koreanPhone 은 전화번호로, .creditCard 는 카드번호로 기본 설정되어 있고, 커스텀 마스크는 autofillHints 를 직접 지정해야 합니다.

키보드#

이 컴포넌트가 직접 처리하는 키는 없습니다. 입력·캐럿·선택은 모두 네이티브 입력 필드의 동작이며, Web 의 onKeyDown / onKeyUp 은 호출자 passthrough 파라미터일 뿐 컴포넌트가 바인딩하는 핸들러가 아닙니다.

Backspace / Delete / 화살표 키 / 붙여넣기 모두 네이티브 필드 위에서 그대로 동작합니다 — 리터럴 자동 삽입과 리터럴-경계 Backspace 만 CoreFormattedInputMaskEngine 이 각 입력 이벤트 뒤에 재포맷으로 반영합니다. 더 이상 세그먼트 간 이동이라는 개념 자체가 없으므로 앞으로 돌아가기 위한 별도 키(Shift+Tab 등)가 필요 없습니다 — 캐럿은 한 필드 안에서 자유롭게 움직입니다.

포커스#

단일 실제 입력 필드가 유일한 탭 정지점입니다 — 양 플랫폼 동일합니다. Flutter 는 그 필드 하나에 FocusNode 를 두고, 컨테이너 자체는 더 이상 별도로 포커스를 받지 않습니다(옛 아키텍처의 Focus 래퍼 제거). Web 은 루트에도 필드에도 tabindex 를 추가하지 않고 네이티브 <input> 의 기본 포커스 동작을 그대로 씁니다.

포커스 표시는 양쪽 모두 테두리 색 교체 하나뿐입니다 — 링/아웃라인도, focus-visible 분기도 없습니다. 포커스 트랩이나 포커스 복원도 없습니다.

스크린 리더#

필드는 이름 없는 텍스트 필드 하나로 읽힙니다. 비어 있을 때 붙는 텍스트는 전체 마스크 패턴(예: ___-____-____)이 플레이스홀더로 전달됩니다 — Web 은 placeholder 속성, Flutter 는 hintText 로 전달됩니다. 정적 리터럴은 더 이상 낱개 텍스트 노드로 떠 있지 않고 필드 자신의 값 문자열 일부이므로, 스크린 리더는 값을 한 번에 온전하게 읽습니다.

알려진 제약#

  • 접근 가능한 이름을 줄 경로가 아예 없습니다. contract 에 semanticLabel / label / aria-label 파라미터가 없어 필드에 이름을 붙일 수 없습니다. Web 은 attributes 를 직접 넘겨 우회할 수 있지만 Flutter 에는 대응하는 탈출구가 없습니다 — 호출자가 바깥에서 Semantics 로 감싸야 합니다.

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

항목FlutterWeb
클래스명FormattedInputFormattedInput
렌더링 필드단일 TextField단일 native <input>
포커스 링 FocusNode + 테두리 색 변경 focus / blur 이벤트 + 테두리 색 변경