Calendar | CoUI
LogoCoUI

Calendar

캘린더 컴포넌트

Calendar#

날짜를 시각적으로 탐색하고 선택할 수 있는 캘린더 컴포넌트입니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 날짜를 시각적으로 탐색하고 선택해야 할 때
  • 범위(체크인/체크아웃) 또는 다중 날짜 선택이 필요할 때

대신 다른 컴포넌트를 사용하세요:

  • DatePicker: 입력 필드와 결합된 날짜 선택 폼 요소일 때
  • TextField: 단순 날짜 텍스트 입력만 필요할 때

기본 사용법 (Basic Usage)#

// 단일 선택
Calendar.single(
  onChanged: (value) => print('Selected: $value'),
)

// 범위 선택
Calendar.range(
  onChanged: (value) => print('Range: $value'),
)

// 다중 선택
Calendar.multi(
  onChanged: (value) => print('Multi: $value'),
)
// 단일 선택
Calendar.single(
  onChanged: (value) => print('Selected: $value'),
)

// 범위 선택
Calendar.range(
  onChanged: (value) => print('Range: $value'),
)

// 다중 선택
Calendar.multi(
  onChanged: (value) => print('Multi: $value'),
)

Props / Parameters#

속성타입기본값설명
selectionMode CoreCalendarSelectionMode single 선택 모드
value CoreCalendarValue? null 현재 선택 값
onChanged Function(CoreCalendarValue?)? null 선택 변경 콜백
initialView CoreCalendarView? null 초기 표시 월/년
stateBuilder CoreCalendarDateStateBuilder? null 날짜별 활성/비활성 결정
minDate DateTime? null 최소 선택 가능 날짜
maxDate DateTime? null 최대 선택 가능 날짜
calendarStyle CoreCalendarStyle? null 인스턴스별 스타일 (chrome / 슬롯 / 상태 토큰)

스타일 시스템 (Style System)#

Calendar 의 모든 chrome / dimensional / 슬롯 / 상태 토큰 override 는 단일 calendarStyle: CoreCalendarStyle 슬롯으로 흐릅니다. selectionMode / initialView 같은 시맨틱 enum 과 동작 필드는 위젯에 그대로 남고, 스타일 슬롯에는 들어가지 않습니다 (원칙 6 / 7).

CoreCalendarStyle 필드#

