TextField
텍스트 입력 필드 컴포넌트
목차
사용자로부터 텍스트를 입력받는 컴포넌트입니다. 다양한 타입과 상태를 지원합니다.
기본 사용법 (Basic Usage)#
// 기본 입력
TextField(
onChanged: handleNameChange,
placeholder: Text('이름을 입력하세요'),
)
// 비밀번호 입력 — 표시/숨김은 passwordToggle 기능이 맡는다
TextField(
obscureText: true,
features: [CoreInputFeature.passwordToggle()],
placeholder: Text('비밀번호'),
)
// 라벨 및 에러
TextField(
label: '이메일',
onChanged: handleEmailChange,
errorText: '올바른 이메일을 입력하세요',
)
// 기본 입력
TextField(
onChanged: handleNameChange,
placeholder: Text('이름을 입력하세요'),
)
// 비밀번호 입력 — 표시/숨김은 passwordToggle 기능이 맡는다
TextField(
obscureText: true,
features: [CoreInputFeature.passwordToggle()],
placeholder: Text('비밀번호'),
)
// 라벨 + 에러
TextField(
label: '이메일',
onChanged: handleEmailChange,
errorText: '올바른 이메일을 입력하세요',
)
Live Preview#
코드 보기
class TextFieldDefaultExample extends StatefulComponent {
const TextFieldDefaultExample({super.key});
@override
State<TextFieldDefaultExample> createState() =>
_TextFieldDefaultExampleState();
}
class _TextFieldDefaultExampleState extends State<TextFieldDefaultExample> {
@override
Component build(BuildContext context) {
return TextField(
placeholder: Text('Enter text...'),
);
}
}
class TextFieldDefaultExample extends StatefulWidget {
const TextFieldDefaultExample({super.key});
@override
State<TextFieldDefaultExample> createState() =>
_TextFieldDefaultExampleState();
}
class _TextFieldDefaultExampleState extends State<TextFieldDefaultExample> {
@override
Widget build(BuildContext context) {
return TextField(
placeholder: const Text('Enter text...'),
);
}
}
구조 (Anatomy)#
코드가 정본입니다. 부분 이름은 위젯 슬롯과 같고, Figma ❖ TextField
레이어와도 맞습니다.
| 부분 | 역할 | 슬롯 |
|---|---|---|
prefix | 입력 영역 앞 | prefix |
placeholder | 비어 있을 때 보이는 안내 | placeholder |
suffix | 입력 영역 뒤 | suffix |
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 사용자로부터 짧은 텍스트를 입력받을 때 (이름, 이메일, 비밀번호 등)
- 검색 필드가 필요할 때
- 숫자, 전화번호 등 특정 형식의 입력이 필요할 때
대신 다른 컴포넌트를 사용하세요:
TextArea: 여러 줄의 긴 텍스트를 입력받을 때Select: 미리 정해진 옵션 중 선택할 때Autocomplete: 입력하면서 추천 목록에서 선택할 때DatePicker: 날짜를 입력받을 때
Props / Parameters#
W는 Flutter의 Widget, Web의 Component를 뜻하는 합성 슬롯 타입입니다. 포커스·컨트롤러·포매터의 실제 타입 차이는 아래 런타임 인프라 표와 플랫폼 차이점에 구분합니다.
콘텐츠 / 상태#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
placeholder | W? | null | 플레이스홀더 |
initialValue |
String? |
null |
초기 값 (비제어) |
value | String? | null | 제어 값 |
onChanged |
ValueChanged<String>? |
null |
값 변경 콜백 |
onCaretChanged |
CoreValueChanged<int>? |
null |
값 변경 시 캐럿 위치를 onChanged보다 먼저 통보. 문자열의 code-unit offset |
onSubmitted |
ValueChanged<String>? |
null |
제출 콜백 |
enabled | bool | true | 활성화 여부 |
readOnly | bool | false | 읽기 전용 |
autofocus | bool | false | 자동 포커스 |
selectAllOnFocus |
bool |
false |
포커스 시 전체 선택 |
size |
CoreComponentSize |
md |
크기 토큰 (위젯 파라미터). xs 는 예고 중 — 표 아래 안내 |
variant |
CoreTextFieldVariant? |
null |
에러가 아닐 때의 chrome.
plain
= 무테(호스트가 경계를 그림).
filled
= 테두리 대신 채움(
surface-container
).
errorText
가 있고 활성(
enabled
)일 때만
error
— 비활성이면 에러가 있어도 이 값을 그대로 씁니다
|
textFieldStyle |
CoreTextFieldStyle? |
null |
chrome / dimensional / cursor / nested slot 단일 진입점 |
xs는 이르면 coui_core 0.153 부터sm으로 그려집니다 — 대신sm을 쓰세요. 그 전까지xs는 지금처럼 그려지고, assert 가 켜진 실행(디버그 빌드, Jaspr 정적 빌드의 사전 렌더)에서 로그 경고가 한 번 나옵니다 — 경고일 뿐 화면이나 빌드를 멈추지 않습니다.xs행이 지워진 뒤에는sm으로 그려지고, assert 가 켜진 실행은xs에서 멈춥니다.
라벨 / 검증#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
label | String? | null | 라벨 텍스트 |
labelMaxLines |
int? |
null |
라벨이 넘칠 때까지 허용하는 줄 수. null 은 현행대로 줄 수를 제한하지 않습니다 |
labelOverflow |
CoreTextOverflow? |
null |
labelMaxLines
에 도달한 라벨의 넘침 처리 (
clip
/
fade
/
ellipsis
/
visible
)
|
description |
String? |
null |
설명 텍스트 |
errorText |
String? |
null |
에러 메시지 (설정 시 자동 error variant) |
required | bool | false | 필수 입력 표시 |
name |
String? |
null |
폼 제출용 필드 이름 |
hintText |
String? |
null |
네이티브 힌트 텍스트 |
title |
String? |
null |
HTML title (검증 실패 안내) |
pattern | String? | null | 검증 정규식 |
minLength / maxLength |
int? |
null |
최소·최대 글자 수 |
min / max |
num? |
null |
숫자 입력 범위 |
텍스트 동작#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
type |
CoreInputType |
CoreInputType.defaultType |
HTML-semantic 입력 타입 |
obscureText |
bool |
false |
처음 마운트될 때의 마스킹 상태(seed). 이후 값을 바꿔도 반영되지 않는다 — 표시/숨김은
features
의
passwordToggle
, 다시 시딩하려면 새
key
|
maxLines |
int? |
현재 제약 참고 | 최대 줄 수. 같은 호출 의도에는 값을 명시 |
minLines | int? | null | 최소 줄 수 |
expands |
bool |
false |
부모 높이만큼 확장 |
autocorrect |
bool? |
null (→ true) |
자동 교정 |
keyboardType |
TextInputType? / String? |
null |
키보드 타입 (Web → inputmode) |
textInputAction |
TextInputAction? / String? |
null |
키보드 액션 키 (Web → enterkeyhint) |
textCapitalization |
TextCapitalization / String? |
none / null |
자동 대문자화 |
textDirection |
TextDirection? / String? |
null |
읽기 방향 |
autofillHints |
Iterable<String> / Iterable<String>? |
const [] / null |
자동완성 힌트 (Web → autocomplete) |
inputFormatters |
List<TextInputFormatter>? / List<CoreInputFormatter>? |
null |
입력 포매터 |
submitFormatters | 동일 타입 | const [] | 제출 시점 포매터 |
showCursor | bool? | null | 커서 표시 |
슬롯 / 어포던스 / 콜백#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
prefix / suffix |
W? |
null |
입력 영역 좌/우 슬롯. 아이콘도 글자(단위·카운트·타이머)도 받는다 — 글자는 필드의 사이즈별 base 타이포를 affixColor 로 입는다 |
features |
List<CoreInputFeature> |
const [] |
clear / password-toggle 어포던스 |
clearButtonSemanticLabel |
String? |
null |
clear 버튼 접근성 이름 |
onTap |
VoidCallback? |
null |
탭 콜백 |
onEditingComplete |
VoidCallback? |
null |
편집 완료 콜백 |
onTapOutside / onTapUpOutside |
TapRegionCallback? · TapRegionUpCallback? / void Function()? |
null |
필드 밖 포인터-다운 · 포인터-업 콜백 |
focusNode |
FocusNode? / CoreFocusNode? |
null |
포커스 노드 |
statesController |
WidgetStatesController? / CoreStatesController? |
null |
필드가 disabled · focused · hovered · error 상태를 바뀔 때마다 써 넣는다 (Web 은 CSS 상태가 대신한다) |
Flutter 전용 확장#
Web 은 이 capability 들을 브라우저 native <input> · DOM focus/scroll ·
네이티브 컨텍스트 메뉴/확대경으로 제공합니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
controller |
TextEditingController? |
null |
텍스트 컨트롤러 |
obscuringCharacter |
String |
'•' |
마스킹 문자 |
cursorOpacityAnimates |
bool |
true |
커서 깜빡임 애니메이션 |
enableSuggestions |
bool |
true |
입력 제안 |
enableInteractiveSelection |
bool |
!readOnly || !obscureText |
선택 제스처 허용 |
enableIMEPersonalizedLearning |
bool |
true |
IME 개인화 학습 |
stylusHandwritingEnabled |
bool |
EditableText.defaultStylusHandwritingEnabled |
스타일러스 필기 입력 |
maxLengthEnforcement |
MaxLengthEnforcement? |
null |
길이 제한 적용 방식 |
keyboardAppearance |
Brightness? |
null |
키보드 밝기 |
scrollController |
ScrollController? |
null |
스크롤 컨트롤러 |
scrollPhysics |
ScrollPhysics? |
null |
스크롤 물리 |
selectionControls |
TextSelectionControls? |
null |
선택 핸들 UI |
selectionHeightStyle / selectionWidthStyle |
BoxHeightStyle / BoxWidthStyle |
tight |
선택 영역 박스 |
dragStartBehavior |
DragStartBehavior |
start |
드래그 시작 기준 |
clipBehavior |
Clip |
hardEdge |
클리핑 |
restorationId |
String? |
null |
상태 복원 ID |
contentInsertionConfiguration |
ContentInsertionConfiguration? |
null |
리치 콘텐츠 삽입 |
contextMenuBuilder |
EditableTextContextMenuBuilder? |
null |
컨텍스트 메뉴 |
magnifierConfiguration |
TextMagnifierConfiguration? |
null (→ 플랫폼 확대경) |
터치로 선택을 끌 때 보이는 확대경. 끄려면 TextMagnifierConfiguration.disabled |
spellCheckConfiguration |
SpellCheckConfiguration? |
null |
스펠 체크 |
undoController |
UndoHistoryController? |
null |
undo 히스토리 |
strutStyle |
StrutStyle? |
null |
strut 메트릭 |
skipInputFeatureFocusTraversal |
bool |
true |
어포던스 버튼 포커스 순회 제외 |
groupId |
Object |
EditableText |
tap-region 그룹 ID |
빠른 오버라이드 (Chain)#
이미 만든 TextField 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius16처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius16 ==
CoreRadius.radius16) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class TextFieldChainExample extends StatefulWidget {
const TextFieldChainExample({super.key});
@override
State<TextFieldChainExample> createState() => _TextFieldChainExampleState();
}
class _TextFieldChainExampleState extends State<TextFieldChainExample> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
TextField(placeholder: const Text('Enter text...')).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
TextField(placeholder: const Text('Enter text...')).withStyle(
const CoreTextFieldStyle(
borderColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
),
),
],
);
}
}
class TextFieldChainExample extends StatefulComponent {
const TextFieldChainExample({super.key});
@override
State<TextFieldChainExample> createState() => _TextFieldChainExampleState();
}
class _TextFieldChainExampleState extends State<TextFieldChainExample> {
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
TextField(placeholder: Text('Enter text...')).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderWidth)까지 한 번에.
TextField(placeholder: Text('Enter text...')).withStyle(
const CoreTextFieldStyle(
borderColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
),
),
],
classes: 'flex flex-col items-start',
);
}
}
스타일 시스템 (Style System)#
textFieldStyle 슬롯#
TextField 의 chrome / dimensional / cursor / 슬롯 텍스트 미세 조정은 단일
textFieldStyle 필드(CoreTextFieldStyle) 하나로 흐릅니다.
TextField(
size: CoreComponentSize.md, // ← 크기 토큰 위젯 파라미터
onChanged: handleEmailChange,
textFieldStyle: CoreTextFieldStyle( // ← chrome / 슬롯 단일 진입점
borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
padding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space16,
vertical: CoreSpace.space12,
),
focusBorderColor: CoreColor.token(CoreColors.primary),
cursorColor: CoreColor.token(CoreColors.primary),
valueStyle: CoreTextStyle.token(CoreTextStyles.bodyMedium),
placeholderStyle: CoreTextStyle.token(
CoreTextStyles.bodyMedium,
color: CoreColor.token(CoreColors.onSurfaceVariant),
),
leadingIconStyle: CoreIconStyle(size: CoreSize.size16),
),
prefix: Icon(LucideIcons.mail),
label: '이메일',
)
CoreTextFieldStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
backgroundColor |
CoreColor? |
Background fill colour override. |
borderColor |
CoreColor? |
Border stroke colour override (idle state). |
focusBorderColor |
CoreColor? |
Border stroke colour when the field is focused. |
hoverBorderColor |
CoreColor? |
Border stroke colour while the pointer is over the field.
null
→ the variant's
hoverBorderColor
(
on-surface
on the ordinary field,
error
on the error one).
|
stateLayer |
CoreStateLayer? |
The wash laid over the fill while hovered / focused / pressed.
null
→ the variant's own ramp (
CoreTextFieldVariantStyle.stateLayer
). A composing parent states its own ramp here —
Select
's trigger is pressed-only, because its hover is a border step and its focus is the ring.
|
errorBorderColor |
CoreColor? |
Border stroke colour when the field is in error state. |
borderWidth |
double? |
Border stroke width override (logical px, pre-scaling) — the resting boundary, which stays in the layout.
null
→ [defaultBorderWidth].
|
activeBorderWidth |
double? |
Width of the active boundary (logical px, pre-scaling) — a focused field, or one wearing the error variant while enabled (
disabled > error
).
null
→ [defaultActiveBorderWidth]. The resting boundary ([borderWidth]) stays where it is and only the part of this width beyond it is added, over the box and outside the layout: a wider boundary never moves the content or the field's own height. A width at or below [borderWidth] adds nothing, so a caller that states only a thicker [borderWidth] is not thinned on focus. The bare
plain
variant keeps its resting boundary in every state.
|
borderRadius |
CoreBorderRadius? |
Border radius override (pre-scaling).
null
→ the size row's corner (
defaultsBySize[size].borderRadius
, the control height × 0.25).
|
height |
double? |
Fixed height override (logical px). Stating it fixes the box and drops the size row's floor ([minHeight]). No
default*
, deliberately: a default fixed height would pin every field — the chromeless ones inside other components' frames included — and stop large text from growing the box, so the text would paint through the border. Keeping the box at the control height is [minHeight]'s job.
|
minHeight |
double? |
Minimum height of the box (logical px).
null
defers to the size row's control height in [defaultsBySize];
0
states no floor at all — what a chromeless composition (a field inside another component's frame) says. See [boxMinHeight].
|
maxHeight |
double? |
Maximum height constraint (logical px). No
default*
, deliberately: a value here caps every field. A caller cap below the size row's floor wins over that floor ([boxMinHeight]).
|
padding |
CoreEdgeInsets? |
Padding override (logical px). |
labelPadding |
CoreEdgeInsets? |
Padding applied to the label element. null defers to [defaultLabelPadding]. |
descriptionPadding |
CoreEdgeInsets? |
Padding applied to the description / error helper text element.
null
defers to [defaultDescriptionPadding].
|
rowSpacing |
double? |
Native
Row.spacing
between the prefix / input / suffix row children (logical px).
null
defers to [defaultRowSpacing].
|
iconStyle |
CoreIconStyle? |
Nested [CoreIconStyle] slot shared as the per-size base for the leading / trailing icon affixes. Raw forwarded to the affix
Icon
resolvers —
Icon
's own resolver folds in scaling.
null
defers to
CoreTextFieldStyle.defaultsBySize[size]!.iconStyle!
.
|
fileLabelFontWeight |
int? |
Font weight applied to the file-upload
<input type="file">
label segment.
null
defers to [defaultFileLabelFontWeight].
|
transitionDuration |
Duration? |
Chrome transition duration override. null defers to [defaultTransitionDuration]. |
textAlign |
CoreTextAlign? |
Horizontal text alignment override. null defers to [defaultTextAlign]. |
textAlignVertical |
CoreVerticalAlign? |
Vertical alignment of the text within the input.
null
defers to [defaultTextAlignVertical].
|
crossAxisAlignment |
CoreVerticalAlign? |
Vertical alignment override for the prefix / input / suffix row.
null
defers to [defaultCrossAxisAlignment].
|
cursorColor | CoreColor? | Cursor colour override. |
cursorWidth |
double? |
Cursor width override (logical px). |
cursorHeight |
double? |
Cursor height override (logical px before text scaling). Null preserves the platform's default caret height. Flutter scales this with accessibility text scaling. Web exposes the value to tooling as
--coui-caret-height
; native HTML input caret geometry is browser-drawn. A fixed kit default would override the platform caret geometry and its text-size-dependent height. The actual default is determined by the host.
|
cursorRadius |
CoreBorderRadius? |
Cursor corner radius override. |
labelDisabledColor |
CoreColor? |
Disabled-state label text colour override. null → [defaultLabelDisabledColor]. |
descriptionColor |
CoreColor? |
Description / helper text colour override. null → [defaultDescriptionColor]. |
affixColor |
CoreColor? |
Prefix / suffix affix glyph colour override. null → [defaultAffixColor]. |
errorColor |
CoreColor? |
Error helper-text colour override. null → [defaultErrorColor]. |
selectionColor |
CoreColor? |
Text-selection tint colour override. null → [defaultSelectionColor]. |
backgroundCursorColor |
CoreColor? |
Background-cursor (composition/IME) colour override.
null
→ [defaultBackgroundCursorColor].
|
focusOutlineStyle |
CoreFocusOutlineStyle? |
Focus-outline style override. Nested [CoreFocusOutlineStyle] covers ring colour / radius / width / offset / offset-background.
null
(or any unset field on the slot) defers to [defaultFocusOutlineStyle]. A
borderColor
stated here outranks [focusBorderColor] and the variant's focus-ring tone; a
borderRadius
stated here outranks the field's own [borderRadius].
|
baseTextStyle |
CoreTextStyle? |
Base text style applied to the value / placeholder / affix slots before each one's own style override merges on top.
null
defers to
CoreTextFieldStyle.defaultsBySize[size]!.baseTextStyle!
(size-aware —
bodyMedium
for xs/sm/md/lg,
bodyLarge
for xl/xl2, each at the role's own line height). Does NOT feed the label (a composed
FormLabel
owns that ladder on its own split point — see [labelStyle]) or description / error (size-invariant, [defaultDescriptionStyle] / [defaultErrorStyle]).
|
labelStyle |
CoreTextStyle? |
Label text style override — forwarded whole into the composed
FormLabel
's own
formLabelStyle.labelStyle
slot (child-component- composition). By design has no
default
of its own here:
TextField
never merges this onto its own [baseTextStyle], because
FormLabel
owns the label's per-size ladder on its own split point (
CoreFormLabelStyle.defaultsBySize
).
null
→
FormLabel
's own size-keyed default.
|
placeholderStyle |
CoreTextStyle? |
Placeholder text style override. By design has no
default
of its own — both resolvers merge it onto [baseTextStyle] (
baseTextStyle.merge(merged.placeholderStyle)
), the same per-size base the raw input value shares, so an unset override reads at the field's own typography rather than a second, independently-tracked default that could drift from it.
|
valueStyle |
CoreTextStyle? |
Input value text style override. Same shape as [placeholderStyle], by design with no
default
of its own — both resolvers merge it onto [baseTextStyle] (
baseTextStyle.merge(merged.valueStyle)
), keeping the caret's line box and the value text on the one per-size base they share.
|
descriptionStyle |
CoreTextStyle? |
Description / helper text style override. |
errorStyle |
CoreTextStyle? |
Error text style override. |
leadingIconStyle |
CoreIconStyle? |
Leading icon style override. No
default*
, and one would be actively wrong: the base for both affix icons already lives in Core as
defaultsBySize[size].iconStyle
, and these two are per-affix overrides layered on it. Both resolvers pick the icon size by precedence —
text merged.leadingIconStyle?.size ?? merged.trailingIconStyle?.size ?? merged.iconStyle?.size ?? defaultsBySize[size].iconStyle.size
— so a
static const
sitting at the head of that chain would win over the shared [iconStyle] slot AND over the size table, pinning one icon size across every size tier. Being unset is what makes the two lower rungs reachable.
|
trailingIconStyle |
CoreIconStyle? |
Trailing icon style override. No
default*
— second rung of the precedence chain documented on [leadingIconStyle]; a value here would shadow the shared [iconStyle] slot and the size table.
|
featureButtonStyle |
CoreButtonStyle? |
Nested [CoreButtonStyle] slot for the input-feature affordance buttons (clear / password toggle) — raw-forwarded to
Button(variant:.plain, buttonStyle: …)
on both platforms. Recursively merged onto [defaultFeatureButtonStyle], then the size row's own
featureButtonStyle
(the row's corner), then this override (child-component-composition).
|
Resolve chain#
variant 는 CoreTextFieldVariant.of(hasError:, enabled:, variant:) 가 정합니다.
우선순위는 disabled > error 입니다 — errorText 가 있고 필드가 활성일 때만
error, 그 밖에는 위젯의 variant(null → defaultVariant). 비활성 필드는
에러가 남아 있어도 비활성으로 먼저 보여야 하므로 테두리·채움 같은 박스 chrome
에 error 행을 쓰지 않습니다. 에러 메시지는 콘텐츠라서 비활성이어도 그대로
표시되고, 그 스타일(errorStyle / errorColor)은 계속 variantStyles[error]
행에서 읽습니다. plain 은 테두리·채움이 없는 무테 필드로, 다른 표면 안에 들어가는
입력(Command 검색 행 등)이 씁니다. 그 키로 variantStyles 가 조회됩니다.
design system default for variant (defaultsByVariant[variant] / defaultsBySize[size])
→ CoreTextFieldTheme.style // 프로젝트 공통
→ CoreTextFieldTheme.variantStyles[variant] // CoreTextFieldVariant.of 가 고른 variant 별
→ 부모 컴포넌트 슬롯 오버라이드
→ widget.textFieldStyle // 인스턴스별
nested slot styles 는 재귀 머지 — valueStyle.fontWeight 만 override 해도
valueStyle.fontSize / valueStyle.color 는 이전 레이어 값 유지.
변형 (Variants)#
variant는 errorText 와 enabled 로 자동 결정됩니다:
errorText가 null이면 → 기본 스타일errorText가 설정되고 활성이면 → 에러 스타일 (빨간 보더)- 비활성이면 →
errorText가 있어도 비활성 스타일이 이깁니다 (에러 메시지는 표시)
테두리는 쉴 때 outline-variant, 마우스를 올리면 outline, 포커스되면 primary 입니다. 굵기는 모든 상태에서 1 이고, 포커스와 에러는 색으로만 말합니다. 바깥 포커스 링은
키보드로 포커스했을 때만 그립니다 — 마우스·터치로 누른 포커스는 primary 테두리가 이미 말합니다. 비활성 필드는 값과 placeholder 를 모두
disabled-content 로 그립니다.
filled 변형은 테두리 대신 채움으로 경계를 말합니다 — 카드·시트처럼 이미 틀이 있는 면 위에 둘 때 씁니다. 바탕 surface-container, hover
surface-container-high, 포커스 primary 테두리, 비활성 surface-container-low
입니다.
// 기본 상태
TextField(
placeholder: Text('이름을 입력하세요'),
)
// 에러 상태 (errorText 설정 시 자동)
TextField(
errorText: '필수 입력 항목입니다',
placeholder: Text('이름을 입력하세요'),
)
// 비활성화
TextField(
enabled: false,
placeholder: Text('수정 불가'),
)
// 채움 변형 — 카드 · 시트 위에서
TextField(
variant: CoreTextFieldVariant.filled,
placeholder: Text('검색'),
)
// 읽기 전용
TextField(
readOnly: true,
initialValue: '읽기 전용 값',
)
동작 스펙 (Behavior)#
포커스 관리#
- 클릭 또는 Tab 키로 포커스 획득 시 테두리 색상 변경
-
포커스와 에러의 테두리 굵기는
activeBorderWidth(기본 1 — 쉬는 테두리와 같음)입니다. 더 굵게 적으면 쉬는 테두리(borderWidth, 기본 1)는 레이아웃 안에 그대로 두고 늘어난 폭만 안쪽에 덧그리므로 (Flutter 는foregroundDecoration, Web 은 차이만큼의 inset 그림자 — 실제border도 남아 강제 색 모드에서 경계가 살아 있습니다) 내용과 필드 높이는 움직이지 않습니다.borderWidth만 더 굵게 쓰면(예:stroke2) 포커스에서 얇아지지 않고 그 폭을 유지합니다.plain은 모든 상태에서 쉬는 테두리 그대로입니다. autofocus: true로 페이지 로드 시 자동 포커스
상태 전환#
-
default→focused(포커스 획득) →filled(값 입력) →default(포커스 해제) error:errorText설정 시error색 테두리와 에러 메시지 표시disabled: 모든 인터랙션 비활성화, 흐리게 표시readOnly: 포커스는 가능하나 편집 불가
글자 색 단계#
필드가 그리는 글자는 세 단계입니다. 값이 가장 크게 읽히고, 라벨은 한 단계 아래, 도움말과 붙임(앞·뒤 아이콘과 붙임 글자)은 가장 조용합니다.
| 글자 | 색 | 슬롯 |
|---|---|---|
| 입력한 값 | onSurface | 변형 행의 foregroundColor |
| 라벨 | onSurfaceVariant |
FormLabel 의 기본 (필드는 색을 덮지 않습니다) |
| 도움말 · 앞·뒤 아이콘 · 붙임 글자 | onSurfaceSubtle |
descriptionColor · affixColor |
| 비활성 라벨 | onSurfaceSubtle | labelDisabledColor |
| 비활성 값 | onSurfaceVariant |
변형 행의 disabledForegroundColor |
활성 라벨이 이미 onSurfaceVariant 이므로 비활성 라벨을 같은 색에 두면 활성과 구분되지
않습니다. 한 단계 더 내려 onSurfaceSubtle 로 두면 읽을 수 있는 글자 단계에 머물면서
꺼진 것으로 읽힙니다 (그래픽 전용 disabledContent 는 쓰지 않습니다). 붙임은 상태가
바뀌어도 색이 같습니다 — 비활성은 값 글자와 테두리가 말합니다. 이 단계는
TextArea 와 같고, FormField 의 비활성 라벨 덮어쓰기도 같은 단계입니다.
FormField / Fieldset 안에서#
TextField 는 감싸는 FormField 나 Fieldset 이 진술한 비활성·에러 상태를
FormFieldScope 로 받아 자기 chrome 에 합칩니다.
| 자기 값 | 감싸는 쪽 | 결과 |
|---|---|---|
enabled: true |
Fieldset(enabled: false) / FormField(enabled: false) |
비활성 (AND — 바깥 제약이 이김) |
errorText: null |
FormField(error: '...') 또는 검증 실패 |
에러 톤 (OR) |
errorText: '...' | 에러 없음 | 에러 톤 (자기 것 유지) |
에러는 톤만 합쳐집니다 — 메시지는 FormField 가 푸터에 그리고, TextField
는 자기 errorText 가 있을 때만 자기 메시지를 그립니다. 감싸는 쪽이 없으면
동작은 단독 사용과 완전히 같습니다.
TextField 를 합성하는 입력(Select 트리거 · Autocomplete · ChipInput
등)도
그 TextField 를 통해 같은 합성을 받습니다. 스코프는 감싼 서브트리 안에서만
흐르므로, 필드에서 연 팝오버·다이얼로그 안의 TextField 는 받지 않습니다.
FormField 안에서 — 값 보고#
FormField 안에서 TextField 는 자기 텍스트를 감싼 필드에 보고합니다
(Checkbox · Toggle · RadioGroup 과 같은 방식).
Form 의
FormController 가 그 값을 formKey 로 모으고 validator
로 검증합니다.
| 상황 | 보고하는 값 |
|---|---|
| 마운트 | 초기 텍스트 (initialValue · 제어 value · controller.text) |
타이핑 · 컨트롤러 쓰기 · 제어 value 변경 · 지우기 버튼 | 새 텍스트 |
| 텍스트가 비어 있음 |
null
— 아직 답하지 않은 필드.
RequiredValidator
는
null
과 빈 문자열을 모두 누락으로 봅니다
|
enabled: false · readOnly: true | 가진 값 그대로 (막히는 것은 편집뿐) |
-
검증기가
ReplaceResult를 돌려주면 텍스트가 그 값으로 바뀌고onChanged가 새 텍스트로 한 번 더 불립니다 — 예: 앞뒤 공백 제거. Flutter 는 커서를 새 텍스트 안에 그대로 둡니다. FormField밖의TextField는 아무것도 보고하지 않습니다 — 동작은 단독 사용과 완전히 같습니다.-
다른 컴포넌트가 내부 부품으로 합성한
TextField(PhoneInput의 번호 입력 ·Select트리거 ·ChipInput의 입력 등)와, 필드에서 연 팝오버·다이얼로그 안의TextField는 보고하지 않습니다 — 그 필드의 값은 합성한 컴포넌트의 것이지 부품 텍스트가 아닙니다. 직접 만든 합성 컴포넌트가TextField를 부품으로 쓴다면 Flutter 는Data<FormFieldHandle>.boundary, Web 은FormFieldHandleScope.boundary로 감싸세요.
사용 가이드라인 (Usage Guidelines)#
라벨·오류·prefix/suffix·clear/password 어포던스를 포함한 입력에는 TextField를 사용합니다. 여러 줄과 드래그 리사이즈는 TextArea, 분할 필드 입력은 ObjectInput, 국가 코드와 번호를 함께 받으려면 PhoneInput을 선택합니다. 미리 정한 옵션만 허용할 때는 Select를 사용합니다.
편집 없이 선택·복사만 제공하면 SelectableText를 사용합니다.
✅ Do#
항상 라벨과 함께 사용하세요.
TextField(
label: '이메일 주소',
placeholder: Text('user@example.com'),
)
라벨은 입력 필드의 용도를 명확히 하고, 접근성을 향상시킵니다.
❌ Don't#
placeholder만으로 라벨을 대체하지 마세요.
TextField(
placeholder: Text('이메일 주소'),
)
입력을 시작하면 placeholder가 사라져 필드 용도를 알 수 없게 됩니다.
✅ Do#
에러 메시지는 구체적으로 작성하세요.
TextField(
errorText: '이메일 형식이 올바르지 않습니다 (예: user@example.com)',
)
사용자가 무엇을 수정해야 하는지 바로 알 수 있습니다.
❌ Don't#
모호한 에러 메시지를 사용하지 마세요.
TextField(
errorText: '입력이 올바르지 않습니다',
)
사용자가 무엇을 고쳐야 하는지 알 수 없습니다.
접근성 (Accessibility)#
역할#
-
Flutter — 별도 role 플래그를 세우지 않고
EditableText가 만드는 텍스트 필드 노드를 그대로 씁니다. 그 노드의 가장 안쪽에Semantics(enabled:, onFocus:)가 병합되어 활성 상태와 포커스 액션을 진술합니다. 입력 어포던스(비밀번호 토글 · 지우기)만Semantics(button: true, label:)로 감싸집니다. -
Web — 편집 표면이 네이티브
<input>이라 암묵 역할(textbox,type="password"면 비밀번호 입력)을 그대로 받습니다.<input>에 붙는 명시적role/aria-*는 하나도 없고, 보조기술에 닿는 것은type·disabled·readonly·required·placeholder·title같은 네이티브 속성뿐입니다. 어포던스<button>에만aria-label이 실립니다. 루트<div>·<label>· 설명/오류<p>에는 역할이 없습니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Tab / Shift+Tab | 필드로 / 필드에서 포커스 이동 | 같음 |
Enter |
maxLines 가 1(기본)이면 onSubmitted 호출, 2 이상이면 줄바꿈 삽입 |
항상 onSubmitted 호출 — <input> 은 단일 행입니다. IME 조합 중에 올라온 Enter 는 무시합니다 |
텍스트 편집 키(화살표 · Home/End · Shift 선택 · 복사/붙여넣기) |
EditableText 가 주는 기본 편집 단축키 |
브라우저 <input> 네이티브 |
Escape 는 어느 플랫폼에서도 처리되지 않습니다 — 포커스를 놓거나 값을 되돌리는
핸들러가 양쪽 모두 없습니다.
스크린 리더#
-
Flutter: 편집 표면의 시맨틱 노드가 활성 상태(
enabled)와SemanticsAction.focus핸들러를 진술합니다. 웹 엔진은 접근성이 켜지면 포인터 이벤트가 아니라 이 focus 액션으로 편집 세션을 열므로, 그 경로가 이 배선으로 성립합니다. 도달 범위는 여기까지입니다.- 노드가 내놓는 액션은
focus하나이고tap은 없습니다. 따라서 모바일 보조기술의 활성화 제스처가 보내는SemanticsAction.tap은 커버되지 않습니다. label:·errorText:는 시각 표시일 뿐 이 노드에 닿지 않습니다 — 노드의label은 빈 문자열입니다.
- 노드가 내놓는 액션은
-
Web:
<input>의 네이티브 시맨틱을 그대로 씁니다. 이름은placeholder(=hintText:또는placeholder:)나title:에서 오는데, 둘 다 이름 계산의 폴백이라 브라우저·보조기술마다 다르게 취급될 수 있습니다.label:은 필드에 연결되지 않습니다(→ 알려진 제약). -
값 변경도 오류 발생도 통보되지 않습니다.
errorText:가 나타나거나 사라지는 것을 알리는 live region 이 양 플랫폼 모두 없습니다.
포커스 관리#
-
진입 — 오버레이가 아니므로 열림에 따른 포커스 이동이 없습니다.
autofocus: true를 주면 최초 빌드에서 필드가 포커스를 가져갑니다(Flutter 는EditableText.autofocus, Web 은<input autofocus>). 기본값은false입니다. - 이탈 — 없음. 포커스를 다른 곳으로 되돌리는 코드가 양 플랫폼 모두 없습니다.
-
트랩 — 없음.
Tab이 필드를 그대로 통과합니다. Flutter 는 필드 다음 tab stop 이 입력 어포던스로 이어지지만 Web 은 어포던스가 tab 순서에서 빠집니다(→ 알려진 제약). -
selectAllOnFocus: true(기본false)를 주면 포커스가 들어오는 순간 값 전체가 선택됩니다 — 양 플랫폼 동일합니다.
알려진 제약#
-
Web 은
label:을<input>에 연결하지 않습니다.<label>요소가 그려지긴 하지만for속성이 없고<input>을 감싸지도 않아, 라벨 텍스트가 필드의 이름이 되지 않고 라벨을 눌러도 포커스가 옮겨가지 않습니다.description:/errorText:도 형제<p>로 그려질 뿐aria-describedby로 참조되지 않고, 오류 상태에aria-invalid도 붙지 않습니다. 호출부가 넘긴attributes는 루트<div>에 실리므로<input>에 직접 aria 를 얹을 수도 없습니다 — 이름이 필요하면title:로<input>의title속성을 채우세요 (툴팁으로도 보입니다). -
입력 어포던스(비밀번호 토글 · 지우기)는 Web 에서 키보드로 닿지 않습니다. Web 은 그
버튼에
tabindex="-1"을 고정으로 박고, 이를 끄는 것처럼 보이는skipInputFeatureFocusTraversal은 Web 에서=> true로 하드코딩된 getter 라 값을 바꿀 수 없습니다. Flutter 에서는 같은 필드가 생성자에 선언만 되어 있고build가 읽지 않아 어포던스가 항상 포커스를 받으므로, 같은 코드가 플랫폼에 따라 다르게 동작합니다. 우회할 방법은 없습니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 편집 표면 | EditableText와 TextEditingController | native <input>과 DOM 값/selection |
| 포커스·상태 | FocusNode / WidgetStatesController | CoreFocusNode / CoreStatesController를 DOM 이벤트에 연결 |
| 입력 포매터 | TextInputFormatter 목록 | CoreInputFormatter 목록을 input 이벤트에 적용 |
| 제출 | 편집기 submit/action 경로 | DOM Enter/편집 완료 경로 |
| 주변 폼 값 | Data의 FormFieldHandle 경계 | FormFieldHandleScope 경계 |
| 키보드/편집 UI 인프라 | TextInputType·TextInputAction·selectionControls 등 | inputmode·enterkeyhint와 브라우저 편집 UI |
현재 기본값 제약과 우회#
현재 Flutter 생성자는 maxLines 생략 시 CoreTextFieldContract.defaultMaxLines(1)을 사용하지만 Web 생성자는 null을 받습니다. 지원되는 플랫폼별 정책이 아니라 기본값 대칭의 코드 갭입니다. 한 줄 입력 의도를 고정하려면 maxLines: 1을 명시하세요. Web의 native input은 여러 줄 표면이 아니므로 실제 다중 줄 편집은 TextArea로 구성합니다.