Calendar
Calendar.Root는 선택한 날짜 하나를 Temporal.PlainDate로 가지고, mode="range"이면 날짜 범위를 가집니다. Calendar.Header는 이전 달과 다음 달로 가는 버튼 사이에 연월을 보여 주고, Calendar.Grid는 그 달의 날짜를 한 줄에 한 주씩 보여 줍니다. 연월을 누르면 날짜 대신 열두 해가 한 페이지로 나오고, 옆의 버튼은 이전과 다음 페이지로 갑니다. 해를 고르면 그해의 달이 다시 나오고, 달과 날은 되도록 유지됩니다. 루트는 value와 onValueChange, 또는 defaultValue를 받고, 사람이 고른 날짜로 onValueChange를 호출합니다.
이 페이지의 내용
<Calendar.Root aria-label="Start date" value={date} onValueChange={setDate}>
<Calendar.Header />
<Calendar.Grid />
</Calendar.Root>
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|
null폼의 날짜에는 Field.DatePicker를 쓰세요. 필드 안의 버튼이 이 달력을 팝오버로 엽니다.
범위와 로캘
min과 max는 고를 수 있는 날짜의 범위를 정하고, 달 버튼은 그 달에서 멈추며, 그 밖의 해는 비활성화됩니다. isDateDisabled는 주말이나 공휴일처럼 다른 날짜를 제외합니다. min과 max 밖의 날은 비활성화된 버튼입니다. isDateDisabled가 제외한 날짜는 포커스를 받을 수 있어서 스크린 리더가 읽을 수 있지만, 누르거나 Enter를 눌러도 선택되지 않습니다.
값이 없으면 그리드는 defaultMonth에서, 없으면 오늘이 있는 달에서 시작하며, min과 max 안에 머뭅니다. 달과 요일 이름은 locale을, 없으면 브라우저의 로캘을 따릅니다. 첫 열은 1(월요일)부터 7(일요일)까지의 firstDayOfWeek입니다. 없으면 브라우저가 Intl.Locale로 알려 주는 로캘의 첫 요일을, 알려 주지 않으면 일요일을 씁니다.
서버 렌더는 오늘을 모르므로, value나 defaultMonth가 없으면 페이지가 하이드레이션될 때까지 그리드에 주가 없고, 오늘 표시도 그때 붙습니다. 달력을 서버에서 렌더링할 때는 locale과 firstDayOfWeek도 넘겨서 서버와 브라우저가 같은 이름과 열을 그리게 하세요.
범위
mode="range"이면 Calendar.Root는 시작일과 종료일을 모두 포함하는 { start, end } 형태의 DateRange를 가지고, value, defaultValue, onValueChange도 범위를 다룹니다. 처음 고른 날짜가 범위를 시작하고 두 번째로 고른 날짜가 끝내며, 순서는 상관없습니다. onValueChange는 두 번째 날짜를 고를 때 범위를 받고, 같은 날을 두 번 고르면 하루짜리 범위가 됩니다. 그 전까지 그리드는 포인터를 올린 날, 없으면 포커스가 있는 날이 범위를 끝낼 수 있을 때 첫 번째로 고른 날부터 그날까지 범위를 미리 보여 주고, 끝낼 수 없으면 첫 번째로 고른 날만 보여 줍니다. Escape를 누르거나, Android 뒤로 가기 제스처 같은 모달의 닫기 요청이 오거나, min, max, isDateDisabled가 바뀌어 첫 번째로 고른 날을 제외하면 첫 번째 선택이 취소됩니다. 범위에는 고를 수 있는 날만 들어가므로, 첫 번째 선택 뒤에는 isDateDisabled, min, max가 제외한 날을 넘어선 날로 범위를 끝낼 수 없습니다. 그런 날을 찾으려고 isDateDisabled는 첫 번째로 고른 날부터 보이는 달까지, min과 max 안에서, 제외할 날이 나올 때까지 하루씩 불립니다. 렌더링 한 번에는 그중 약 2년 치 날만 확인하고 나머지는 프레임 사이에 조금씩 나누어 확인하며, 탐색이 닿기 전까지 그날로는 범위를 끝낼 수 없습니다. 이 탐색은 isDateDisabled, 첫 번째 선택, min, max가 그대로인 동안 답을 다시 쓰므로, 렌더링마다 새로 만든 함수는 다시 탐색하고, 끝나지 않은 탐색은 이 중 하나가 바뀌면 처음부터 다시 시작하므로 먼 범위에는 바뀌지 않는 함수가 필요합니다. 예약된 날처럼 바뀌는 데이터를 읽는 함수는 그 데이터가 바뀔 때 새로 만들어야 하고, 그러면 탐색은 첫 번째 선택부터 다시 시작합니다. 탐색과 별개로, 그리드가 그리는 날은 렌더링마다 확인합니다. 새 value가 알렸지만 아직 답이 없는 마지막 여덟 번의 선택 중 하나와 같으면, 아무리 늦게 와도 부모가 그 선택에 답한 것으로 보고 그리드를 옮기지 않으며 그사이 시작한 범위도 취소하지 않습니다. 그 선택과 그보다 오래된 선택은 답한 것으로 치므로, 빠르게 한 선택에 차례로 답할 수 있습니다. 그 밖의 새 value는 첫 번째 선택을 취소하고 그리드를 그 시작일로 옮깁니다. 다만 포커스가 있는 날이 범위의 한쪽 끝이면 옮기지 않습니다. 그래서 부모가 선택을 받아들이지 않았다가 그사이 다른 값 없이 나중에 같은 값을 직접 설정하면 그리드는 그곳으로 옮겨 가지 않습니다. 해 페이지에서는 범위가 걸친 해가 모두 선택되고, 범위가 종료일을 기다리는 동안에는 첫 번째로 고른 날의 해만 선택됩니다.
<Calendar.Root aria-label="Stay" mode="range" value={range} onValueChange={setRange}>
<Calendar.Header />
<Calendar.Grid />
</Calendar.Root>
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
2026-10-12/2026-10-16폼의 날짜 범위에는 Field.DateRangePicker를 쓰세요.
Temporal
Todae는 Temporal 폴리필을 포함하지 않습니다. Calendar.Root는 렌더링할 때 Temporal을 호출하므로, 브라우저에 Temporal이 없는 곳에서는 앱이 폴리필을 불러옵니다(네이티브 플랫폼 참고).
접근성을 지키는 조합
Todae가 맡는 것: Calendar.Root는 role="group"입니다. Calendar.Grid는 Calendar.Header가 렌더링되어 있는 동안 헤더의 달로 이름이 붙는 role="grid" 표이며, 요일마다 abbr가 전체 이름인 열 머리글이 있습니다. Tab 정지점은 하나입니다. 화살표 키는 하루와 한 주씩, Home과 End는 주의 처음과 끝으로, Page Up과 Page Down은 한 달씩, Shift와 함께면 한 해씩 움직입니다. 오른쪽에서 왼쪽으로 읽는 페이지에서는 왼쪽과 오른쪽이 바뀝니다. 날마다 전체 날짜로 이름 붙은 버튼이고, 선택한 날의 칸에는 aria-selected가, 오늘에는 aria-current="date"가 붙습니다. isDateDisabled가 제외한 날짜에는 aria-disabled가 붙고, min과 max 밖의 날은 비활성화됩니다. 연월은 해가 보이는 동안 true인 aria-expanded를 가진 버튼이고, 라이브 영역 안에 있어서 바뀌면 스크린 리더가 알려 줍니다. 해 페이지도 Tab 정지점이 하나인 role="grid"입니다. 화살표 키는 한 해와 한 줄씩, Home과 End는 줄의 처음과 끝으로, Page Up과 Page Down은 한 페이지씩 움직입니다. 연월 옆의 버튼은 StringsProvider의 previousMonth와 nextMonth 문자열로, 해가 보이는 동안은 previousYears와 nextYears로 이름이 붙고, 이 버튼들과 연월은 GlyphsProvider의 chevron 글리프를 그립니다. disabled나 비활성화된 Fieldset은 모든 버튼을 비활성화합니다. 범위 모드에서는 그리드와 해 페이지에 aria-multiselectable이 붙고 범위가 걸친 모든 날과 해에 aria-selected가 붙으며, 범위가 종료일을 기다리는 동안에는 첫 번째로 고른 날과 그 해에만 붙습니다. 첫 번째 선택 뒤에는 pickEndDate 문자열을 라이브 영역으로 알려 주고, 범위를 끝낼 수 없는 날에는 aria-disabled가 붙지만 포커스는 받을 수 있습니다. 이때 Escape나 Android 뒤로 가기 제스처 같은 모달의 닫기 요청은 첫 번째 선택만 취소하고 거기서 멈추므로, 달력을 감싼 팝오버나 대화상자는 열린 채로 남습니다.
직접 제공할 것: aria-label이나 aria-labelledby로 그룹의 이름, 그리고 Calendar.Header가 없으면 그리드의 이름. 페이지의 언어가 브라우저의 언어와 다르면 locale.
주의할 점: isDateDisabled로 날짜를 제외하면서 이유를 알리지 않는 것. "평일만"처럼 규칙을 가까이에 설명하세요.
언제 쓰나요
언제: 예약 화면처럼 페이지에 펼쳐 둔 달력에서 날짜 하나를 고를 때.
쓰지 않을 때: 폼 필드의 날짜 → Field.DatePicker. 폼 필드의 날짜 범위 → Field.DateRangePicker. 시각이 있는 날짜 → Field.DateTimePicker.
Props
| 이름 | 타입 | 설명 |
|---|---|---|
mode | 'single' | 'range' | 날짜 하나, 또는 "range"이면 날짜 범위. 첫 번째로 고른 날이 범위를 시작하고 두 번째로 고른 날이 끝냅니다. 기본값: 'single' |
value | Temporal.PlainDate | DateRange | null | 제어되는 선택 날짜, mode="range"이면 선택 범위, 없으면 null. onValueChange와 함께 전달하세요. |
defaultValue | Temporal.PlainDate | DateRange | null | 비제어일 때 처음 선택된 날짜, mode="range"이면 처음 범위. |
onValueChange | ((value: Temporal.PlainDate) => void)| ((value: DateRange) => void) | 사람이 고른 날짜마다, 이미 선택된 날짜라도 받아 호출됩니다. mode="range"이면 두 번째 날짜를 고를 때 범위를 받아 호출됩니다. |
min | Temporal.PlainDate | 고를 수 있는 가장 이른 날짜. |
max | Temporal.PlainDate | 고를 수 있는 가장 늦은 날짜. |
isDateDisabled | (date: Temporal.PlainDate) => boolean | 공휴일처럼 고를 수 없는 날짜에 true를 돌려줍니다. 범위 모드에서는 범위에 그런 날짜가 들어갈 수 없습니다. |
defaultMonth | Temporal.PlainYearMonth | 값이 없을 때 처음 보이는 달. 없으면 오늘이 있는 달. |
firstDayOfWeek | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 그리드의 첫 열, 1(월요일)부터 7(일요일)까지. 없으면 브라우저가 알려 주는 로캘의 첫 요일, 그것도 없으면 7. |
locale | string | 달과 요일 이름의 로캘, 예: "ko". 없으면 브라우저의 로캘. |
disabled | boolean | 모든 파트가 날짜와 달을 바꾸지 못하게 합니다. 비활성화된 Fieldset도 이를 설정합니다. |
| 이름 | 타입 | 설명 |
|---|
| 이름 | 타입 | 설명 |
|---|