필드타입설명
dayCellSize double? Day / month / year cell size (logical px). null defers to [defaultDayCellSize].
monthCellWidth double? Month / year grid cell width (logical px). null defers to [defaultMonthCellWidth].
cellSpacing double? Gap between calendar grid cells (logical px). null defers to [defaultCellSpacing]. Consumed natively as Column.spacing on Flutter and gap-${cellSpacing.tailwindSpace} on Web.
headerGridGapStyle CoreGapStyle? Nested [CoreGapStyle] slot for the header-row → grid gap (also reused as the inter-button gap inside the header row). Forwarded straight to Gap(gapStyle: …) by the Flutter resolver. Per- instance override is merged on top of [defaultHeaderGridGapStyle].
yearRowSpacing double? Row gap between year-grid rows (logical px). null defers to [defaultYearRowSpacing]. Consumed natively as Column.spacing on Flutter and gap-${yearRowSpacing.tailwindSpace} on Web.
endpointPillBorderRadius CoreBorderRadius? Border radius for the range endpoint pill. null defers to [defaultEndpointPillBorderRadius].
fadedOpacity double? Opacity applied to dates from the previous / next month. null defers to [defaultFadedOpacity].
yearGridSize int? Total items in the year grid. null defers to [defaultYearGridSize].
monthGridColumns int? Month grid columns. null defers to [defaultMonthGridColumns].
yearGridColumns int? Year grid columns. null defers to [defaultYearGridColumns].
navButtonStyle CoreButtonStyle? Style applied to navigation buttons (prev / next month / year arrows + the header button that opens the year/month grid).
dateCellStyle CoreButtonStyle? Style applied to each date / month / year cell button.
arrowIconStyle CoreIconStyle? Icon style used by the prev / next arrow icons inside the nav buttons.
headerTextStyle CoreTextStyle? Header label text style (the "May 2026" / "2026" / decade label). null defers to [defaultHeaderTextStyle].
weekdayTextStyle CoreTextStyle? Weekday header label text style (Sun / Mon / …). null defers to [defaultWeekdayTextStyle].
cellTextStyle CoreTextStyle? Date / month / year cell text style — applied to the cell Button.labelStyle . null defers to [defaultCellTextStyle].
selectedColor CoreColor? Background colour for the selected (start / end pill) date cell. null defers to [defaultSelectedColor].
selectedTextStyle CoreTextStyle? Text style for the selected (start / end pill) date cell. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw selectedTextColor field removed). null defers to [defaultSelectedTextStyle].
todayColor CoreColor? Background colour for today's date cell. null defers to Button's secondary variant default. Carries no defaultTodayColor . The today cell is a composed Button(variant:.secondary) , so its fill is CoreButtonStyle.defaultsByVariant[.secondary].backgroundColor and a constant here would be a second copy of it ( style-contract.md : the parent must not reach into the child's defaults). Both platforms key the override on absence, so a default would double the fill rather than replace it: Flutter passes it as dateCellDefault.copyWith(backgroundColor: merged.todayColor) , where null leaves the Button's own variant fill in place, and Web skips the override wrapper entirely ( todayColor == null emits neither class nor inline), so a constant would paint a fill on top of the Button's on every calendar.
todayTextStyle CoreTextStyle? Text style for today's date cell. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw todayTextColor field removed). null defers to [defaultTodayTextStyle].
rangeSelectionColor CoreColor? Background colour for cells inside a selected range (between start and end, exclusive). null defers to [defaultRangeSelectionColor].
rangeSelectionTextStyle CoreTextStyle? Text style for cells inside a selected range. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw rangeSelectionTextColor field removed). null defers to [defaultRangeSelectionTextStyle].
weekdayLabelTextStyle CoreTextStyle? Weekday header label text style (Sun / Mon / …). Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw weekdayLabelColor field removed). null defers to [defaultWeekdayLabelTextStyle].

Resolve chain#

design system default
  → CoreCalendarTheme.style                   // 프로젝트 공통
  → parent component slot override
      (예: CoreDatePickerStyle.calendarStyle)
  → widget.calendarStyle                      // 인스턴스별

CoreCalendarStyle.merge 는 nested slot Style (navButtonStyle 등) 을 재귀적으로 머지하므로 부모 / theme / 인스턴스 단계에서 부분 override 가 가능합니다.

사용 예#

Calendar(
  selectionMode: CoreCalendarSelectionMode.single,
  calendarStyle: CoreCalendarStyle(
    selectedColor: CoreColor.token(CoreColors.success),
    dateCellStyle: CoreButtonStyle(
      labelStyle: CoreTextStyle(fontWeight: CoreFontWeight.semiBold),
    ),
  ),
  onChanged: (value) => print(value),
)
Calendar(
  selectionMode: CoreCalendarSelectionMode.single,
  calendarStyle: CoreCalendarStyle(
    selectedColor: CoreColor.token(CoreColors.primary),
    rangeSelectionColor: CoreColor.token(CoreColors.secondary),
  ),
  onChanged: (value) => print(value),
)

빠른 오버라이드 (Chain)#

이미 만든 Calendar 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class CalendarChainExample extends StatelessWidget {
  const CalendarChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Calendar.single().withStyle(
      const CoreCalendarStyle(
        dayCellSize: CoreSpace.space40,
        cellSpacing: CoreSpace.space12,
        endpointPillBorderRadius: CoreBorderRadius.all(CoreRadius.radius8),
        headerTextStyle: CoreTextStyle.token(
          CoreTextStyles.titleSmall,
          color: CoreColor.token(CoreColors.primary),
        ),
      ),
    );
  }
}
class CalendarChainExample extends StatelessComponent {
  const CalendarChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return Calendar.single().withStyle(
      const CoreCalendarStyle(
        dayCellSize: CoreSpace.space40,
        cellSpacing: CoreSpace.space12,
        endpointPillBorderRadius: CoreBorderRadius.all(CoreRadius.radius8),
        headerTextStyle: CoreTextStyle.token(
          CoreTextStyles.titleSmall,
          color: CoreColor.token(CoreColors.primary),
        ),
      ),
    );
  }
}

