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

FormattedInput

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

목차

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

기본 사용법 (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: .plain,
    size: .sm,
    child: Text('인증번호 보내기').labelMedium,
  ),
)
FormattedInput.koreanPhone(
  onChanged: (value) => print(value),
  trailing: Button(
    onPressed: () => print('Send verification code'),
    variant: .plain,
    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',
    );
  }
}

Live Preview#

사용 시기 (When to Use)#

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

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

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

  • ObjectInput: 마스크 문자열 하나 대신 이름 붙은 구간의 키·값을 입력할 때
  • PhoneInput: 전화번호 문자열 마스크 외에 국가·국가번호 선택이 필요할 때
  • TimePicker: 날짜·시간 문자열 마스크 대신 시간 선택 표면이 필요할 때
  • TextField: 마스크 자리·리터럴 없이 자유 형식 문자열을 받을 때
  • InputOtp: 리터럴 마스크 대신 고정 길이 인증 코드와 완료 콜백이 필요할 때

Props / Parameters#

W 는 Flutter의 Widget / Web의 Jaspr Component입니다. leading / trailing은 선택 콘텐츠 슬롯입니다.

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

팩토리 생성자#

입력 문자열만 변환하고 마스크 UI는 필요 없으면 Formatter 의 TextInputFormatters를 씁니다.

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

스타일 시스템 (Style System)#

FormattedInput 은 세그먼트별 박스가 아니라 하나의 연속된 입력 필드를 렌더링합니다. 마스크의 구분자(리터럴)는 그 필드가 가진 값 텍스트의 일부라 별도의 색·타이포를 갖지 않고 필드 자신의 타이포그래피를 따릅니다. 따라서 세그먼트 간격이나 세그먼트별 너비를 지정하는 필드는 없으며, 중첩 슬롯은 segmentInputStyle 하나뿐입니다 — 그 하나의 실제 필드에 그대로 raw-forward 됩니다.

CoreFormattedInputStyle 필드#

필드타입설명
backgroundColor CoreColor? Container background colour override.
borderColor CoreColor? Container border colour override.
hoverBorderColor CoreColor? Container border colour override while the pointer is over the container. null → [defaultHoverBorderColor]. Never applied while the container is focused (the focus border wins) or disabled.
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.
disabledForegroundColor CoreColor? Value / placeholder text colour while the field is disabled. null → [defaultDisabledForegroundColor].
focusBorderColor CoreColor? Container focus-ring border colour override.
borderRadius CoreBorderRadius? Container border radius override.
borderWidth double? Container border width override (logical px).
padding CoreEdgeInsets? Container padding override.
height double? Container height override (logical px). null → [defaultHeight], which moves with the density; a stated height stays as stated.
contentSpacing double? Gap between the leading / field / trailing children override (logical px). null → [defaultContentSpacing].
segmentInputStyle CoreTextFieldStyle? Editable-segment TextField style override (chrome + value / placeholder typography). Nested child-component slot forwarded to the single composed TextField on both platforms.

동작 스펙 (Behavior)#

상자#

  • 컨테이너가 테두리 · 면 · 포커스를 하나만 그립니다. 높이는 컨트롤 크기 사다리의 lg 행(48), 모서리 12, 좌우 패딩 14, leading · 입력 · trailing 사이 간격 8 로 TextField 와 한 줄에 놓입니다.
  • 쉬는 테두리는 outlineVariant, 포인터가 올라가면 outline, 포커스는 primary 입니다(disabled > focus > hover > rest).
  • Flutter 의 안쪽 입력은 TextField 의 plain 변형을 lg 행으로 합성합니다 — 자기 테두리와 면이 없어 상자 안에 상자가 생기지 않습니다. 값은 코드 역할, 마스크 자리표시는 본문 역할(bodySmall)로 그립니다.
  • 비활성은 불투명도가 아니라 면 disabledContainer, 테두리 disabledOutline, 글자와 자리표시 disabledContent 토큰으로 그립니다.

인터랙션#

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

폭#

  • 필드는 부모가 준 폭을 채웁니다. Flutter 와 Web 이 같고, TextField · Autocomplete 같은 다른 입력 필드와도 같습니다. leading / trailing 이 있으면 입력은 그 둘이 남긴 폭을 채웁니다.
  • 폭을 줄이려면 부모에 폭을 줍니다 — Flutter 는 SizedBox · ConstrainedBox, Web 은 감싼 요소의 폭(또는 css)입니다. 루트에 classes: 'w-64' 를 얹는 방식은 w-full 과 겹쳐 이기지 못할 수 있습니다.
  • Flutter 에서 폭이 정해지지 않은 Row 에 그대로 넣으면 TextField 와 마찬가지로 예외가 납니다 — Expanded 나 SizedBox 로 폭을 줍니다.

사용 가이드라인 (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은 attributes로 aria-label을 우회 전달할 수 있지만, Flutter에는 대응하는 탈출구가 없어 호출자가 직접 Semantics로 감싸야 합니다.

화면에 라벨 텍스트도 같이 보여야 한다면 Semantics 단독보다 아래 조합 예제의 FormField 로 감싸는 편이 낫습니다 — 시각적 라벨/설명과 동시에 양쪽 플랫폼 접근성 이름(Flutter Semantics, Web aria-labelledby)까지 한 번에 붙습니다.

접근성 (Accessibility)#

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

역할#

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 이벤트 + 테두리 색 변경
  • TextField: 자유 형식 텍스트 입력
  • InputOtp: OTP 코드 입력
  • FormField: 라벨 / 설명 / 에러 텍스트가 필요하면 이 컴포넌트로 감쌉니다 — FormattedInput 자신은 라벨 슬롯을 갖지 않습니다

조합 예제#

FormattedInput 은 라벨·설명 슬롯을 자체적으로 갖지 않습니다 — 그 둘이 필요하면 컴포넌트를 새로 확장하지 않고 FormField 로 감쌉니다. Checkbox/Toggle/RadioGroup/Select 가 이미 같은 방식으로 라벨/설명을 받습니다.

// 전화번호 인증 폼 패턴 — label/description 은 FormField 합성으로 받는다
Form(
  onSubmit: handleSubmit,
  children: [
    FormField(
      label: '전화번호',
      description: '인증번호를 받을 번호를 입력하세요.',
      required: true,
      child: FormattedInput.koreanPhone(
        onChanged: handlePhoneChanged,
      ),
    ),
    Button(
      variant: CoreButtonVariant.primary,
      onPressed: handleSubmit,
      child: Text('제출'),
    ),
  ],
)