TimePicker | CoUI
LogoCoUI

TimePicker

시간을 선택하는 시간 선택기 컴포넌트

TimePicker#

시간(시·분·초)을 선택할 수 있는 시간 선택기 컴포넌트입니다. 12시간/24시간 형식과 선택적인 초 표시를 지원합니다.

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: 고정된 시간 슬롯 목록에서 선택해야 하는 경우

기본 사용법 (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,
)

// 선택 가능 범위 제한 (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,
)

// 선택 가능 범위 제한
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,
              triggerContentGapStyle: CoreGapStyle(size: CoreSpace.space12),
              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,
            triggerContentGapStyle: CoreGapStyle(size: CoreSpace.space12),
            trailingIconStyle: CoreIconStyle(
              size: CoreIconSize.size20,
              color: CoreColor.token(CoreColors.primary),
            ),
          ),
        ),
      ],
      classes: 'w-${CoreSpace.scale.space256}',
    );
  }
}

Props / Parameters#

공통 Contract 필드 (CoreTimePickerContract):

속성FlutterWeb기본값설명
value String? String? null 현재 선택된 시간 (ISO 형식: HH:MM 또는 HH:MM:SS)
placeholder String? String? null (미지정 시 세그먼트 글리프로 조립) 미선택 시 표시될 텍스트
placeholderSegment String String '--' 미선택 세그먼트(시/분/초) 하나를 나타내는 글리프
placeholderSeparator String String ':' 인접한 placeholder 세그먼트 사이 구분자
enabled bool bool true 활성화 여부
showSeconds bool bool false 초 선택 포함 여부
use24HourFormat bool? bool? null (플랫폼 기본값) 24시간 형식 사용 여부
min String? String? null 최소 선택 가능 시간 (ISO 문자열)
max String? String? null 최대 선택 가능 시간 (ISO 문자열)
onChanged ValueChanged<String>? CoreValueChanged<String>? null 시간 변경 콜백 (ISO 문자열)
mode CorePromptMode? CorePromptMode? CorePromptMode.popover 트리거 클릭 시 popover/dialog 중 어떤 surface로 열릴지
popoverPlacement CorePopoverPlacement CorePopoverPlacement CorePopoverPlacement.bottomStart popover 모드에서 패널이 트리거 기준 어디에 열릴지
dialogTitle Widget? Component? null mode = dialog일 때 dialog 헤더에 표시되는 타이틀
errorText String? String? null 에러 메시지(있으면 보더가 error 색으로 표시됨, TextField와 패리티)
timePickerStyle CoreTimePickerStyle? CoreTimePickerStyle? null 인스턴스 스타일 (Style 시스템 참조)

스타일 시스템 (Style System)#

TimePicker의 모든 chrome / 치수 / 중첩 슬롯 오버라이드는 CoreTimePickerStyle 한곳으로 흐릅니다.

시맨틱 vs 스타일#

  • 시맨틱 enum / 동작: 위젯 파라미터로 직접 (mode, popoverPlacement, placeholder, placeholderSegment, placeholderSeparator, enabled, showSeconds, use24HourFormat, 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 슬롯 스타일 (popoverStyle / dialogStyle / segmentInputStyle / amPmButtonStyle / amPmSelectedButtonStyle / actionButtonStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다. 예: popoverStyle.triggerStyle 의 chrome 은 → CoreButtonTheme → variant 룩업 → 부모 슬롯 → widget.timePickerStyle.popoverStyle.triggerStyle 순. 마찬가지로 popover 패널 박스는 popoverStyle.panelStyle (CorePopupStyle) 을 통해 Popup 의 resolver 로 흐릅니다.

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 two 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? 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? 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.
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.labelStylegrep -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.
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? 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 / separator content text colour. Falls back to [defaultContentColor] when null.
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).
triggerBorderWidth double? Custom trigger / segment border width (pre-scaling).
triggerContentGapStyle CoreGapStyle? 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 / height of each numeric segment field (pre-scaling).
dialogLabelSpacing double? Uniform spacing between the segment field and its label (Flutter Column.spacing ) and between the AM/PM stacked toggle buttons (Flutter Column.spacing / Web inline gap ). 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).
trailingIconStyle CoreIconStyle? Trigger trailing (clock) icon style override. null → the trigger popoverStyle.triggerStyle.trailingIconStyle override, else [defaultTrailingIconStyle].

