TimePicker
시간을 선택하는 시간 선택기 컴포넌트
목차
시간(시·분·초)을 선택할 수 있는 시간 선택기 컴포넌트입니다. 12시간/24시간 형식과 선택적인 초 표시를 지원합니다.
기본 사용법 (Basic Usage)#
// 기본 시간 선택기
TimePicker(
value: selectedTime,
placeholder: 'Select time',
onChanged: handleTimeChanged,
)
// 24시간 형식 강제
TimePicker(
value: meetingTime,
use24HourFormat: true,
onChanged: handleMeetingTimeChanged,
)
// 초 표시 포함
TimePicker(
value: preciseTime,
showSeconds: true,
onChanged: handlePreciseTimeChanged,
)
// 분을 15분 단위로
TimePicker(
value: appointment,
minuteInterval: 15,
onChanged: handleAppointmentChanged,
)
// 선택 가능 범위 제한 (ISO time string)
TimePicker(
value: businessHour,
min: '09:00',
max: '18:00',
onChanged: handleBusinessHourChanged,
)
// 기본 시간 선택기
TimePicker(
value: selectedTime,
placeholder: 'Select time',
onChanged: handleTimeChanged,
)
// 24시간 형식 강제
TimePicker(
value: meetingTime,
use24HourFormat: true,
onChanged: handleMeetingTimeChanged,
)
// 초 표시 포함
TimePicker(
value: preciseTime,
showSeconds: true,
onChanged: handlePreciseTimeChanged,
)
// 분을 15분 단위로
TimePicker(
value: appointment,
minuteInterval: 15,
onChanged: handleAppointmentChanged,
)
// 선택 가능 범위 제한
TimePicker(
value: businessHour,
min: '09:00',
max: '18:00',
onChanged: handleBusinessHourChanged,
)
빠른 오버라이드 (Chain)#
이미 만든 TimePicker 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class TimePickerChainExample extends StatefulWidget {
const TimePickerChainExample({super.key});
@override
State<TimePickerChainExample> createState() => _TimePickerChainExampleState();
}
class _TimePickerChainExampleState extends State<TimePickerChainExample> {
String? _value;
@override
Widget build(BuildContext context) {
return SizedBox(
width: CoreSpace.space256,
child:
TimePicker(
value: _value,
placeholder: 'Select time',
onChanged: (value) => setState(() => _value = value),
).withStyle(
const CoreTimePickerStyle(
segmentBorderRadius: CoreBorderRadius.all(CoreRadius.radius8),
segmentBorderColor: CoreColor.token(CoreColors.primary),
contentColor: CoreColor.token(CoreColors.primary),
triggerHeight: CoreSpace.space48,
triggerBorderWidth: CoreStrokeWidth.stroke2,
trailingIconStyle: CoreIconStyle(
size: CoreIconSize.size20,
color: CoreColor.token(CoreColors.primary),
),
),
),
);
}
}
class TimePickerChainExample extends StatefulComponent {
const TimePickerChainExample({super.key});
@override
State<TimePickerChainExample> createState() => _TimePickerChainExampleState();
}
class _TimePickerChainExampleState extends State<TimePickerChainExample> {
String? _value;
@override
Component build(BuildContext context) {
return div(
[
TimePicker(
value: _value,
placeholder: 'Select time',
onChanged: (value) => setState(() => _value = value),
).withStyle(
const CoreTimePickerStyle(
segmentBorderRadius: CoreBorderRadius.all(CoreRadius.radius8),
segmentBorderColor: CoreColor.token(CoreColors.primary),
contentColor: CoreColor.token(CoreColors.primary),
triggerHeight: CoreSpace.space48,
triggerBorderWidth: CoreStrokeWidth.stroke2,
trailingIconStyle: CoreIconStyle(
size: CoreIconSize.size20,
color: CoreColor.token(CoreColors.primary),
),
),
),
],
classes: 'w-${CoreSpace.scale.space256}',
);
}
}
Live Preview#
기본 (popover)#
코드 보기
class TimePickerDefaultExample extends StatefulComponent {
const TimePickerDefaultExample({super.key});
@override
State<TimePickerDefaultExample> createState() =>
_TimePickerDefaultExampleState();
}
class _TimePickerDefaultExampleState extends State<TimePickerDefaultExample> {
String? _value;
@override
Component build(BuildContext context) {
return div(
[
TimePicker(
value: _value,
placeholder: 'Select time',
onChanged: (value) => setState(() => _value = value),
),
],
classes: 'w-${CoreSpace.scale.space256}',
);
}
}
class TimePickerDefaultExample extends StatefulWidget {
const TimePickerDefaultExample({super.key});
@override
State<TimePickerDefaultExample> createState() =>
_TimePickerDefaultExampleState();
}
class _TimePickerDefaultExampleState extends State<TimePickerDefaultExample> {
String? _value;
@override
Widget build(BuildContext context) {
return SizedBox(
width: CoreSpace.space256,
child: TimePicker(
value: _value,
placeholder: 'Select time',
onChanged: (value) => setState(() => _value = value),
),
);
}
}
다이얼로그 모드#
코드 보기
class TimePickerDialogExample extends StatefulComponent {
const TimePickerDialogExample({super.key});
@override
State<TimePickerDialogExample> createState() =>
_TimePickerDialogExampleState();
}
class _TimePickerDialogExampleState extends State<TimePickerDialogExample> {
String? _value;
@override
Component build(BuildContext context) {
return div(
[
TimePicker(
value: _value,
placeholder: 'Select time',
mode: CorePromptMode.dialog,
dialogTitle: Text('Pick a time'),
onChanged: (value) => setState(() => _value = value),
),
],
classes: 'w-${CoreSpace.scale.space256}',
);
}
}
class TimePickerDialogExample extends StatefulWidget {
const TimePickerDialogExample({super.key});
@override
State<TimePickerDialogExample> createState() =>
_TimePickerDialogExampleState();
}
class _TimePickerDialogExampleState extends State<TimePickerDialogExample> {
String? _value;
@override
Widget build(BuildContext context) {
return SizedBox(
width: CoreSpace.space256,
child: TimePicker(
value: _value,
placeholder: 'Select time',
mode: CorePromptMode.dialog,
dialogTitle: const Text('Pick a time'),
onChanged: (value) => setState(() => _value = value),
),
);
}
}
초 단위 (HH:MM:SS)#
코드 보기
class TimePickerSecondsExample extends StatefulComponent {
const TimePickerSecondsExample({super.key});
@override
State<TimePickerSecondsExample> createState() =>
_TimePickerSecondsExampleState();
}
class _TimePickerSecondsExampleState extends State<TimePickerSecondsExample> {
String? _value;
@override
Component build(BuildContext context) {
return div(
[
TimePicker(
value: _value,
placeholder: 'Select time',
showSeconds: true,
onChanged: (value) => setState(() => _value = value),
),
],
classes: 'w-${CoreSpace.scale.space256}',
);
}
}
class TimePickerSecondsExample extends StatefulWidget {
const TimePickerSecondsExample({super.key});
@override
State<TimePickerSecondsExample> createState() =>
_TimePickerSecondsExampleState();
}
class _TimePickerSecondsExampleState extends State<TimePickerSecondsExample> {
String? _value;
@override
Widget build(BuildContext context) {
return SizedBox(
width: CoreSpace.space256,
child: TimePicker(
value: _value,
placeholder: 'Select time',
showSeconds: true,
onChanged: (value) => setState(() => _value = value),
),
);
}
}
에러 상태#
Time is required
코드 보기
class TimePickerErrorExample extends StatefulComponent {
const TimePickerErrorExample({super.key});
@override
State<TimePickerErrorExample> createState() =>
_TimePickerErrorExampleState();
}
class _TimePickerErrorExampleState extends State<TimePickerErrorExample> {
String? _value;
@override
Component build(BuildContext context) {
return div(
[
TimePicker(
value: _value,
placeholder: 'Select time',
errorText: 'Time is required',
onChanged: (value) => setState(() => _value = value),
),
],
classes: 'w-${CoreSpace.scale.space256}',
);
}
}
class TimePickerErrorExample extends StatefulWidget {
const TimePickerErrorExample({super.key});
@override
State<TimePickerErrorExample> createState() =>
_TimePickerErrorExampleState();
}
class _TimePickerErrorExampleState extends State<TimePickerErrorExample> {
String? _value;
@override
Widget build(BuildContext context) {
return SizedBox(
width: CoreSpace.space256,
child: TimePicker(
value: _value,
placeholder: 'Select time',
errorText: 'Time is required',
onChanged: (value) => setState(() => _value = value),
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 예약, 일정, 알림 등 시간을 입력받아야 하는 경우
- 12시간(AM/PM) 또는 24시간 형식의 시간 선택이 필요한 경우
- 영업 시간, 배송 시간처럼 선택 가능한 시간 범위를 제한해야 하는 경우
대신 다른 컴포넌트를 사용하세요:
DatePicker: 날짜와 시간을 함께 선택해야 하는 경우TextField: 시간을 자유 형식 텍스트로 직접 입력받는 경우Select: 고정된 시간 슬롯 목록에서 선택해야 하는 경우
Props / Parameters#
W는 dialogTitle의 콘텐츠 타입이며 Flutter의 Widget, Web의 Component입니다. 시간 값은 24시간 ISO 문자열로 주고받고 표시 형식만 12/24시간으로 바뀝니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
value |
String? |
null |
HH:MM 또는 HH:MM:SS 값 |
placeholder |
String? |
null |
미선택 안내; 생략하면 세그먼트 글리프로 조립 |
placeholderSegment |
String |
'--' |
미선택 세그먼트 글리프 |
placeholderSeparator |
String |
':' |
placeholder 세그먼트 구분자 |
enabled | bool | true | 활성화 여부 |
showSeconds |
bool |
false |
초 선택 포함 |
use24HourFormat |
bool? |
null |
플랫폼 시간 형식 설정을 따름 |
minuteInterval |
int |
1 |
분 세그먼트의 간격(분). 60의 약수(
1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60
)만 받고, 그 밖의 값은 debug 빌드에서 거부됩니다
|
min / max |
String? |
null |
ISO 시간 경계 선언. 현재 선택 패널의 저장 경로에서 강제하지 않음 |
onChanged |
CoreValueChanged<String>? |
null |
저장된 ISO 시간 통보 |
mode |
CorePromptMode? |
null → popover |
popover/dialog 표면 선택 |
popoverPlacement |
CorePopoverPlacement |
bottomStart |
앵커 패널 위치 |
dialogTitle |
W? |
null |
dialog 헤더 제목 |
errorText | String? | null | 오류 표현 |
timePickerStyle |
CoreTimePickerStyle? |
null |
인스턴스 스타일 |
스타일 시스템 (Style System)#
TimePicker의 모든 chrome / 치수 / 중첩 슬롯 오버라이드는 CoreTimePickerStyle 한곳으로 흐릅니다.
시맨틱 vs 스타일#
-
시맨틱 enum / 동작: 위젯 파라미터로 직접 (
mode,popoverPlacement,placeholder,placeholderSegment,placeholderSeparator,enabled,showSeconds,use24HourFormat,minuteInterval,onChanged,min,max,errorText,dialogTitle) - chrome / 치수 / 슬롯 스타일:
CoreTimePickerStyle한곳 (아래 필드 표) - 변형 교체: asChild —
dialogTitle슬롯 등에 위젯을 직접 넣습니다
Resolve chain#
design system default for time picker
→ CoreTimePickerTheme.style // 프로젝트 공통
→ parent component slot override
→ widget.timePickerStyle // 인스턴스별
각 nested 슬롯 스타일 (triggerStyle / popoverStyle / dialogStyle / segmentInputStyle
/ amPmToggleStyle / actionButtonStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다. 예: 트리거는 읽기 전용
TextField 라서 triggerStyle 의 chrome 은 → CoreTextFieldTheme → 크기 행(md) → 부모 슬롯 →
widget.timePickerStyle.triggerStyle 순. 마찬가지로 popover 패널 박스는 popoverStyle.panelStyle
(CorePopupStyle) 을 통해 Popup 의 resolver 로, AM/PM 선택은 amPmToggleStyle
(CoreToggleGroupStyle) 을 통해 ToggleGroup 의 resolver 로 흐릅니다.
트리거는 DatePicker 와 같은 입력창 모양입니다 — 높이 40 · 모서리 10 · outline-variant 테두리(포커스에서 primary) · Regular 14 값 · 오른쪽 시계 아이콘, 폭은 부모가 준 폭을 채웁니다. 박스는 합성한
TextField 의 md 행이 정하므로 TimePicker 는 높이(triggerHeight) · 값 색(contentColor) · 시계(trailingIconStyle)만 말합니다.
CoreTimePickerStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
popoverStyle |
CorePopoverStyle? |
Popover style for the popover-mode picker (trigger + dropdown chrome). |
dialogStyle |
CoreDialogStyle? |
Dialog style for the modal-mode picker (panel + title + action button chrome).
No default, and must not have one.
Both platforms forward this slot to
Dialog(dialogStyle: …)
unchanged, so null is what lets
Dialog
resolve its own panel — the single source for that surface. A constant here would be a second copy of
Dialog
's panel defaults, and because a slot override outranks the child's own baseline it would also win over them for every picker dialog. The asymmetry with [defaultPopoverStyle] is the point: popover mode
diverges
from
Popup
(radius16 / outlineVariant / padding16) and that divergence is what the default states. Dialog mode diverges in nothing, so it states nothing.
|
segmentInputStyle |
CoreTextFieldStyle? |
Style applied to each hour / minute / second segment input (TextField inside the picker panel).
No default, and must not have one.
This slot is the top of two precedence chains that already end at named Core defaults:
text …?.borderColor ?? segmentBorderColor ?? defaultSegmentBorderColor …?.valueStyle ?? valueTypography ?? defaultValueTypography
The baseline exists — under those names. Giving this slot a non-null default would insert it
above
the flat overrides in both chains, so
segmentBorderColor
and
valueTypography
would stop being reachable: a caller setting either would see no change. That is a dead parameter, not a repaint.
|
amPmButtonStyle |
CoreButtonStyle? |
Deprecated (since 0.174)
— Use amPmToggleStyle — am/pm is a ToggleGroup. Style applied to the am/pm toggle button when
use24HourFormat: false
.
No default, and must not have one.
Raw-forwarded to
Button(buttonStyle: …)
on both platforms, where null means the button paints its variant. A constant would outrank
CoreButtonStyle.defaultsByVariant
inside
Button
's resolver and repaint the toggle. [defaultAmPmSelectedButtonStyle] exists for the sibling slot because the selected button genuinely diverges — it needs a transparent border so selection does not resize the toggle. This slot has no such divergence to state.
|
amPmSelectedButtonStyle |
CoreButtonStyle? |
Deprecated (since 0.174)
— Use amPmToggleStyle — am/pm is a ToggleGroup. Style applied to the
selected
am/pm toggle button, layered on top of [amPmButtonStyle]. The default carries a transparent border matching the unselected outline button's stroke, so the selected (borderless
primary
) button occupies the same layout footprint and the toggle never resizes when selection moves.
|
amPmToggleStyle |
CoreToggleGroupStyle? |
Style of the am/pm
ToggleGroup
shown in the panel when
use24HourFormat: false
— the shared grouped toggle (a
surfaceContainerHigh
track with a raised chosen face). Raw-forwarded to
ToggleGroup(toggleGroupStyle: …)
.
No default, and must not have one
:
ToggleGroup
resolves its own track and chosen face, and a constant here would outrank that component's defaults.
|
triggerStyle |
CoreTextFieldStyle? |
Trigger field chrome — raw-forwarded to the composed read-only
TextField
, over [defaultTriggerStyle]. The trigger's value colour ([contentColor]), placeholder tone and clock ([trailingIconStyle]) are folded in under it. An explicit
popoverStyle.triggerStyle
is the chrome of the earlier button trigger and is not read.
|
actionButtonStyle |
CoreButtonStyle? |
Style merged into the Cancel / Save action buttons of the popover / dialog panel — single shared slot, mirroring
CoreDialogStyle.actionButtonStyle
.
No default, and must not have one
, for the same reason as
CoreDialogStyle.actionButtonStyle
: one slot feeds both buttons, Cancel is
.ghost
and Save is the filled default, and a value named here outranks
CoreButtonStyle.defaultsByVariant
in
Button
's resolver. A single constant would therefore paint the two buttons alike and erase the distinction they exist to draw.
|
labelStyle |
CoreTextStyle? |
Label text style (text shown above the trigger).
Read by no resolver and no widget — wire it or remove it.
Left untouched (deleting a public field is breaking and not mine to decide). Nobody reads it on either platform: both resolvers only author it into
ResolvedTimePicker
(
labelStyle: merged.labelStyle
) and neither widget ever reads
resolved.labelStyle
—
grep -rn 'resolved.labelStyle' packages/coui_{web,flutter}/lib
over time_picker returns nothing. The root cause is that
TimePicker
has no
label
parameter on either platform, so there is no label element to style; the segment captions inside the panel are styled by
helperTypography
+
labelColor
instead. Either the component gains a
label
slot and this styles it, or the field is dropped at the next breaking release — a decision about the component's API, not about a default.
|
descriptionStyle |
CoreTextStyle? |
Description / helper text style.
Read by no resolver and no widget — wire it or remove it.
Left untouched. Same as
labelStyle
: authored into both
ResolvedTimePicker
s, read by neither widget, and
TimePicker
has no
description
parameter on either platform, so no description text is rendered at all. Wire it (add the slot) or drop it in a breaking release.
|
errorStyle |
CoreTextStyle? |
Error text style.
Read by no resolver and no widget — wire it or remove it.
Left untouched. Authored into both
ResolvedTimePicker
s and read by neither widget.
TimePicker
does take
errorText
, but it only drives the trigger's error border colour (
hasError
at time_picker.dart:320-321 Flutter / 622-623 Web) — no error
text
is ever rendered, so there is nothing for a text style to apply to. Either the component renders the message (then this styles it) or the field goes in a breaking release.
|
segmentBorderColor |
CoreColor? |
Custom segment input border colour. Falls back to [defaultSegmentBorderColor] when null. |
segmentHoverBorderColor |
CoreColor? |
Deprecated (since 0.175) — Not read — a hovered segment wears a state layer. Not read — a hovered segment wears a state layer, not a border step. Use [segmentStateLayerColor] / [segmentStateLayer]. |
segmentStateLayerColor |
CoreColor? |
Custom colour of the state layer laid over a segment tile while the pointer is over it. Falls back to [defaultSegmentStateLayerColor] when null. The layer covers the tile's own shape (size and corner) over whichever face it wears and changes neither the face colour nor the border. |
segmentStateLayer |
CoreStateLayer? |
Custom weights of the segment tile's state layer (only
hover
is read). Falls back to [defaultSegmentStateLayer] when null.
|
segmentSurfaceColor |
CoreColor? |
Custom segment input fill / surface colour. Falls back to [defaultSegmentSurfaceColor] when null. |
segmentContentColor |
CoreColor? |
Custom segment input content text colour. Falls back to [defaultSegmentContentColor] when null. |
segmentFocusOutlineStyle |
CoreFocusOutlineStyle? |
Deprecated (since 0.174)
— Not read — the edited segment takes a primaryContainer face, not a ring; use segmentFocusedSurfaceColor. Segment focus ring override.
null
defers to [defaultSegmentFocusOutlineStyle], and a field left null inside it defers to
CoreFocusOutlineStyle
's own default. Geometry is stated as
borderWidth
plus
align
(the ring's total outset), not as a width/offset pair — the gap between the segment and the ring is
align - borderWidth
. Both platforms draw the ring the same way — a composed
FocusOutline
wrapping the segment box — so the slot reaches the same seven fields on each:
borderColor
,
borderWidth
,
align
,
offsetColor
,
ringOpacity
,
borderRadius
,
duration
.
circleBorderRadius
is the eighth and reaches neither. It is read only when the ring's shape is a circle, and a time segment is a rectangle — the two radius fields are mutually exclusive by shape, so no single instance can reach both. A rectangle taking a circle's radius would be the bug; this is the correct outcome, not a gap to close.
|
labelColor |
CoreColor? |
Custom segment label / description text colour. Falls back to [defaultLabelColor] when null. |
contentColor |
CoreColor? |
Custom trigger content text colour. Falls back to [defaultContentColor] when null. The segment separator has its own [separatorColor]. |
valueTypography |
CoreTextStyle? |
Custom trigger value / segment numeric typography role. Falls back to [defaultValueTypography] when null. |
helperTypography |
CoreTextStyle? |
Custom helper / segment-label typography role. Falls back to [defaultHelperTypography] when null. |
segmentBorderRadius |
CoreBorderRadius? |
Custom segment input border radius (pre-scaling). |
triggerHeight |
double? |
Custom trigger height (pre-scaling) — forwarded as the trigger field's
height
.
null
→ the field's own
md
row (40).
|
triggerBorderWidth |
double? |
Custom trigger / segment border width (pre-scaling). |
triggerContentGapStyle |
CoreGapStyle? |
Deprecated (since 0.174)
— Not read — the field owns the gap. Nested [CoreGapStyle] slot for the gap between trigger text and trailing icon. Forwarded as the trigger
Button
's nested
iconLabelGapStyle
. Merged on top of [defaultTriggerContentGapStyle].
|
segmentSize |
double? |
Custom width of each numeric segment field (pre-scaling). Falls back to [defaultSegmentSize] when null. |
segmentHeight |
double? |
Custom height of each numeric segment field (pre-scaling). The AM/PM column beside it keeps the ToggleGroup's own height. Falls back to [defaultSegmentHeight] when null. |
segmentFocusedSurfaceColor |
CoreColor? |
Custom fill of the segment being edited. Falls back to [defaultSegmentFocusedSurfaceColor] when null. |
segmentFocusedContentColor |
CoreColor? |
Custom text colour of the segment being edited. Falls back to [defaultSegmentFocusedContentColor] when null. |
dialogLabelSpacing |
double? |
Uniform spacing between the segment field and its label (Flutter
Column.spacing
/ Web inline
margin-top
). Falls back to [defaultDialogLabelSpacing] when null.
|
dialogOuterGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the horizontal gap between the segment row and the AM/PM column. Forwarded to a
Gap
widget on Flutter and drives an inline spacer width on Web. Merged on top of [defaultDialogOuterGapStyle].
|
actionRowGapStyle |
CoreGapStyle? |
Nested [CoreGapStyle] slot for the vertical gap between the segment row and the action button row (also used between the optional title and the segment row). Forwarded to a
Gap
widget on Flutter and drives an inline
margin-top
on Web. Merged on top of [defaultActionRowGapStyle].
|
separatorPadding |
CoreEdgeInsets? |
Custom padding around the segment separator glyph (pre-scaling). |
separatorTypography |
CoreTextStyle? |
Custom segment separator (
:
) typography. Falls back to [defaultSeparatorTypography] when null.
|
separatorColor |
CoreColor? |
Custom segment separator (:) colour. Falls back to [defaultSeparatorColor] when null. |
trailingIconStyle |
CoreIconStyle? |
Trigger trailing (clock) icon style override. null → [defaultTrailingIconStyle]. |
errorTriggerStyle |
CoreButtonStyle? |
Deprecated (since 0.174)
— Use triggerStyle.errorBorderColor. Trigger border colour while the picker holds an error (the rest of the button bundle is not read — the trigger is a
TextField
, whose error look is its own). Merged on top of [defaultErrorTriggerStyle] and folded in as the field's
errorBorderColor
;
triggerStyle
outranks it.
|
사용 예 (Flutter)#
TimePicker(
value: '14:30',
placeholder: 'Select time',
onChanged: handleTimeChanged,
timePickerStyle: CoreTimePickerStyle(
popoverStyle: CorePopoverStyle(
panelStyle: CorePopupStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
padding: CoreEdgeInsets.all(CoreSpace.space16),
),
placementOffset: CoreSpace.space8,
),
triggerStyle: CoreTextFieldStyle(
borderColor: CoreColor.token(CoreColors.outline),
),
segmentInputStyle: CoreTextFieldStyle(
borderColor: CoreColor.token(CoreColors.primary),
),
amPmToggleStyle: CoreToggleGroupStyle(itemHeight: CoreSize.size36),
labelStyle: CoreTextStyle(fontWeight: CoreFontWeight.semiBold),
),
)
Theme 오버라이드#
CoreComponentTheme.timePicker를 통해 프로젝트 레벨의 기본 스타일(CoreTimePickerStyle)을 지정할 수 있습니다:
CoreComponentTheme(
timePicker: CoreTimePickerTheme(
style: CoreTimePickerStyle(
popoverStyle: CorePopoverStyle(
panelStyle: CorePopupStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
padding: CoreEdgeInsets.all(CoreSpace.space16),
),
),
triggerStyle: CoreTextFieldStyle(
borderColor: CoreColor.token(CoreColors.outline),
),
),
),
)
동작 스펙 (Behavior)#
인터랙션#
-
트리거 클릭:
mode가popover(기본)면Popover패널이popoverPlacement위치로,dialog면Dialog가 중앙 모달로 열립니다 (양쪽 동일) - 직접 입력: 시/분/초 필드에 숫자를 직접 입력 가능 (양쪽 동일)
-
세그먼트 칸: 64×60, 모서리 10, 채움
surface-container-high, 테두리는 투명(칸 안에 입력 상자를 따로 두지 않고 칸 하나가 곧 입력입니다). 패널 모서리는 12(Popup기본), 테두리는outlineVariant입니다 (양쪽 동일) -
세그먼트 포커스: 편집 중인 칸은 링이 아니라
primary-container면과on-primary-container글자로 표시합니다(segmentFocusedSurfaceColor/segmentFocusedContentColor). 링은 닫힌 트리거의 포커스에만 있습니다 (양쪽 동일) -
세그먼트 hover: 포인터가 올라간 세그먼트 위에 공통 상태 레이어가 깔립니다 —
on-surface(segmentStateLayerColor)를 hover 가중치(segmentStateLayer.hover)로 칸 모양 그대로 덮습니다. 칸의 면 색과 테두리는 바뀌지 않고, 편집 중인 칸(primary-container 면) 위에도 같은 레이어가 얹힙니다.segmentHoverBorderColor는 더 이상 읽히지 않습니다(since 0.175) -
AM/PM 토글: 12시간 형식에서 AM/PM 전환 — 공용 ToggleGroup
한 개를 세그먼트 옆에 둡니다. 크기는
sm(칸 26 · 트랙 32)이고 세로로 배치해(AM 위, PM 아래) 두 칸이3 + 26 + 2 + 26 + 3 = 60— 시 · 분 칸과 같은 높이로 칸 위쪽에 맞춰 놓입니다 (CoreTimePickerContract.defaultAmPmToggleSize·defaultAmPmToggleDirection). 판 폭은 시 · 분 칸과 세로 묶음만큼으로 좁게 유지됩니다. 선택한 칸이surface-raised면으로 올라옵니다 (양쪽 동일) - 저장/취소: 패널 하단의
Button(Save/Cancel)
값 형식#
- 값은 ISO 시간 문자열로 주고받습니다:
"14:30"또는"14:30:45"(showSeconds 시) - 12시간 포맷에서도 값은 24시간 ISO로 저장되며, 디스플레이만
hh:mm AM/PM로 포맷팅됩니다
분 간격 (minuteInterval)#
minuteInterval 은 분 세그먼트가 움직이는 단위입니다. 60의 약수여야 하므로 어떤 간격이든 :00 이 그 배수에 들어 있고, 시간이 바뀌어도 배수가 같은 자리에 반복됩니다. 시 · 초 세그먼트는 바뀌지 않습니다.
-
화살표 키: 분 세그먼트에서
↑는 다음 배수로,↓는 이전 배수로 옮깁니다 (키를 누르고 있으면 반복). 15분 간격이면:00 → :15 → :30 → :45 → :00입니다. 배수가 아닌 분(:07)에서는 가장 가까운 위쪽·아래쪽 배수(:15·:00)로 갑니다. 수정 키(Shift · Alt · Ctrl · Meta)와 함께 누르면 브라우저·프레임워크 기본 동작(텍스트 선택·캐럿 이동)에 맡깁니다. -
직접 입력: 입력하는 동안에는 건드리지 않고, 세그먼트를 벗어날 때와 저장할 때 가장 가까운 배수로 맞춥니다. 중간에 걸린
1이15로 가는 길에00으로 되돌려지지 않게 하려는 것입니다. 맞추는 기준은 가장 가까운 배수이고, 정확히 중간인 값은 더 큰 배수로 갑니다(반올림). 15분 간격에서:07은:00,:08은:15이며, 4분 간격에서 정확히 중간인:02는:04입니다.:59를 넘는 값은 먼저:59로 줄인 뒤 맞춥니다. -
value로 들어온 분은 배수가 아니어도 그대로 보여 주고, 사용자가 건드리지 않았더라도 저장할 때 같은 규칙으로 맞춥니다.value: '10:07',minuteInterval: 15에서 저장하면10:00이 통보됩니다. -
시간으로 넘어가지 않습니다: 정각 쪽 끝은 같은 시간의
:00으로 돌아갑니다. 15분 간격에서10:53은11:00이 아니라10:00,↑로:45를 넘겨도10:00입니다. - 기본값
1에서는 모든 분이 그대로 유지되고, 입력한 텍스트도 고쳐 쓰지 않습니다. -
간격은 단위이지 경계가 아닙니다.
min/max는 여전히 선언일 뿐 저장 경로에서 강제되지 않습니다.
두 플랫폼 모두 같은 규칙을 coui_core 의 CoreMinuteInterval 에서 읽습니다.
경계와 현재 제약#
시·분·초 숫자는 각 세그먼트의 유효 숫자 범위로 제한됩니다. 저장 시 ISO 문자열을 통보하고 패널을 닫으며, value 갱신은 호출자가 담당합니다. 현재 양 플랫폼의 선택/저장 경로는 min/max 필드를 읽지 않으므로 영업시간 같은 제품 경계는 onChanged에서 검증하고 errorText 등으로 안내해야 합니다.
사용 가이드라인 (Usage Guidelines)#
시·분·초 선택 패널에는 TimePicker를 사용합니다. 날짜는 DatePicker, 시간 문자열을 직접 편집하는 입력은 FormattedInput이나 TextField를 선택합니다. 저장 형식과 화면의 AM/PM 표기를 구분하세요.
✅ Do#
제품의 시간 범위는 저장 콜백에서 검증
TimePicker(
value: businessHour,
min: '09:00',
max: '18:00',
onChanged: validateAndSaveBusinessHour,
)
validateAndSaveBusinessHour는 호출자가 구현하는 검증·저장 함수입니다. min/max만 넘겨도 범위 밖 입력이 차단된다고 가정하지 말고, 유효한 값만 앱 상태에 반영하세요.
❌ Don't#
12시간 포맷 문자열을 value/onChanged에 그대로 넘기지 않기
// ❌ "HH:MM" ISO 형식이 아닌 12시간 문자열
TimePicker(
value: '02:30 PM',
onChanged: (v) => save(v),
)
이유: use24HourFormat이 false여도 value/onChanged는 항상 24시간 ISO 문자열("HH:MM" 또는 "HH:MM:SS")을 주고받습니다. 12시간 포맷은 디스플레이 단계에서만 변환되므로, 12시간 문자열을 직접 넘기면 파싱에 실패하거나 의도하지 않은 시간으로 해석됩니다.
접근성 (Accessibility)#
역할#
| 요소 | Flutter | Web |
|---|---|---|
| 루트 | 없음 | 역할 없는 <div> |
| 트리거 |
읽기 전용
TextField
— 포커스할 수 있고 누를 수 있지만 글자를 칠 수는 없습니다. 팝오버 모드에서는
Popover
가
Semantics(expanded: …)
를 추가합니다
|
읽기 전용
<input>
. 팝오버 모드에서는
Popover
wrapper
<div>
가
aria-haspopup="dialog"
/
aria-expanded
를 붙입니다
|
| 패널 | 역할 없음 — 파일 전체에 Semantics 가 0건입니다 |
패널 콘텐츠에
role="dialog"
+
aria-label="Time picker popover"
. 팝오버 모드에서는
Popover
bubble 에도
role="dialog"
, 다이얼로그 모드에서는
Dialog
패널에
role="dialog"
+
aria-modal="true"
|
| 시 / 분 / 초 세그먼트 |
이름 없는
TextField
(
keyboardType: .number
, 숫자만 · 최대 2자). 시각 라벨은 시맨틱으로 연결되지 않은 형제
Text
입니다
|
<input type="text" inputmode="numeric" pattern="[0-9]*" maxlength="2" autocomplete="off">
+
aria-label
(
Hour
/
Minute
/
Second
)
|
| AM / PM | ToggleGroup — 각 칸이 Semantics(checked:, inMutuallyExclusiveGroup: true) 입니다 |
ToggleGroup
— 트랙이
role="radiogroup"
, 각 칸이
role="radio"
+
aria-checked
입니다
|
| Cancel / Save | Button (텍스트 라벨) |
<button> (텍스트 라벨) |
키보드#
| 키 | 동작 |
|---|---|
Tab | 트리거 → (패널이 열려 있으면) 시 → 분 → 초 → AM/PM → Cancel → Save. 양 플랫폼 동일 |
Enter / Space | 트리거·AM/PM 칸·Cancel·Save 를 활성화합니다 |
| 숫자 입력 | 각 세그먼트에 두 자리를 직접 입력합니다. 시 · 초는 이것이 값을 바꾸는 유일한 경로입니다 |
Escape |
Web 만
패널/다이얼로그를 닫습니다. Flutter 는 팝오버 모드에서 동작하지 않고,
mode: CorePromptMode.dialog
일 때만
showDialog
라우트가 Escape 를 처리합니다
|
↑ / ↓ |
분 세그먼트
에서
minuteInterval
단위로 다음 · 이전 배수로 옮깁니다 (양 플랫폼 동일, 누르고 있으면 반복). 시 · 초 세그먼트에서는 값 증감이 없고, Flutter 에서는 프레임워크 기본 방향 포커스 이동만 일어납니다
|
Page Up / Page Down | 없음 |
스크린 리더#
-
트리거의 접근 가능한 이름은 표시 문자열입니다. 선택된 시각(또는
placeholder)이 그대로 필드의 값으로 읽힙니다.TimePicker에는label파라미터가 없으므로 필드 이름이 필요하면 호출부에서 감싸십시오. -
패널은 Web 에서만 이름이 있습니다.
timePickerPopoverLabel(Time picker popover)이role="dialog"와 함께 붙습니다. Flutter 에는 대응하는Semantics가 없어 패널이 이름 없는 위젯 더미로 읽힙니다. -
세그먼트 이름도 Web 에만 있습니다. Web 은
Hour/Minute/Second를aria-label로 붙이지만, Flutter 에서는 같은 세 입력이 이름 없이 읽히고 아래의 시각 라벨은 별개의 텍스트로 따로 읽힙니다. -
AM / PM 선택 상태는
ToggleGroup이 통보합니다. 선택한 칸이aria-checked/Semantics(checked:)로 읽힙니다 — 색과 올라온 면만으로 표현되지 않습니다. - 라이브 리전은 없습니다. 세그먼트를 편집해도 값 변경이 통보되지 않습니다.
포커스 관리#
-
진입 — 팝오버 모드에서는 포커스가 트리거에 그대로 남습니다. 세그먼트에 autofocus 가 없고, Flutter 의 비모달
Popover는 감싸는 스코프에 포커스된 자식이 없을 때만 스코프 포커스를 요청하므로 트리거를 눌러 연 직후에는 사실상 발화하지 않습니다.mode: CorePromptMode.dialog에서는 FluttershowDialog라우트가 자기 포커스 스코프를 가져가고, Web 은 배경이inert가 되어 첫Tab이 패널 안으로 들어갑니다. -
이탈 — 팝오버가 닫힐 때 트리거로 포커스를 되돌리지 않습니다(양 플랫폼). 다이얼로그 모드의 Flutter
showDialog는 라우트가 pop 되며 이전 포커스를 복원합니다. -
트랩 — 팝오버 모드에는 없습니다. Web 패널은 오버레이 레이어로 포탈되므로 트리거에서
Tab을 눌러도 패널로 바로 가지 않고 문서 순서를 계속 걸어갑니다. 다이얼로그 모드에서만 트랩이 생깁니다 — Web 은OverlayHost가 앱 서브트리에inert를 걸어Tab이 패널 안에서만 돌고, Flutter 는 모달 라우트가 포커스를 가둡니다.
알려진 제약#
-
Flutter 쪽에는 시맨틱 배선이 없습니다.
time_picker.dart전체에Semantics가 한 건도 없어, Web 이 emit 하는 패널role="dialog"/aria-label과 시·분·초 입력의aria-label에 대응하는 것이 Flutter 에는 없습니다. Flutter 에서 세 세그먼트는 이름 없는 입력 세 개로 읽히고, 시각 라벨은 시맨틱으로 연결되지 않은 형제Text입니다. 대안은 없습니다 —timePickerPopoverLabel/timeHour문자열은CoUILocalizations에 존재하지만 Flutter 위젯이 읽지 않습니다. -
팝오버가 열려도 포커스가 패널로 이동하지 않고, 닫힐 때 트리거로 되돌아오지도 않습니다.
Popover를 기본값(modal: false)으로 합성하고 세그먼트에 autofocus 를 두지 않기 때문입니다. Flutter 는 비모달 경로에도FocusScope(autofocus: …)가 남아 있지만 감싸는 스코프에 이미 포커스된 자식이 있으면 발화하지 않아, 트리거를 눌러 연 뒤에는 사실상 포커스가 움직이지 않습니다.mode: CorePromptMode.dialog로 두면 Flutter 는showDialog라우트가, Web 은OverlayHost의inert배경이 배경을 포커스 대상에서 제외합니다. -
시 · 초 값은 화살표 키로 증감할 수 없습니다. 분 세그먼트만
↑/↓를 처리하고(minuteInterval단위), 시 · 초 세그먼트에는 키 핸들러가 없어(Web 은input이벤트만, Flutter 는 숫자 전용TextField) 값 변경은 두 자리 숫자를 직접 입력하는 것으로만 가능합니다. 분 세그먼트의 증감도 스핀버튼 역할(role="spinbutton")이나 값 범위 속성을 노출하지 않으므로, 화살표 키로 바뀐 값은 보조 기술에 별도로 통보되지 않습니다. -
errorText는 트리거TextField가 그립니다. 양 플랫폼 모두 이 값을 필드에 넘기므로 테두리와 포커스 링이error색이 되고 메시지가 필드 아래에 나옵니다(비활성이면 그리지 않습니다). 필드의 오류 시맨틱을 그대로 따릅니다.
최소 터치 타겟(WCAG 2.2 AA)은 24×24 논리 픽셀입니다. 트리거는 필드 기본 높이(40px)라 이미 그보다 큽니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 트리거 | 읽기 전용 TextField — 부모 폭을 채웁니다 |
읽기 전용 TextField — 루트 <div> 가 부모 폭을 채웁니다 |
| 시간 형식 폴백 | MediaQuery의 alwaysUse24HourFormat | 브라우저의 prefersHour12Format 판정 |
| 세그먼트 편집 | TextEditingController / FocusNode 기반 입력 | DOM 문자열 상태·focus/blur 이벤트 |
분 세그먼트 ↑ / ↓ |
CallbackShortcuts 가 분 TextField 의 키를 가로채 컨트롤러 텍스트를 바꿈 |
분 <input> 의 keydown 핸들러가 기본 동작을 막고 상태 문자열을 바꿈 |
| popover | PopoverController와 Flutter overlay | PopoverController와 portal/DOM overlay |
| dialog | showDialog의 위젯 lifecycle | Dialog의 DOM 표시 상태 |
| 세그먼트 hover 상태 레이어 |
세그먼트 박스의
MouseRegion
이 hover 를 감지하고 박스의
foregroundDecoration
으로 resolver 의
segmentHoverLayerColor
를 칸 위에 깝니다
|
칸에 마커 클래스와 인라인
--coui-state-layer-hover
를 싣고, 전역 규칙이
:hover
에서
background-image
로 면 위에 깝니다
|
| 숫자 칸의 데스크톱 단계 고정 |
패널 콘텐츠 안 세그먼트 행을 감싸는
TypographyTierScope
. 기본 숫자는 역할이 없는 고정 44 라 원래 단계가 닿지 않고, 고정이 크기를 지키는 것은
valueTypography
·
segmentInputStyle.valueStyle
에 역할을 적은 경우(
CoreTextStyle.token(CoreTextStyles.displaySmall)
)입니다
|
같은 자리의 상자 없는 래퍼(
<div style="display: contents" data-coui-typography-tier="desktop">
). 기본 숫자를 역할 클래스(
text-display-small
)로 그리므로 고정이 없으면 단계를 따랐을 글자입니다. 세그먼트 행이 래퍼 안에 있으므로, 패널의 직계 자식으로 행을 고르는 선택자는 후손 선택자로 고르세요
|
값 형식은 같은 ISO 문자열이며 초 선택도 지원합니다. min/max를 저장 경로에서 강제하지 않는 현재 제약은 양쪽 모두에 있으므로 플랫폼 차이로 승인된 정책이 아니라 호출자 검증이 필요한 상태입니다.
관련 컴포넌트 (Related Components)#
- DatePicker: 날짜와 시간을 함께 선택해야 하는 경우
- TextField: 시간을 텍스트로 직접 입력받는 경우
- Select: 고정된 시간 슬롯 목록을 드롭다운으로 제공하는 경우