FormattedInput#
전화번호·신용카드·날짜처럼 고정된 형식을 가진 데이터를 입력받는 마스크 입력 컴포넌트입니다. 정적 구분자와 편집 가능한 자리가 [parts]로 정의되지만, 화면에는
하나의 실제 입력 필드만 렌더링됩니다 — 리터럴((, ), /, -)은 입력하는 동안 자동으로 끼워지고, Backspace는 커서 위치와 무관하게 자연스럽게 앞 숫자를 지웁니다. 하나의 진짜 필드라 브라우저 자동완성·OS 자동완성·비밀번호 관리자 확장(1Password 등)이 일반 텍스트 필드와 동일하게 인식하고 채울 수 있습니다.
.phone / .koreanPhone / .creditCard / .date 팩토리로 흔한 마스크를 바로 사용할 수 있습니다.
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'),
);
}
}
class FormattedInputKoreanPhoneExample extends StatelessComponent {
const FormattedInputKoreanPhoneExample({super.key});
@override
Component build(BuildContext context) {
return FormattedInput.koreanPhone(
onChanged: (value) => print('Phone: $value'),
);
}
}
class FormattedInputKoreanPhoneExample extends StatelessWidget {
const FormattedInputKoreanPhoneExample({super.key});
@override
Widget build(BuildContext context) {
return FormattedInput.koreanPhone(
onChanged: (value) => debugPrint('Phone: $value'),
);
}
}
class FormattedInputCreditCardExample extends StatelessComponent {
const FormattedInputCreditCardExample({super.key});
@override
Component build(BuildContext context) {
return FormattedInput.creditCard(
onChanged: (value) => print('Card: $value'),
);
}
}
class FormattedInputCreditCardExample extends StatelessWidget {
const FormattedInputCreditCardExample({super.key});
@override
Widget build(BuildContext context) {
return FormattedInput.creditCard(
onChanged: (value) => debugPrint('Card: $value'),
);
}
}
class FormattedInputDateExample extends StatelessComponent {
const FormattedInputDateExample({super.key});
@override
Component build(BuildContext context) {
return FormattedInput.date(
onChanged: (value) => print('Date: $value'),
);
}
}
class FormattedInputDateExample extends StatelessWidget {
const FormattedInputDateExample({super.key});
@override
Widget build(BuildContext context) {
return FormattedInput.date(
onChanged: (value) => debugPrint('Date: $value'),
);
}
}
class FormattedInputVerifyExample extends StatelessComponent {
const FormattedInputVerifyExample({super.key});
@override
Component build(BuildContext context) {
return FormattedInput.koreanPhone(
onChanged: (value) => print('Phone: $value'),
trailing: Button(
onPressed: () => print('Send verification code'),
variant: .link,
size: .sm,
child: Text('인증번호 보내기').labelMedium,
),
);
}
}
class FormattedInputVerifyExample extends StatelessWidget {
const FormattedInputVerifyExample({super.key});
@override
Widget build(BuildContext context) {
return FormattedInput.koreanPhone(
onChanged: (value) => debugPrint('Phone: $value'),
trailing: Button(
onPressed: () => debugPrint('Send verification code'),
variant: .link,
size: .sm,
child: Text('인증번호 보내기').labelMedium,
),
);
}
}
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',
);
}
}
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,
),
),
],
);
}
}
사용 시기 (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 |
초기 값 (편집 자리들을 이어붙인 문자열) |
enabled | bool | true | 상호작용 가능 여부 |
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이 지정된 자리는 일치하는 문자만 입력을 허용합니다. enabled가false이면 비활성.
사용 가이드라인 (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로 감싸야 합니다.
접근성 (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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | FormattedInput | FormattedInput |
| 렌더링 필드 | 단일 TextField | 단일 native <input> |
| 포커스 링 | FocusNode + 테두리 색 변경 |
focus / blur 이벤트 + 테두리 색 변경 |