변형 (Variants)#

단일 선택 (Single)#

Calendar.single(
  onChanged: (value) => print('Selected: $value'),
)

범위 선택 (Range)#

Calendar.range(
  onChanged: (value) => print('Range: $value'),
)

범위 모드에서는 듀얼 캘린더 레이아웃이 표시됩니다.

다중 선택 (Multi)#

Calendar.multi(
  onChanged: (value) => print('Multi: $value'),
)

표시 전용 (None)#

Calendar(
  selectionMode: CoreCalendarSelectionMode.none,
)

동작 스펙 (Behavior)#

선택 모드#

CoreCalendarSelectionMode.none    // 표시 전용
CoreCalendarSelectionMode.single  // 단일 날짜 선택
CoreCalendarSelectionMode.range   // 시작~끝 범위 선택
CoreCalendarSelectionMode.multi   // 다중 날짜 선택
  • Single: 클릭으로 선택, 다시 클릭하면 해제
  • Range: 첫 번째 클릭=시작, 두 번째 클릭=끝. 자동 정렬(start < end)
  • Multi: 클릭할 때마다 선택/해제 토글

CoreCalendarValue#

// 단일 선택
CoreCalendarValue.single(DateTime(2024, 3, 15))

// 범위 선택
CoreCalendarValue.range(DateTime(2024, 3, 10), DateTime(2024, 3, 20))

// 다중 선택
CoreCalendarValue.multi([DateTime(2024, 3, 5), DateTime(2024, 3, 15)])

뷰 전환#

  • CoreCalendarViewType.date: 월별 일 그리드 (6행 × 7열)
  • CoreCalendarViewType.month: 월 선택 (4×3 그리드)
  • CoreCalendarViewType.year: 연도 선택 (4×4 그리드)
  • 헤더의 월/연도를 클릭하면 상위 뷰로 전환

날짜 상태 검증#

Calendar.single(
  stateBuilder: (date) {
    if (date.isBefore(DateTime.now())) return CoreCalendarDateState.disabled;
    return CoreCalendarDateState.enabled;
  },
  onChanged: (value) => print(value),
)

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

✅ Do#

minDate/maxDate 범위 제한과 커스텀 로직을 함께 쓰려면 stateBuilder 안에서 직접 검사

Calendar.single(
  stateBuilder: (date) {
    final min = DateTime(2024, 1, 1);
    final max = DateTime(2024, 12, 31);
    if (date.isBefore(min) || date.isAfter(max)) {
      return CoreCalendarDateState.disabled;
    }
    if (date.weekday == DateTime.saturday || date.weekday == DateTime.sunday) {
      return CoreCalendarDateState.disabled;
    }
    return CoreCalendarDateState.enabled;
  },
  onChanged: (value) => print(value),
)

stateBuilder가 있으면 범위 검사를 그 안에 직접 포함시켜야 합니다 — 아래 Don't 예제처럼 minDate/maxDate와 나란히 넘겨도 minDate/maxDate 쪽은 실행되지 않습니다.


❌ Don't#

stateBuilderminDate/maxDate가 함께 적용될 거라 기대하지 않기

// ❌ 주말만 비활성화하려는 의도인데 minDate/maxDate 도 같이 넘김
Calendar.single(
  minDate: DateTime(2024, 1, 1),
  maxDate: DateTime(2024, 12, 31),
  stateBuilder: (date) =>
      (date.weekday == DateTime.saturday || date.weekday == DateTime.sunday)
          ? CoreCalendarDateState.disabled
          : CoreCalendarDateState.enabled,
  onChanged: (value) => print(value),
)

내부 날짜 상태 판정은 stateBuilder가 non-null이면 그 결과를 즉시 반환하고 minDate/maxDate 검사로는 아예 넘어가지 않습니다 — 위 코드는 주말만 비활성화될 뿐 minDate/maxDate 범위는 조용히 무시됩니다.

