Calendar
캘린더 컴포넌트
목차
날짜를 시각적으로 탐색하고 선택할 수 있는 캘린더 컴포넌트입니다.
기본 사용법 (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'),
)
Live Preview#
코드 보기
class CalendarNoneExample extends StatefulComponent {
const CalendarNoneExample({super.key});
@override
State<CalendarNoneExample> createState() => _CalendarNoneExampleState();
}
class _CalendarNoneExampleState extends State<CalendarNoneExample> {
@override
Component build(BuildContext context) {
return Calendar(
selectionMode: CoreCalendarSelectionMode.none,
);
}
}
class CalendarNoneExample extends StatefulWidget {
const CalendarNoneExample({super.key});
@override
State<CalendarNoneExample> createState() => _CalendarNoneExampleState();
}
class _CalendarNoneExampleState extends State<CalendarNoneExample> {
@override
Widget build(BuildContext context) {
return Calendar(
selectionMode: CoreCalendarSelectionMode.none,
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 날짜를 시각적으로 탐색하고 선택해야 할 때
- 범위(체크인/체크아웃) 또는 다중 날짜 선택이 필요할 때
- 날짜마다 "그날 무엇이 몇 건 있는지" 를 점으로 보여 줘야 할 때 (일정 · 근태 · 식단 · 예약 현황)
대신 다른 컴포넌트를 사용하세요:
- DatePicker: 캘린더를 계속 표시하지 않고 입력 트리거에서 팝오버 · 다이얼로그로 열어야 할 때
- TextField: 월·연도 탐색과 날짜 그리드 없이 문자열만 입력받을 때. 날짜 파싱·검증은 호출부가 담당합니다
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
selectionMode |
CoreCalendarSelectionMode |
single |
선택 모드 |
value |
CoreCalendarValue? |
null |
현재 선택 값 |
onChanged |
Function(CoreCalendarValue?)? |
null |
선택 변경 콜백 |
initialView |
CoreCalendarView? |
null |
초기 표시 월/년 |
firstDayOfWeek |
int |
DateTime.sunday |
한 주가 시작하는 요일 —
DateTime.monday
…
DateTime.sunday
(
1..7
). 요일 헤더 순서와 그리드 앞뒤의 이웃 달 날짜가 따라가며, 범위 밖 값은 debug 빌드에서 거부됩니다
|
stateBuilder |
CoreCalendarDateStateBuilder? |
null |
날짜별 활성/비활성 결정 |
minDate |
DateTime? |
null |
최소 선택 가능 날짜 |
maxDate |
DateTime? |
null |
최대 선택 가능 날짜 |
markerBuilder |
CoreCalendarMarkerBuilder? |
null |
날짜별 마커(점 · 개수 · 접근성 레이블) — 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]. The weekday header is the grid's axis label, so its default role is
bodyXs
(12px) — a step below the date cells'
bodySmall
on purpose, not drift to reconcile.
|
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.
|
todayBorderColor |
CoreColor? |
Border colour of today's date cell.
null
→ [defaultTodayBorderColor]. Today is a quiet
ghost
cell marked by this line rather than by a fill, so it reads against the selected (filled) and range cells.
|
todayBorderWidth |
double? |
Border width of today's date cell (logical px, pre-scaling).
null
→ [defaultTodayBorderWidth].
|
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? |
Range band colour — fills the cells inside a selected range (between start and end, exclusive) and the inner half of each endpoint cell behind its pill.
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].
|
markerSize |
double? |
Marker dot diameter (logical px). null defers to [defaultMarkerSize]. |
markerSpacing |
double? |
Space between two marker dots (logical px).
null
defers to [defaultMarkerSpacing]. Consumed natively as
Row.spacing
on Flutter and an inline
gap
on Web.
|
markerPadding |
CoreEdgeInsets? |
Inset of the marker row from the day cell's edges. The row is anchored to the cell's bottom centre, so the bottom inset is the one that shows.
null
defers to [defaultMarkerPadding].
|
markerBorderRadius |
CoreBorderRadius? |
Marker dot corner radius. null defers to [defaultMarkerBorderRadius] (a round dot). |
markerMaxCount |
int? |
Most dots drawn for one date. null defers to [defaultMarkerMaxCount]. |
markerColor |
CoreColor? |
Marker dot fill on an unselected cell, used when the marker itself names no colour.
null
defers to [defaultMarkerColor].
|
selectedMarkerColor |
CoreColor? |
Marker dot fill on a selected cell (single / multi selection and range endpoints) — always used there, even when the marker names its own colour, so the dots stay legible on the selection fill.
null
defers to [defaultSelectedMarkerColor].
|
rangeMarkerColor |
CoreColor? |
Marker dot fill on a cell inside a selected range (between the endpoints) — always used there, for the same legibility reason as [selectedMarkerColor].
null
defers to [defaultRangeMarkerColor].
|
그리드의 글자 역할은 자리마다 의도적으로 다릅니다. 요일 헤더(
weekdayTextStyle)는 축 라벨이라 한 단계 작은bodyXs(12px)와 subtle 색을, 날짜 셀(cellTextStyle)은 읽히는 데이터라bodySmall(13px)을 씁니다. 월 제목(headerTextStyle)은 월 · 연도 보기로 바꾸는 버튼이라 버튼 라벨 역할labelMedium(13px · Medium)을 씁니다. 오늘(todayTextStyle)은bodySmallSemiBold ·primary, 선택된 날짜와 범위의 시작 · 끝(selectedTextStyle)은bodySmallMedium ·onPrimary로 크기는 날짜와 같고 굵기만 오릅니다.
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'),
)
한 주의 시작 요일 (First Day of Week)#
firstDayOfWeek 는 한 주를 어느 요일에서 시작할지 정합니다. DateTime.monday … DateTime.sunday
상수를 그대로 쓰고, 생략하면 DateTime.sunday 입니다. 이 값 하나가 요일 헤더의 순서와, 그리드 첫 줄을 채우는 이전 달 날짜의 개수를 함께 정하므로 헤더와 날짜 열이 어긋날 수 없습니다.
Calendar.single(
firstDayOfWeek: DateTime.monday,
onChanged: (value) => print('Selected: $value'),
)
Calendar.single(
firstDayOfWeek: DateTime.monday,
onChanged: (value) => print('Selected: $value'),
)
- 로케일에서 유도하지 않습니다. 한 주의 시작은 언어가 아니라 지역의 속성입니다 — 영어를 쓰는 미국은 일요일, 영국은 월요일에 시작합니다. 로케일 표가 언어 단위라 로케일 기본값은 어느 언어든 일부 사용자에게 틀리므로, 제품이 필요한 값을 호출하는 쪽이 적습니다.
-
DatePicker/DateRangePicker도 같은 이름의 같은 값을 받아 자신이 여는Calendar로 그대로 넘깁니다. - 범위 선택의 띠는 한 줄의 첫 칸·끝 칸에서 이웃 칸과 이어지지 않으므로, 그 "첫 칸" 은
firstDayOfWeek가 정한 요일입니다.
날짜 마커 (Markers)#
markerBuilder 가 날짜마다 CoreCalendarMarker? 를 돌려주면 그 날짜 숫자 아래에 점이 찍힙니다. 점 개수는
count 를 따르되 markerMaxCount(기본 3) 에서 멈추고, 정확한 개수는 접근성 이름으로 전달됩니다.
class CalendarMarkersExample extends StatelessWidget {
const CalendarMarkersExample({super.key});
static CoreCalendarMarker? _markerFor(DateTime date) =>
date.day % 5 == 0 ? CoreCalendarMarker(count: date.day ~/ 5) : null;
@override
Widget build(BuildContext context) {
return const Calendar.range(markerBuilder: _markerFor);
}
}
class CalendarMarkersExample extends StatelessComponent {
const CalendarMarkersExample({super.key});
static CoreCalendarMarker? _markerFor(DateTime date) =>
date.day % 5 == 0 ? CoreCalendarMarker(count: date.day ~/ 5) : null;
@override
Component build(BuildContext context) {
return const Calendar.range(markerBuilder: _markerFor);
}
}
CoreCalendarMarker 는 데이터입니다 — 그날 무엇이 몇 건 있는지(count), 그것이 무엇인지(semanticsLabel), 그리고 선택적으로 분류 색(color). 점의 크기 · 간격 · 모양 · 기본 색 · 선택된 날짜 위의 색은
CoreCalendarStyle 의 marker* 필드가 소유하므로 프로젝트 테마 한 곳에서 모든 캘린더의 점을 바꿀 수 있습니다.
Calendar.single(
markerBuilder: (date) => switch (schedule[date]) {
null => null,
final items => CoreCalendarMarker(
count: items.length,
semanticsLabel: '접종 ${items.length}건 예정',
),
},
calendarStyle: const CoreCalendarStyle(markerMaxCount: 4),
)
- 범위 선택과 함께 쓸 수 있습니다. 체크인 · 체크아웃 끝점과 그 사이 날짜 모두 마커를 유지합니다.
-
선택된 날짜(단일 · 다중 선택, 범위 끝점) 위의 점은 마커가 색을 지정했더라도 항상
selectedMarkerColor(기본onPrimary) 로 칠해져 선택 배경 위에서도 보입니다. - 점은 셀 레이아웃에 끼어들지 않습니다. 셀 하단에 겹쳐 그려지므로 마커가 생기거나 사라져도 그리드가 흔들리지 않고, 점을 눌러도 그 날짜가 눌립니다.
- 빌더는 매 빌드마다 보이는 날짜 셀마다 한 번씩 불립니다 — 날짜를 키로 한
Map조회처럼 가볍게 유지하세요.
표시 전용 (None)#
Calendar(
selectionMode: CoreCalendarSelectionMode.none,
)
동작 스펙 (Behavior)#
선택 모드#
CoreCalendarSelectionMode.none // 표시 전용
CoreCalendarSelectionMode.single // 단일 날짜 선택
CoreCalendarSelectionMode.range // 시작~끝 범위 선택
CoreCalendarSelectionMode.multi // 다중 날짜 선택
- Single: 클릭으로 선택, 다시 클릭하면 해제
- Range: 첫 번째 클릭=시작, 두 번째 클릭=끝. 자동 정렬(start < end)
- Multi: 클릭할 때마다 선택/해제 토글
- None: 날짜 선택 콜백을 내지 않지만 월·연도 탐색은 가능합니다.
-
선택은 내부 값에 반영한 뒤
onChanged를 호출합니다. range의 첫 클릭은single, 둘째 클릭은range값이며 같은 시작일 재클릭은 해제, 완료된 범위의 다음 클릭은 새 시작일입니다. -
stateBuilder가 있으면 그 판정이minDate/maxDate보다 우선합니다. 두 경계는 날짜 단위로 포함 범위이며, 전체 캘린더를 끄는enabled플래그는 없습니다.
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#
stateBuilder와 minDate/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)#
역할#
| 루트 | 그리드 | 행 | 요일 헤더 셀 | 날짜 / 월 / 연도 셀 | |
|---|---|---|---|---|---|
| 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 의 직접 그린 셀은 위 절의
Clickable 과 keydown 핸들러).
포커스 관리#
셀 단위 포커스만 있고 그리드 차원의 조율은 없습니다.
-
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", Flutterrole: .table)로 읽히고, 행은 "행"으로, 요일 헤더 셀은 "열 머리글"로 읽힙니다. 격자 구조는 전달되지만, 화살표 키로 그 구조를 순회하는 것(roving tabindex)은 아직 없습니다 — 위 "알려진 제약" 참조. -
마커가 있는 날짜 셀: 마커의
semanticsLabel(없으면 로케일의 개수 문구 —calendarMarkerCount, 예: "항목 2개") 이 날짜 이름에 합쳐져 "15, 접종 2건 예정, 버튼" 처럼 읽힙니다. Flutter 는 셀을MergeSemantics로 묶고, Web 은 버튼(또는 직접 그린 범위 셀) 안에sr-onlyspan 을 둡니다. 점 자체는 양쪽 모두 접근성 트리에서 빠집니다(FlutterExcludeSemantics, Webaria-hidden). 점의 색만으로 의미를 전달하지 않도록 개수 · 레이블이 항상 함께 갑니다. - 날짜 셀: "15, 버튼" 처럼 날짜 숫자와 버튼 역할만 읽힙니다. 월·연도·요일은 물론 "오늘" · "선택됨" · "범위 안" 도 전달되지 않습니다 — 선택과 오늘은 색과 글자 굵기로만 표현됩니다.
- 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)#
| 항목 | Flutter | Web |
|---|---|---|
| 그리드 의미 | Semantics 의 table / row / cell |
DOM의 grid / row / gridcell |
| 일반 날짜 활성화 | Button → Clickable |
네이티브 <button> |
| 범위 띠의 날짜 활성화 | Clickable | 직접 그린 셀의 click / Enter / Space 핸들러 |
| 프레임워크 속성 | key 와 호출부 시맨틱 래퍼 |
루트 id / attributes / 이벤트 전달 |
| 날짜 마커 | 셀 위 Stacks 오버레이 + MergeSemantics |
relative
래퍼 +
absolute
점 행(
aria-hidden
) +
sr-only
레이블
|
선택 모드 · 값 모델 · date → month → year 탐색은 공유하지만, 이것이 그리드 화살표 탐색이나 roving focus 지원을 뜻하지는 않습니다. 위 접근성 제약을 함께 확인하세요.
관련 컴포넌트 (Related Components)#
- DatePicker: 입력 필드 + 캘린더 팝업