사용 예 (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: CoreButtonStyle(
        height: CoreSize.size44,
        padding: CoreEdgeInsets.symmetric(horizontal: CoreSpace.space16),
      ),
    ),
    segmentInputStyle: CoreTextFieldStyle(
      borderColor: CoreColor.token(CoreColors.primary),
    ),
    amPmButtonStyle: CoreButtonStyle(height: CoreSize.size36),
    labelStyle: CoreTextStyle(fontWeight: CoreFontWeight.semiBold),
  ),
)

동작 스펙 (Behavior)#

인터랙션#

  • 트리거 클릭: modepopover(기본)면 Popover 패널이 popoverPlacement 위치로, dialogDialog 가 중앙 모달로 열립니다 (양쪽 동일)
  • 직접 입력: 시/분/초 필드에 숫자를 직접 입력 가능 (양쪽 동일)
  • AM/PM 토글: 12시간 형식에서 AM/PM 전환 — Button 기반 (양쪽 동일)
  • 저장/취소: 패널 하단의 Button (Save / Cancel)

값 형식#

  • 값은 ISO 시간 문자열로 주고받습니다: "14:30" 또는 "14:30:45" (showSeconds 시)
  • 12시간 포맷에서도 값은 24시간 ISO로 저장되며, 디스플레이만 hh:mm AM/PM로 포맷팅됩니다

Theme 오버라이드#

CoreComponentTheme.timePicker를 통해 프로젝트 레벨의 기본 스타일(CoreTimePickerStyle)을 지정할 수 있습니다:

CoreComponentTheme(
  timePicker: CoreTimePickerTheme(
    style: CoreTimePickerStyle(
      popoverStyle: CorePopoverStyle(
        panelStyle: CorePopupStyle(
          borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
          padding: CoreEdgeInsets.all(CoreSpace.space16),
        ),
        triggerStyle: CoreButtonStyle(
          height: CoreSize.size40,
          padding: CoreEdgeInsets.symmetric(horizontal: CoreSpace.space12),
        ),
      ),
    ),
  ),
)

사용 가이드라인 (Usage Guidelines)#

✅ Do#

min / max 로 선택 가능한 범위를 원천 제한

TimePicker(
  value: businessHour,
  min: '09:00',
  max: '18:00',
  onChanged: handleBusinessHourChanged,
)

이유: min/max는 세그먼트 입력 단계에서부터 범위를 제한하므로, 사용자가 영업시간 밖 값을 아예 선택할 수 없습니다 — errorText로 사후에 경고 문구를 보여줄 필요가 없습니다.


❌ Don't#

12시간 포맷 문자열을 value/onChanged에 그대로 넘기지 않기

// ❌ "HH:MM" ISO 형식이 아닌 12시간 문자열
TimePicker(
  value: '02:30 PM',
  onChanged: (v) => save(v),
)

이유: use24HourFormatfalse여도 value/onChanged는 항상 24시간 ISO 문자열("HH:MM" 또는 "HH:MM:SS")을 주고받습니다. 12시간 포맷은 디스플레이 단계에서만 변환되므로, 12시간 문자열을 직접 넘기면 파싱에 실패하거나 의도하지 않은 시간으로 해석됩니다.

접근성 (Accessibility)#

  • 트리거에 role="button"aria-haspopup="dialog"가 붙습니다.
  • 패널은 role="dialog"로 열립니다.
  • 키보드로 시/분/초를 직접 입력할 수 있습니다 (양쪽 동일).
  • 최소 터치 타겟(WCAG 2.2 AA)은 24×24 논리 픽셀입니다. 트리거는 필드 기본 높이(40px)라 이미 그보다 큽니다.

크로스 플랫폼 차이점 (Platform Differences)#

양쪽 모두 Popover + Button + Icon을 사용해 동일한 UX를 제공합니다. 값 형식, 토큰, 동작 모두 1:1 통일되어 있습니다.

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