접근성 (Accessibility)#

역할 / Semantics#

루트그리드요일 헤더 셀날짜 / 월 / 연도 셀
Flutter Semantics(container: true, label:) — single · range 레이아웃 공통. 역할은 없습니다. Semantics(container: true, role: .table) Semantics(container: true, role: .row) Semantics(container: true, role: .columnHeader) (날짜 그리드만 — 월/연도 그리드엔 헤더 행이 없음) Semantics(container: true, role: .cell) 로 감싼 Button 이 그 안에서 Clickable 을 통해 Semantics(button: true, enabled:) 를 겹침
Web <div> + aria-label (역할 없음, single · range 공통) role="grid" role="row" role="columnheader" (날짜 그리드만) role="gridcell" ( display: contents ) 로 감싼 네이티브 <button type="button"> . 비활성 시 disabled + tabindex="-1" + aria-disabled="true"

라벨 문자열은 양쪽 모두 로케일(calendarLabel)에서 옵니다.

table/grid 역할은 루트가 아니라 그리드 자체(날짜·월·연도 세 뷰 모두)에 있습니다 — 루트는 헤더 내비게이션 버튼도 함께 담고 있어 그 전체에 grid 역할을 씌우면 내비게이션 버튼까지 그리드 셀처럼 읽히기 때문입니다. role="gridcell"/Semantics(role: .cell) 은 셀을 감싸는 별도 wrapper 에 있고 Button 자신에는 없습니다 — 한 요소는 ARIA 역할을 하나만 가질 수 있어, 버튼이 이미 갖는 role="button"/button: true 시맨틱과 겹칠 수 없기 때문입니다(Web 의 wrapper 는 display: contents 로 레이아웃엔 관여하지 않습니다). aria-selected / aria-current 는 여전히 어느 플랫폼에도 없습니다 — 선택 상태는 여전히 Button variant 색으로만 표현됩니다.

range 모드의 범위 내부 셀과 시작/끝 셀만 Button 이 아니라 직접 그린 요소입니다 — 범위 띠가 끝점 알약 뒤로 이어져야 해서 컴포넌트를 합성하지 않습니다. 대신 버튼이라는 사실을 스스로 진술합니다: Flutter 는 Clickable(Semantics(button: true) + Enter/Space + 포커스 링), Web 은 role="button" + tabindex="0" + Enter/Space keydown 핸들러. 포인터로 고를 수 있는 날짜는 키보드로도 고를 수 있습니다.

stateBuilder 가 비활성으로 판정한 셀도 버튼으로 남습니다 — Web 은 role="button" 을 유지한 채 tabindex 대신 aria-disabled="true" 를, Flutter 는 Clickable(enabled: false) 를 씁니다. 역할을 빼버리면 고를 수 없는 날짜가 달 주변의 빈 칸과 구분되지 않는데, "지금은 못 고름"과 "날짜가 아님"은 같은 질문에 대한 다른 답입니다.

키보드#

동작
Enter포커스된 날짜 / 월 / 연도 / 네비게이션 버튼 활성화
Space위와 동일

이것이 전부입니다. 화살표 키 날짜 이동, Home / End, PageUp / PageDown, Escape 는 어느 플랫폼에도 없습니다. 캘린더 자체는 그리드 차원의 키 핸들러를 설치하지 않으며, 위 두 키는 셀이 각자 제공합니다(Button 은 Flutter Clickable 의 activation shortcut / Web 네이티브 <button> 의 기본 동작, range 의 직접 그린 셀은 위 절의 Clickablekeydown 핸들러).

포커스#

셀 단위 포커스만 있고 그리드 차원의 조율은 없습니다.

  • Flutter: 각 셀이 자기 FocusNode 를 갖고 FocusOutline 링을 그립니다(range 의 직접 그린 셀도 Clickable 기본값으로 동일).
  • Web: 네이티브 버튼 포커스 + focus-visible 링 클래스, range 의 직접 그린 셀은 tabindex="0".
  • roving tabindex 가 없어 날짜 하나하나가 독립된 탭 스톱입니다 — 한 달 그리드는 주 단위로 채워져 대개 35–42칸이고, 헤더·네비게이션 버튼까지 더한 만큼을 Tab 으로 지나야 그리드를 벗어납니다.
  • 초기 포커스/autofocus 대상이 없고, 뷰 전환(date → month → year)이나 월 이동 후 포커스를 복원하지 않으며, 포커스 트랩도 없습니다.

