TimePicker | CoUI
본문으로 건너뛰기
LogoCoUI

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)#

다이얼로그 모드#

Web
Flutter
Loading Flutter...
코드 보기
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)#

Web
Flutter
Loading Flutter...
코드 보기
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),
      ),
    );
  }
}

에러 상태#

Web
Flutter
Loading Flutter...
코드 보기
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 세그먼트 구분자
enabledbooltrue활성화 여부
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 헤더 제목
errorTextString?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)#

역할#

요소FlutterWeb
루트없음역할 없는 <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 에서는 Flutter showDialog 라우트가 자기 포커스 스코프를 가져가고, 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)#

항목FlutterWeb
트리거 읽기 전용 TextField — 부모 폭을 채웁니다 읽기 전용 TextField — 루트 <div> 가 부모 폭을 채웁니다
시간 형식 폴백MediaQuery의 alwaysUse24HourFormat브라우저의 prefersHour12Format 판정
세그먼트 편집TextEditingController / FocusNode 기반 입력DOM 문자열 상태·focus/blur 이벤트
분 세그먼트 ↑ / ↓ CallbackShortcuts 가 분 TextField 의 키를 가로채 컨트롤러 텍스트를 바꿈 분 <input> 의 keydown 핸들러가 기본 동작을 막고 상태 문자열을 바꿈
popover PopoverController와 Flutter overlay PopoverController와 portal/DOM overlay
dialogshowDialog의 위젯 lifecycleDialog의 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를 저장 경로에서 강제하지 않는 현재 제약은 양쪽 모두에 있으므로 플랫폼 차이로 승인된 정책이 아니라 호출자 검증이 필요한 상태입니다.

  • DatePicker: 날짜와 시간을 함께 선택해야 하는 경우
  • TextField: 시간을 텍스트로 직접 입력받는 경우
  • Select: 고정된 시간 슬롯 목록을 드롭다운으로 제공하는 경우