InputOtp
OTP 인증 코드를 입력하는 분리된 입력 필드 컴포넌트
목차
OTP(일회용 비밀번호) 인증 코드를 자리수별로 분리된 입력 필드로 입력하는 컴포넌트입니다. Flutter/Web 양쪽에서 동일한 API(InputOtp)를 제공합니다.
기본 사용법 (Basic Usage)#
Flutter와 Web 모두 동일한 InputOtp 클래스를 사용합니다.
// 6자리 OTP (3자리마다 구분선)
InputOtp(
length: 6,
onCompleted: (code) => print(code),
)
// PIN 입력 (4자리, 구분선 없음, 입력값 숨김)
InputOtp(
length: 4,
obscured: true,
separatorInterval: 0,
onCompleted: handlePinCompleted,
)
// 초기값 복원
InputOtp(
length: 6,
initialValue: [49, 50, 51, 52, 53, 54], // "123456" codepoints
onChanged: handleChanged,
)
// 6자리 OTP
InputOtp(
length: 6,
onCompleted: (code) => print(code),
)
// PIN 입력 (4자리, 구분선 없음, 입력값 숨김)
InputOtp(
length: 4,
obscured: true,
separatorInterval: 0,
onCompleted: handlePinCompleted,
)
// 초기값 복원
InputOtp(
length: 6,
initialValue: '123456',
onChanged: handleChanged,
)
높이가 다른 셀#
칸은 기본으로 정사각형입니다(cellSize 가 너비이자 높이). 세로로 긴 칸이 필요하면 cellHeight 를 함께 적습니다. 행 높이(height)는 칸보다 낮아지지 않으므로 따로 맞추지 않아도 됩니다 — Flutter 에서 칸이 줄어들거나 Web 에서 칸이 행 밖으로 넘치는 일이 없습니다.
InputOtp(
length: 6,
separatorInterval: 0,
inputOtpStyle: const CoreInputOtpStyle(
cellSize: CoreSize.size48,
cellHeight: CoreSize.size56,
),
)
InputOtp(
length: 6,
separatorInterval: 0,
inputOtpStyle: const CoreInputOtpStyle(
cellSize: CoreSize.size48,
cellHeight: CoreSize.size56,
),
)
크기 (Size)#
크기는 md 와 lg 두 단계입니다. md 가 기본값이며 지금까지의 모습 그대로입니다. lg
는 인증 번호 입력처럼 코드를 입력하는 것이 화면의 유일한 일일 때 씁니다 — 칸 · 모서리 · 숫자 · 가림 점이 함께 커지고, 칸이 테두리 대신 채움으로 읽힙니다.
md | lg | |
|---|---|---|
| 칸 | 40 × 40 | 56 × 56 |
| 모서리 | 8 | 14 |
| 숫자 | labelLarge 14 · 500 | titleLarge 20 · SemiBold |
구분선 - | labelLarge | titleLarge |
| 가림 점 | 8 | 12 |
| 칸 사이 | 8 | 8 |
| 칸 채움 | surface | surfaceContainer |
| 쉬는 칸 보더 | outlineVariant | 없음 |
| 호버 보더 | outline | 없음 |
| 활성 칸 | surface · outlineVariant |
surface · primary |
| 보더 두께 | 1 | 1 |
행 높이(height)는 칸을 따라 lg 에서 56 이 됩니다. 칸 사이 간격과 오류 문구는 크기와 상관없이 같습니다. inputOtpStyle
에 적은 값은 크기 단계보다 우선하므로, lg 위에 cellSize 만 바꿔도 칸은 그 값의 정사각형이 됩니다.
lg 의 칸은 쉴 때(호버 · 비활성 포함) 보더 없이 surfaceContainer 채움만으로 보이고, 지금 입력할 칸(활성 칸)은
surface 채움에 primary 보더를 씁니다. 보더 두께 1 은 모든 상태에 같게 깔려 있고 쉴 때는 투명할 뿐이라, 활성 칸이 되어도 칸 크기가 바뀌지 않습니다. 포커스 링은 이 보더와 따로이며
md 와 같은 기본 링 색을 씁니다. errorText 가 있으면 lg 에서도 쉬는 칸 · 호버 · 활성 칸 보더가 모두
error 가 됩니다 — 변형 단계가 크기 단계 위에 놓이기 때문입니다.
InputOtp(
length: 6,
size: CoreInputOtpSize.lg,
onCompleted: handleVerify,
)
InputOtp(
length: 6,
size: CoreInputOtpSize.lg,
onCompleted: handleVerify,
)
빠른 오버라이드 (Chain)#
이미 만든 InputOtp 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
.radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 ==
CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.
class InputOtpChainExample extends StatelessWidget {
const InputOtpChainExample({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
InputOtp(length: 6, onCompleted: (_) {}).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
InputOtp(length: 6, onCompleted: (_) {}).withStyle(
const CoreInputOtpStyle(
backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
borderColor: CoreColor.token(CoreColors.tertiary),
borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
borderWidth: CoreStrokeWidth.stroke2,
),
),
],
);
}
}
class InputOtpChainExample extends StatelessComponent {
const InputOtpChainExample({super.key});
@override
Component build(BuildContext context) {
return div(
[
// 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
InputOtp(length: 6).radius16.primary,
const Gap.space12(),
// withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
InputOtp(length: 6).withStyle(
const CoreInputOtpStyle(
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 InputOtpDefaultExample extends StatelessComponent {
const InputOtpDefaultExample({super.key});
@override
Component build(BuildContext context) {
return InputOtp(length: 6);
}
}
class InputOtpDefaultExample extends StatelessWidget {
const InputOtpDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return InputOtp(
length: 6,
onCompleted: (_) {},
);
}
}
editable 예제는 앞의 셀을 눌러 이미 입력한 숫자를 고칠 수 있습니다.
기본 설정에서는 셀 편집을 사용하지 않습니다.
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- SMS 또는 이메일로 전송된 인증 코드를 입력받을 때
- 앱 잠금 해제를 위한 PIN 번호를 입력받을 때
- 2단계 인증(2FA) 코드 입력 화면을 구현할 때
- 자리수가 고정되어 있고 자동 다음 필드 이동이 필요할 때
대신 다른 컴포넌트를 사용하세요:
- TextField: 고정 길이·완료 전이 없이 일반 문자열을 입력받을 때
- FormattedInput: 인증 코드 셀 대신 날짜·전화번호 리터럴 마스크가 필요할 때
Props / Parameters#
O 는 OTP 값의 플랫폼 표현인 Flutter List<int?> / Web String 을 뜻하는 설명용 기호입니다. Flutter의 int는 숫자 0~9가 아니라 문자 코드 단위이며
'1' 은 49입니다. 문자열이 필요하면 String.fromCharCodes(value.nonNulls) 로 바꿉니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
length |
int |
CoreInputOtpContract.defaultLength (6) |
표시·입력 자리수 |
separatorInterval |
int |
CoreInputOtpContract.defaultSeparatorInterval (3) |
N자리마다 구분선. 0이면 없음. Figma 의
Show separator
불리언이 이 값입니다 — 켜면 기본 간격(
3
), 끄면
separatorInterval: 0
|
obscured | bool | false | 표시값 숨김 |
enabled | bool | true | 입력 가능 여부 |
autofocus |
bool |
false |
마운트 시 입력 포커스 |
allowCellEditing |
bool |
false |
입력된 칸을 눌러 해당 숫자를 선택하고 교체 |
initialValue |
O? |
null |
초기 OTP 값 |
errorText |
String? |
null |
셀 오류 chrome과 하단 메시지 |
onChanged |
void Function(O)? |
null |
값 변경 콜백 |
onCompleted |
void Function(O)? |
null |
미완료 → 완료 입력 전이의 콜백 |
size |
CoreInputOtpSize |
CoreInputOtpSize.defaultSize (md) |
칸 · 모서리 · 숫자 · 가림 점의 크기 단계. md 는 기본 모습, lg 는 코드 입력이 화면의 유일한 일일 때 |
inputOtpStyle |
CoreInputOtpStyle? |
null |
셀·텍스트 chrome 단일 슬롯 |
스타일 시스템 (Style System)#
모든 chrome / dimensional / 텍스트 override 는 inputOtpStyle 슬롯 하나로 흐릅니다.
CoreInputOtpStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
cellSpacing |
double? |
Gap between sibling OTP cells (logical px).
null
defers to [defaultCellSpacing]. Consumed natively as
Row.spacing
on Flutter and
gap-${cellSpacing.tailwindSpace}
on Web.
|
cellSize |
double? |
Width of each OTP cell (logical px) — and its height too, unless [cellHeight] says otherwise. |
cellHeight |
double? |
Height of each OTP cell (logical px).
null
follows [cellSize], whose own fallback is [defaultCellHeight] — so a cell stays square until a caller states a height of its own (a 50 × 58 cell is
cellSize: 50, cellHeight: 58
).
|
obscuredDotSize |
double? |
Size (logical px, square) of the obscured-character dot.
null
→ [defaultObscuredDotSize]. Flutter renders the dot at this size; resolver pre-applies scaling.
|
height |
double? |
Overall height of the OTP input row (logical px). Never shorter than the resolved cell height — see [defaultHeight]. |
backgroundColor |
CoreColor? |
Background colour of each OTP cell. |
borderColor |
CoreColor? |
Border colour of each OTP cell. |
hoverBorderColor |
CoreColor? |
Border colour of each OTP cell while the pointer is over the control and no cell holds focus.
null
falls through to the variant's own hover border (the error variant restates
error
), then to [defaultHoverBorderColor]. A disabled control never takes it.
|
focusBackgroundColor |
CoreColor? |
Background colour when a cell is focused.
null
follows [backgroundColor], whose own fallback is [defaultFocusBackgroundColor].
|
focusBorderColor |
CoreColor? |
Border colour when a cell is focused. |
borderRadius |
CoreBorderRadius? |
Border radius of each OTP cell. |
borderWidth |
double? |
Border stroke width of each OTP cell (logical px) — defaults to [defaultBorderWidth]. |
separatorPadding |
CoreEdgeInsets? |
Padding around the separator glyph — defaults to [defaultSeparatorPadding]. |
transitionDuration |
Duration? |
Transition duration for cell focus / value changes — defaults to [defaultTransitionDuration]. |
textStyle |
CoreTextStyle? |
Text style applied to the OTP input value. Layered on top of [defaultTextStyle]. |
separatorTextStyle |
CoreTextStyle? |
Text style applied to the separator glyph (
-
). Layered on top of [defaultSeparatorTextStyle].
|
errorTextStyle |
CoreTextStyle? |
Text style applied to the error helper message (rendered when
widget.errorText != null
). Layered on top of [defaultErrorTextStyle].
|
errorTextPadding |
CoreEdgeInsets? |
Padding above the error helper message — defaults to [defaultErrorTextPadding]. |
테마 커스터마이징 (Theme)#
CoreInputOtpTheme으로 프로젝트 수준 스타일 오버라이드가 가능합니다.
CoreComponentTheme(
inputOtp: CoreInputOtpTheme(
style: CoreInputOtpStyle(
cellSize: 44, // 기본: md 40 · lg 56
cellSpacing: 12, // 기본: 8 (두 크기 같음)
height: 44, // 기본: 칸과 같음 (md 40 · lg 56)
borderRadius: CoreBorderRadius.all(CoreRadius.radius8), // 기본: md 8 · lg 14
backgroundColor: coreColor, // 기본: md surface · lg surfaceContainer
borderColor: coreColor, // 기본: md outlineVariant · lg 투명 (error variant: error)
hoverBorderColor: coreColor, // 기본: md outline · lg 투명 (error variant: error)
focusBackgroundColor: coreColor, // 기본: 칸 채움을 따름 · lg surface
focusBorderColor: coreColor, // 기본: 쉬는 보더를 따름 · lg primary (error variant: error)
textStyle: coreTextStyle,
),
),
)
Resolve chain (뒤에 올수록 우선): 디자인 기본값(CoreInputOtpStyle.default*) → CoreInputOtpStyle.defaultsBySize[size]
→ CoreInputOtpStyle.defaultsByVariant[variant] → 테마 style → 테마 variantStyles[variant]
→ 위젯 inputOtpStyle. 앞의 두 단계를 합친 머리는 CoreInputOtpStyle.chainHeadFor(size, variant)
하나로 두 플랫폼이 같이 읽습니다.
크기 단계는 칸 · 행 높이 · 모서리 · 글자 역할 · 가림 점과, lg 에서는 칸의 채움 · 보더 색 · 보더 두께까지 적습니다. 변형 단계(errorText
가 있을 때의 error)는 보더 색만 적고 크기 단계 뒤에 오므로, 두 단계가 보더 색에서 만나면 변형이 이깁니다 — lg 오류 칸이 보더 없는 채움 칸이 되지 않는 이유입니다. 포커스 링 색은 크기 단계를 읽지 않습니다: 링은 테마 · 위젯 · 변형이 적은
focusBorderColor 만 따르고, 아무도 적지 않으면 FocusOutline 기본 링 색입니다. 테마 style
은 두 단계보다 뒤에 오므로, 위 예처럼 테마에 cellSize: 44 를 적으면 md 와 lg 모두 칸이 44 가 됩니다. 크기마다 다른 값이 필요하면 테마 대신 인스턴스의
inputOtpStyle 에 적습니다.
동작 스펙 (Behavior)#
아키텍처 — 단일 히든 입력 (Flutter/Web 공통)#
InputOtp는 화면에 보이는 N개의 칸을 각각 별도의 입력 필드로 만들지 않는다. 실제 키보드/자동완성 연결은 보이지 않는 입력 필드 하나가 소유하고, 화면에 보이는 N개의 칸은 그 값을 그대로 반영해 그리는 순수 표시용 요소다(Flutter: 투명
TextField 위에 시각 셀을 겹쳐 그림, Web: opacity:0인 <input> 하나가 행 전체를 덮고 그 아래 시각 셀
<div>들은 pointer-events: none).
왜 단일 입력인가: 칸마다 별도 입력 필드를 두면 타이핑할 때마다 포커스가 필드 사이를 옮겨 다니게 되는데, 이는 iOS/Android의 SMS 자동완성(자동채움) 연결을 매 키 입력마다 끊어버려 실제 기기에서 자동완성이 되다 말다 하는 문제를 일으킨다. 단일 입력 필드를 유지하면 OS/브라우저의 OTP 자동완성 제안이 안정적으로 동작한다 — 이것이 실제 SMS 인증에 사용할 때 가장 중요한 개선점이다.
인터랙션#
- 자동 이동: 필요 없음 — 단일 입력이므로 숫자를 계속 치면 자연스럽게 다음 자리로 이어진다
-
아무 칸이나 선택해도 커서는 항상 입력된 값의 끝으로: 중간 칸(비어 있는 칸 포함)을 클릭/탭해도 실제 캐럿은 항상 지금까지 입력한 값의 마지막 위치로 이동한다 — 숨겨진 입력 필드의 실제 글자 배치는 화면에 보이는 칸 격자와 무관하기 때문에, 브라우저/OS 의 기본 클릭 위치 기반 캐럿 배치를 그대로 두면 의미 없는 위치에 커서가 놓인다. Flutter 는
TextField.onTap+ 포커스 획득 시, Web 은click/focus이벤트에서 캐럿을 값 끝으로 강제 이동시켜 이 문제를 막는다. - 역방향 이동: Backspace 는 마지막 한 자리를 지우고, 활성(포커스 링) 칸이 한 칸씩 앞으로 이동한다 — 캐럿이 항상 끝에 있으므로 단일 입력의 네이티브 Backspace 동작만으로 자연스럽게 동작 (화살표 키도 네이티브 캐럿 이동, 양 플랫폼 모두 별도 로직 없음)
- 붙여넣기: 클립보드 내용에서 숫자만 추출해 자동 채움 — 숫자가 아닌 문자(예: SMS 메시지 전체를 복사해 붙여넣은 경우의 안내 문구)는 무시되므로 "인증번호는 123456 입니다" 같은 문자열을 통째로 붙여넣어도 코드만 추출된다
-
SMS 자동완성: Flutter는
AutofillHints.oneTimeCode(iOSUITextContentType.oneTimeCode/ Android SMS Retriever), Web은autocomplete="one-time-code"+ WebOTP API(지원 브라우저에서 SMS 수신 시 자동으로 값 채움, 미지원 브라우저는 조용히 무시되고 수동 입력/붙여넣기로 동작) - 완료 감지: 모든 자리 입력 완료 시
onCompleted호출 -
변경 콜백은
onChanged→ 완료 전이이면 포커스 해제 →onCompleted순서입니다. 이미 full인 값을 같은 길이로 수정했다고 완료 콜백을 반복하지 않으며, 다시 미완료가 되면 다음 완료를 보고할 수 있습니다. 서버 검증·제출은 콜백을 받은 호출부의 책임입니다. -
완료 시 키보드 해제: 마지막 자리 입력으로
onCompleted가 발생하면 입력 필드가 자동으로 포커스를 해제해 모바일 소프트 키보드를 내림 (Flutter/Web 공통) -
호버: 포인터를 입력 위에 올리면 쉬고 있는 셀의 보더가
outlineVariant에서outline으로 한 단계 진해집니다(TextField 와 같은 단계) (hoverBorderColor).lg는 쉬는 칸에 보더가 없으므로 호버에서도 보더가 생기지 않습니다. 포인터를 받는 것은 행 전체를 덮는 히든 입력 하나라서 호버는 셀 하나가 아니라 컨트롤 단위로 일어납니다 — 포커스·enabled와 같은 단위입니다. 우선순위는disabled > error > focus > hover > rest: 비활성이면 호버 단계가 없고,errorText가 있으면 호버에서도 에러 색을 그대로 유지하며(errorvariant가 호버 보더도error로 적습니다), 포커스를 가진 컨트롤은 활성 칸의 포커스 보더와 링을 그대로 두고 옆 칸도 변하지 않습니다.
상태 전환#
empty→filling(첫 칸 입력 시작)filling→completed(모든 자리 입력)completed→filling(Backspace로 마지막 자리 삭제)
유효성#
- 기본 숫자만 입력 (
inputmode="numeric"/keyboardType.number) enabled: false시 모든 인터랙션 차단errorText가 설정되면 셀 보더(및 포커스 링)가 에러 색으로 바뀌고 메시지가 셀 아래 표시됨 (호버해도 에러 색 유지)
사용 가이드라인 (Usage Guidelines)#
✅ Do#
PIN 입력 시 obscured: true로 보안 강화
InputOtp(
length: 4,
obscured: true,
separatorInterval: 0,
onCompleted: handlePinVerify,
)
PIN은 민감 정보이므로 화면 노출을 방지한다.
SMS 인증 화면에서는 autofocus: true로 진입 즉시 입력 가능하게
InputOtp(
length: 6,
autofocus: true,
onCompleted: handleVerify,
)
화면이 뜨자마자 키보드가 올라와 있어야 사용자가 바로 입력을 시작할 수 있다.
❌ Don't#
인증 실패 후 에러 표시 없이 기존 입력 유지
// ❌ 실패 후 코드가 남아있어 혼란 야기, errorText 미사용
InputOtp(
length: 6,
onCompleted: (otp) async {
final ok = await authService.verify(otp);
// 실패 시 초기화/에러 메시지 없음
},
)
errorText 로 실패를 명확히 표시하고, 재시도를 위해 새 key 를 발급해 입력을 초기화한다(컴포넌트는 uncontrolled 모델이라 key 교체가 리셋 방법이다).
// ✅ errorText 로 에러 표시 + key 교체로 리셋
InputOtp(
key: ValueKey(attemptCount),
length: 6,
errorText: verifyFailed ? '인증번호가 일치하지 않습니다' : null,
onCompleted: (otp) async {
final ok = await authService.verify(otp);
setState(() {
verifyFailed = !ok;
if (!ok) attemptCount++; // key 변경 → 입력 초기화
});
},
)
❌ Don't#
OTP 자리수를 8자리 이상으로 설정
8자리를 초과하면 모바일 레이아웃이 깨지고 입력 피로도가 급증한다. 일반적으로 4~8자리가 적절하다.
접근성 (Accessibility)#
역할#
-
Web: 루트
<div>에role="group"과 로컬라이즈된aria-label을 둡니다. 실제 값을 쥔 히든<input>은type="text"·inputmode="numeric"·pattern="[0-9]*"·maxlength={length}·autocomplete="one-time-code"를 갖고 같은aria-label을 다시 실으며, 화면에 보이는 N 개의 칸<div>는 전부aria-hidden="true"입니다.enabled: false면 히든 입력에disabled가 붙습니다. -
Flutter: 루트를
Semantics(container: true, label: ...)로 감싸 같은 라벨을 붙이고, 안쪽TextField가 자기 텍스트 필드 시맨틱과AutofillHints.oneTimeCode를 가집니다. 다만 시각 칸과 구분선에는 Web 의aria-hidden에 해당하는ExcludeSemantics가 없습니다. -
errorText를 그리는 노드에는 양 플랫폼 모두 역할이 없습니다(Web 은 평범한<div>, Flutter 는 그룹Semantics바깥의Text).
키보드#
단일 히든 입력이 실제 포커스를 가지므로 모든 키 동작은 네이티브 텍스트 입력 편집 동작 그대로다(별도 구현 없음).
| 키 | 동작 |
|---|---|
0-9 | 값에 자리 추가 (네이티브 입력 동작) |
Backspace | 마지막 자리 삭제 (네이티브 입력 동작) |
← / → | 히든 입력 내부 캐럿 이동 (네이티브 입력 동작) |
Ctrl+V | 붙여넣은 내용에서 숫자만 추출해 자동 채움 |
칸 사이를 옮겨 다니는 전용 키는 없습니다 — 칸은 표시 전용이고 편집 대상은 히든 입력 하나뿐이므로, 자리 이동은 그 입력 안의 캐럿 이동으로 처리됩니다.
스크린 리더#
- 전체 컨테이너:
role="group",aria-label="One-time password input" -
실제 포커스 가능 요소는 히든 입력 하나뿐이며
aria-label="One-time password input"을 가진다 — 화면에 보이는 N개의 칸은aria-hidden="true"인 순수 표시 요소라 스크린 리더가 칸을 하나씩 탭하며 읽지 않는다(단일 입력 필드를 하나의 코드로 다루는 편이 자리마다 별도 텍스트 필드를 탭하는 것보다 접근성상 더 낫다는 것이 일반적인 OTP 폼 접근성 권고다). -
이름의 출처는 로컬라이제이션의
inputOtpLabel입니다(영어 기본값 "One-time password input", 한국어 "일회용 비밀번호 입력"). 이 문자열을 바꾸는 파라미터는 없고 활성 로케일이 결정합니다. -
오류 통보:
errorText문구만 FlutterSemantics(liveRegion: true)/ Webaria-live="polite"·aria-atomic="true"영역으로 제공됩니다. 입력값과 그룹 이름은 이 영역 밖에 두어 오류 변경으로 코드 전체를 다시 읽지 않습니다. 자리 채움·완료를 별도로 통보하지는 않습니다.
포커스 관리#
-
진입:
autofocus: true면 마운트 시 히든 입력이 포커스를 받습니다. 기본값은 어느 칸을 눌러도 캐럿을 입력된 마지막 자리 뒤로 이동합니다.allowCellEditing: true는 입력된 칸을 누르면 그 숫자를 선택하여 다음 입력으로 교체하고, 빈 칸은 입력 끝으로 이동합니다. 두 모드 모두 포커스와 자동완성은 하나의 히든 입력이 담당합니다. -
이탈: 마지막 자리가 채워져
onCompleted가 처음 발생하는 순간 포커스를 스스로 놓습니다(모바일 키보드를 내리기 위한 동작 — WebblurActiveElement()/ FlutterFocusNode.unfocus()). 되돌아갈 트리거가 지정돼 있지 않으므로 그다음 포커스를 어디로 보낼지는 호출부가onCompleted안에서 정해야 합니다. -
트랩: 없습니다. tab stop 이 히든 입력 하나뿐이라
Tab한 번으로 컴포넌트 전체를 지나갑니다.enabled: false면 그 하나마저 포커스 대상에서 빠집니다.
알려진 제약#
-
obscured: true는 화면만 가립니다 — 스크린 리더는 입력된 코드를 그대로 읽습니다. 실제 값을 쥔 히든 입력이 Web 에서는type="text"이고(보이지 않는 노드에password타입을 주면 브라우저 비밀번호 관리자 휴리스틱을 건드릴 수 있어 의도적으로 text 입니다), Flutter 에서도obscureText를 설정하지 않습니다. Flutter 의 히든 버퍼는 배경·보더·포커스 링이 모두 투명하고 글자색까지 투명하며 캐럿도 꺼져 있어obscured와 무관하게 언제나 보이지 않으며,obscured: true에서 사용자가 실제로 보는 마스킹은 시각 칸이 그리는 원형 점입니다. 값을 보조기술에서까지 가릴 파라미터는 양 플랫폼 모두 없습니다. -
errorText의 입력 연결은 아직 없습니다. 오류 문구는 별도 live region으로 제공하지만 Web 히든 입력에aria-invalid/aria-describedby가 붙지는 않습니다. 오류 영역의 id도 없으므로 입력에 다시 포커스했을 때 오류를 설명으로 함께 읽는 관계는 제공하지 않습니다. 실제 낭독 시점은 플랫폼과 보조기술에 따릅니다. -
칸 사이 구분선
-이 스크린 리더에 그대로 읽힙니다.aria-hidden은 칸<div>에만 붙고 구분선<span>과 그것을 담은 행에는 붙지 않으며, Flutter 쪽 구분선도ExcludeSemantics없는Text('-')입니다.separatorInterval기본값이3이라 기본 구성에서 이미 발생하며,separatorInterval: 0으로 구분선을 없애는 것 외의 회피 수단은 없습니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 값 표현 | null 빈자리를 포함한 문자 코드 목록 | 숫자 문자열 |
| 실제 입력 | 투명 TextField · TextEditingController | 투명 native input |
| 자동채움 | oneTimeCode autofill hint | autocomplete=one-time-code · 지원 시 WebOTP |
| 완료 후 포커스 | FocusNode.unfocus | DOM 활성 입력 blur |
| 셀 의미 | 시각 셀과 실제 입력 시맨틱 | 시각 셀 aria-hidden · 입력 자체의 의미 |
| 호버 감지 | 컨트롤 전체를 감싼 MouseRegion이 위젯 상태로 전달 |
셀이
pointer-events: none
이라 히든 입력의
:hover
를 루트의 이름 붙은 그룹(
group/input-otp
)이 셀에 전달 — 위젯 상태 없음
|