스크린 리더#

  • 루트: 양 플랫폼 모두 로컬라이즈된 라벨을 가진, 역할 없는 컨테이너로 읽힙니다 — single · range 레이아웃에 동일하게 적용됩니다. 예전엔 Web 루트가 role="application" 이라 리더의 브라우즈 모드(가상 커서) 탐색 전체를 껐지만, 이 요소는 Enter/Space 외의 어떤 키도 가로챈 적이 없어 얻는 것 없이 탐색만 막고 있었습니다 — 지금은 제거되었습니다.
  • 그리드: 날짜/월/연도 그리드 자체는 이제 "표" (Web role="grid", Flutter role: .table)로 읽히고, 행은 "행"으로, 요일 헤더 셀은 "열 머리글"로 읽힙니다. 격자 구조는 전달되지만, 화살표 키로 그 구조를 순회하는 것(roving tabindex)은 아직 없습니다 — 위 "알려진 제약" 참조.
  • 날짜 셀: "15, 버튼" 처럼 날짜 숫자와 버튼 역할만 읽힙니다. 월·연도·요일은 물론 "오늘" · "선택됨" · "범위 안" 도 전달되지 않습니다 — 선택과 오늘은 Button variant 색으로만 표현됩니다.
  • range 의 범위 내부·시작/끝 날짜도 버튼으로 읽히지만, 마찬가지로 숫자만 말할 뿐 범위 안이라는 사실은 전달되지 않습니다.
  • 비활성 날짜(stateBuilder)는 모든 셀에서 전달됩니다 — Button 셀과 직접 그린 range 셀 모두 Web 은 aria-disabled, Flutter 는 Semantics(enabled: false) 로 알립니다.

알려진 제약#

  • grid 구조는 있지만 roving tabindex 는 없습니다. role="grid" / Semantics(role: .table) 이 셀 사이의 2D 관계를 스크린 리더에 알리긴 하지만, 화살표 키로 그 관계를 따라 이동하는 것은 별도 기능입니다 — 지금은 화살표 키도 roving tabindex 도 없어 키보드 사용자는 원하는 날짜까지 Tab 으로 셀 하나하나를 거쳐야 합니다. grid 역할을 부여했다는 것은 "이 구조를 화살표 키로 순회할 수 있다"는 기대를 스크린 리더 사용자에게 주므로, 그 기대에 실제로 부응하는 roving tabindex 는 이 구조 위에 올라갈 후속 작업입니다.
  • 선택 상태가 비시각적으로 전달되지 않습니다. aria-selected / aria-current 가 없어 선택된 날짜와 오늘은 색으로만 구분되며, 스크린 리더 사용자와 색 구분이 어려운 사용자 모두에게 도달하지 않습니다. 범위 안에 든 날짜도 마찬가지로 색으로만 표시됩니다.
  • 날짜 셀이 날짜를 말하지 않습니다. "15" 만 읽히므로 어느 달·어느 요일인지 알 수 없습니다.
  • Web 은 aria-label 을 호출자 attributes 뒤에 병합하므로 소비자가 그 값을 덮어쓸 수 없습니다.

위 항목들은 현재 코드 상태이며, 캘린더를 접근 가능한 형태로 출하해야 한다면 소비자가 직접 보완해야 합니다. 모든 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축에 있습니다.

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

항목FlutterWeb
클래스명CalendarCalendar
값 모델CoreCalendarValueCoreCalendarValue
선택 모드none/single/range/multinone/single/range/multi
뷰 전환date → month → yeardate → month → year
범위 레이아웃듀얼 캘린더듀얼 캘린더
네비게이션 Button(variant: .outline) Button(variant: .outline)
날짜 셀 Button(variant: .ghost/.primary/.secondary) Button(variant: .ghost/.primary/.secondary)