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#
코드 보기
class FormattedInputDefaultExample extends StatelessComponent {
const FormattedInputDefaultExample({super.key});
@override
Component build(BuildContext context) {
return FormattedInput.phone(
onChanged: (value) => print('Phone: $value'),
);
}
}
class FormattedInputDefaultExample extends StatelessWidget {
const FormattedInputDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return FormattedInput.phone(
onChanged: (value) => debugPrint('Phone: $value'),
);
}
}
사용 시기 (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 값. 초기값과 외부 갱신에 사용 |
enabled | bool | true | 상호작용 가능 여부 |
onChanged |
ValueChanged<String>? |
null |
값 변경 콜백 |
leading | W? | null | 필드 앞 콘텐츠 |
trailing | W? | 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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | FormattedInput | FormattedInput |
| 렌더링 필드 | 단일 TextField | 단일 native <input> |
| 포커스 링 | FocusNode + 테두리 색 변경 |
focus / blur 이벤트 + 테두리 색 변경 |
관련 컴포넌트 (Related Components)#
- 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('제출'),
),
],
)