---
title: "日历"
description: "以日期网格显示一个月，可以放在页面里，也可以放在选择器里；用户可以从中选择一个日期或一段日期范围。月份是一列连续的周，跟随滚动和拖动在月份之间移动。"
url: "https://tanrei.chnotchy.com/zh-cn/components/calendar/"
last_updated: 2026-10-23T21:17:12.000Z
---

# 日历

> 以日期网格显示一个月，可以放在页面里，也可以放在选择器里；用户可以从中选择一个日期或一段日期范围。月份是一列连续的周，跟随滚动和拖动在月份之间移动。

## 概览

日历是嵌在页面里的月份网格。它没有自己的表面、内边距和层级，所以可以和其他内容并排放在页面里，例如预订表单或报表的筛选条件；也可以放在 [DateTimePicker](https://tanrei.chnotchy.com/zh-cn/components/date-time-picker/) 里，后者原样组合它。它选择一个日期（`mode` 为 `single`），或一段连续的日期范围（`mode` 为 `range`）。

> [!NOTE]
> **目前仅在 Web 上；其他平台在计划中**
>
> Web 组件按本页的描述实现了日历：月份是一列连续的周，跟随滚动和拖动。本页的演示就是这个组件。SwiftUI、Compose、Flutter 和 React Native 尚未实现它。它们的包里仍保留着已停用的日期选择器的代码，本页不对其作说明。

### 用法

用户选择某一天时，日历会自行设置 `value`（或 `start` 和 `end`），然后触发 `change`。你也可以自己设置它们：如果设置的日期不在当前显示的月份里，它所在的月份会被显示出来。

**Web**

```html
<tanrei-calendar id="check-in" label="入住日期" value="2026-09-17" min="2026-09-01"></tanrei-calendar>

<!-- 范围选择，空余的单元格里显示相邻月份的日期。 -->
<tanrei-calendar id="stay" mode="range" label="住宿" show-adjacent-days
  disabled-dates="2026-09-21 2026-09-22"></tanrei-calendar>

<script type="module">
  import '@tanrei/web/components/calendar';
  const checkIn = document.querySelector('#check-in');
  // 列表无法表达的规则通过属性传入：一个接收 ISO 日期、返回布尔值的函数。
  checkIn.isDateDisabled = (iso) => bookedDays.has(iso);
  // change 触发时，元素已经持有新的日期。
  checkIn.addEventListener('change', (e) => save(e.detail.value));

  // 范围选择在每次选择后都会触发 change；只选了开始日期时，end 为 ""。
  document.querySelector('#stay').addEventListener('change', (e) => show(e.detail.start, e.detail.end));
</script>
```

**React**

```tsx
import { Calendar } from '@tanrei/react';

<Calendar
  label="入住日期"
  value={checkIn}
  min="2026-09-01"
  isDateDisabled={(iso) => bookedDays.has(iso)}
  onChange={(e) => setCheckIn(e.detail.value)}
/>

<Calendar
  mode="range"
  label="住宿"
  start={stay.start}
  end={stay.end}
  showAdjacentDays
  disabledDates={['2026-09-21', '2026-09-22']}
  onChange={(e) => setStay({ start: e.detail.start, end: e.detail.end })}
/>
```

这里没有 SwiftUI、Compose、Flutter 和 React Native 的示例，因为这些平台尚未实现日历。它在这些平台上将使用的名称，见[规格](https://tanrei.chnotchy.com/zh-cn/components/calendar/#%E8%A7%84%E6%A0%BC)中的平台对照表。

### 日期选择器的去向

日期和时间用两个组件来选择：这个日历，以及容纳它的 [DateTimePicker](https://tanrei.chnotchy.com/zh-cn/components/date-time-picker/)。

| 以前 | 现在 |
| --- | --- |
| 日期选择器，模态或停靠式 | `date` 模式的 DateTimePicker，`modal` 或 `docked` |
| 放在页面里的月份网格 | 日历 |
| 带范围的日期选择器 | `mode` 为 `range` 的日历，或带 `range` 的 DateTimePicker |
| 日期选择器的文本输入形式 | `type` 为 `date` 的[文本字段](https://tanrei.chnotchy.com/zh-cn/components/text-field/)，它的选择器按钮会打开 DateTimePicker |
| 日期选择器的令牌 | 网格使用 `comp.calendar.*`，表面、页眉和操作使用 `comp.date-time-picker.*` |

在 Web 上，以前用来选择日期的元素已经不存在。原先把它的网格直接画在页面里的地方，改用 `<tanrei-calendar>`，`value`、`min`、`max`、`disabled-dates`、`today`、`locale` 和 `first-day-of-week` 保持不变；原先把它作为选择器打开的地方，改用 `<tanrei-date-time-picker>`。

### Material 3 和 Apple 的名称

- Material 3 的停靠式和模态日期选择器，是 `date` 模式的 DateTimePicker。它的日期范围选择器是同一个组件加上 `range`。m3-like 预设用 Material 的容器和圆角绘制它们，横向翻页切换月份，并把文本操作放在末尾。
- 放在页面里的日历，对应 Flutter 的 `CalendarDatePicker` 和 SwiftUI 的图形式日期选择器样式：在 SwiftUI、Compose 和 Flutter 上是 `TanreiCalendar`，在 React Native 上是 `Calendar`。
- Material 用来切换到文本输入的铅笔和键盘切换按钮没有对应物：日期在文本字段里输入。
- Apple 的图形式日期选择器是轴为水平方向的日历，cupertino 预设就是这样设置的。

## 规格

- Category: Selection
- Level: base
- Listing: default
- Used in: DateTimePicker
- Page: https://tanrei.chnotchy.com/components/calendar/
- Spec: https://github.com/chnotchy/tanrei/blob/main/specs/calendar.spec.json

### Availability

| Design system | Has it | Notes |
| --- | --- | --- |
| Tanrei (`tanrei`) | Yes | — |
| Cupertino (Apple HIG-inspired) (`cupertino`) | Yes | — |
| M3 Expressive-like (Material 3 Expressive-inspired) (`m3-expressive-like`) | Yes | — |
| M3-like (Material 3-inspired) (`m3-like`) | Yes | — |

### Platforms

- Web: the element `<tanrei-calendar>`, registered by importing `@tanrei/web` (or only its own module, `@tanrei/web/components/calendar`).
- React: `Calendar` from `@tanrei/react`.

| Platform | Name | Status |
| --- | --- | --- |
| Web | `tanrei-calendar` | lagging |
| SwiftUI | `TanreiCalendar` | planned |
| Jetpack Compose | `TanreiCalendar` | planned |
| Flutter | `TanreiCalendar` | planned |
| React Native | `Calendar` | planned |

Names a platform gives in its own idiom:

| Spec | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
| --- | --- | --- | --- | --- | --- |
| `mode` (prop) | `mode` | `range` | `mode` | `mode` | `mode` |
| `value` (prop) | `value` | `selection` | `value` | `value` | `value` |
| `start` (prop) | `start` | `range` | `start` | `start` | `start` |
| `end` (prop) | `end` | `range` | `end` | `end` | `end` |
| `min` (prop) | `min` | `minDate` | `min` | `firstDate` | `min` |
| `max` (prop) | `max` | `maxDate` | `max` | `lastDate` | `max` |
| `today` (prop) | `today` | `today` | `today` | `currentDate` | `today` |
| `showAdjacentDays` (prop) | `showAdjacentDays` | `showsAdjacentDays` | `showAdjacentDays` | `showAdjacentDays` | `showAdjacentDays` |
| `label` (prop) | `label` | `label` | `label` | `label` | `accessibilityLabel` |
| `change` (event) | `change` | `selection` | `onChange` | `onChanged` | `onChange` |

Statuses: `planned` — In the inventory. Nothing shippable exists yet. `lagging` — An implementation of an earlier spec exists; it has not caught up with the current one. `alpha` — Implemented with tests. The API may still change. `beta` — Feature complete and at parity. Only fixes are expected. `stable` — Covered by semantic versioning. `deprecated` — Still shipped; scheduled for removal.

### Anatomy

- `navigation-row`: The top row: the view toggle at the start, and the previous and next buttons at the end.
- `view-toggle`: The displayed month and year with a chevron (expand_more). Pressing it switches between the day view and the year view. It is drawn with the unselected condition's colors.
- `navigation`: The previous-month and next-month buttons: Tanrei icon-only Buttons of the text variant whose arrows follow the axis the months move along. They are hidden in the year view. A scroll of a wheel or a trackpad, a drag with a finger, a pen or a mouse, and Page Up and Page Down move the month too.
- `weekday-row`: The narrow names of the seven weekdays, from the first day of the week.
- `day`: One day in the grid (an item the calendar generates): its number on a round container that carries the day's condition and its state answer. The containers of the days inside a range join into one band. On the vertical axis the days are one unbroken column of weeks: a week two months share is one row, which a month change carries from its place in the one view to its place in the other.
- `year` (optional): One year of the year view (an item the calendar generates), on a container that carries the year's condition and its state answer.
- `focus-ring`: Gap-then-ring indicator around the container of the day, year or view toggle that has keyboard focus.

### Props

| Prop | Type | Default | Web attribute | Description |
| --- | --- | --- | --- | --- |
| `mode` | `enum`: `single`, `range` | `"single"` | `mode` | single: one date, in value. range: one contiguous span of dates, from start to end inclusive. |
| `value` | `string` | `""` | `value` | The chosen date in single mode, as an ISO 8601 calendar date ("2026-09-17"); empty when none is chosen. The calendar sets it when the person chooses a day and emits change; the product may also set it. A calendar date has no time and no time zone. |
| `start` | `string` | `""` | `start` | The first date of the chosen range in range mode, as an ISO 8601 calendar date; empty when none is chosen. |
| `end` | `string` | `""` | `end` | The last date of the chosen range in range mode, as an ISO 8601 calendar date, never before start; empty while only start is chosen. |
| `min` | `string` | `""` | `min` | The earliest date that can be chosen, as an ISO 8601 calendar date; empty for no limit. |
| `max` | `string` | `""` | `max` | The latest date that can be chosen, as an ISO 8601 calendar date; empty for no limit. |
| `disabledDates` | `strings` | `` | `disabled-dates` | Dates inside min and max that cannot be chosen (booked, closed), as ISO 8601 calendar dates. Every platform also takes a predicate (isDateDisabled) for a rule no list can hold, such as every weekend; a day is unavailable when either says so. |
| `today` | `string` | `""` | `today` | The date drawn as today, as an ISO 8601 calendar date. Empty uses the device's current date in its own time zone. Set it when the product's today is another (a venue's time zone, a server's date) and in tests. |
| `firstDayOfWeek` | `enum`: `locale`, `sunday`, `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday` | `"locale"` | `first-day-of-week` | The weekday in the first column. locale takes it from the locale (Sunday in the United States and Japan, Monday in most of Europe). |
| `locale` | `string` | `""` | `locale` | A BCP 47 language tag ("en-US", "ja-JP") for month and weekday names, digits and the first day of the week. Empty uses the platform's current locale. The calendar is the Gregorian calendar in every locale. |
| `startView` | `enum`: `day`, `year` | `"day"` | `start-view` | Whether the calendar first shows the days of a month or the list of years. year suits a date far from today, such as a birthday. |
| `showAdjacentDays` | `boolean` | `false` | `show-adjacent-days` | Fills the grid's spare cells with the days of the previous and next month, drawn quieter than the month's own. Such a day can be chosen when it is available, and a range may start or end on it; choosing one also shows its month. |
| `label` | `string` | `""` | `label` | The accessible name of the whole calendar ("Check-in date"). Needed when a page has more than one calendar, or when nothing near it says what the date is for. A picker that holds the calendar names it with its own label. |
| `startLabel` | `string` | `""` | `start-label` | Range mode: the description of the start day, in the product's language ("Start date"). The product's: Tanrei has no words for it. Empty reads the product's words. |
| `endLabel` | `string` | `""` | `end-label` | Range mode: the description of the end day, in the product's language ("End date"). The product's: Tanrei has no words for it. Empty reads the product's words. |
| `previousMonthLabel` | `string` | `""` | `previous-month-label` | The accessible name of the previous-month button, in the product's language ("Previous month"). The product's: Tanrei has no words for it. Empty reads the product's words. Required: a glyph alone is not a name. |
| `nextMonthLabel` | `string` | `""` | `next-month-label` | The accessible name of the next-month button, in the product's language ("Next month"). The product's: Tanrei has no words for it. Empty reads the product's words. Required: a glyph alone is not a name. |
| `yearViewLabel` | `string` | `""` | `year-view-label` | The description of the view toggle, whose name is the month and year it shows, in the product's language ("Choose a year"). The product's: Tanrei has no words for it. Empty reads the product's words. |
| `todayLabel` | `string` | `""` | `today-label` | Read after today's date and the current year, where the platform has no property that says so (the web's aria-current), in the product's language ("Today"). The product's: Tanrei has no words for it. Empty reads the product's words. With neither, the platform's own word for the present day in the locale, where it has one. |

### Events

| Event | Description |
| --- | --- |
| `change` | The person chose a day that changes the choice. Carries value in single mode, or start and end in range mode (end empty after a start alone). The calendar has already set the props it carries. |

### Item: `day`

One day in the grid. The calendar generates the days from the month, the bounds, the disabled dates and showAdjacentDays; a product and a fixture never list them.

Names: Web `renderDay`, SwiftUI `TanreiCalendarDay`, Jetpack Compose `TanreiCalendarDay`, Flutter `TanreiCalendarDay`, React Native `CalendarDay`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `string` | `""` | The day, as an ISO 8601 calendar date. |
| `disabled` | `boolean` | `false` | Whether the day is unavailable: before min, after max, in disabledDates or named by isDateDisabled. |
| `adjacent` | `boolean` | `false` | Whether the day belongs to the previous or next month, shown because showAdjacentDays is true. |

### Item: `year`

One year of the year view, from min's year to max's year. The calendar generates them; a product and a fixture never list them.

Names: Web `renderYear`, SwiftUI `TanreiCalendarYear`, Jetpack Compose `TanreiCalendarYear`, Flutter `TanreiCalendarYear`, React Native `CalendarYear`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `year` | `number` | `0` | The year. |

### States

`enabled`, `disabled`, `hovered`, `focused`, `pressed`, `selected`

### Accessibility

- Role: `group`
- Labeling: The calendar is a group named by label when it has one. The grid is named by the month and year it shows and its column headers are the weekdays' full names. Each day is named by its full date in the locale, with todayLabel after today's where the platform has no current-date property, and exposes its selected and disabled states; a range's start and end are described by startLabel and endLabel. The view toggle is named by its visible month and year, described by yearViewLabel, and exposes whether the year view is expanded. The navigation buttons are named by previousMonthLabel and nextMonthLabel.
- Minimum target size: yes

| Keys | Action |
| --- | --- |
| Tab / Shift+Tab | Moves through the view toggle, the previous and next buttons and the active day (or the displayed year). |
| Arrow keys | Move the active day by one day (Left and Right, in the direction the arrow points) or one week (Up and Down), changing the month as needed, whatever axis the months move along; in the year view, by one year or one row. |
| Home / End | The first or last day of the week row; the first or last year. |
| Page Up / Page Down | The same day of the previous or next month. |
| Shift+Page Up / Shift+Page Down | The same day of the previous or next year. |
| Enter or Space | Choose the active day or the focused year; activate the focused button. |

- The cells are the days' hit areas and are never smaller than the minimum target (CAL-1); the view toggle extends its hit area (CAL-6). The navigation buttons are Tanrei Buttons, which meet the target themselves (BTN-5).
- Unavailable days stay reachable by the arrow keys, as the WAI-ARIA date picker dialog pattern recommends, so a screen-reader user learns that a date exists and cannot be chosen.
- Scrolling and dragging to move the month are pointer and touch shortcuts, with a finger, a pen, a mouse, a wheel or a trackpad. The buttons and Page Up / Page Down do the same, so nothing depends on a gesture, and a drag is never the only way (WCAG 2.5.7). While a drag lasts nothing the calendar exposes changes; while a scroll goes on through several months the title follows each month it reaches, but the month is announced once, when the scroll or the release has chosen it (CAL-31, CAL-32, CAL-34), so a screen reader is not read every month that passes.
- A scroll along the calendar's axis is the calendar's while the pointer is over the day grid (CAL-33): a page that scrolls the same way does not scroll from there. The weekday row, the navigation row and everything around the calendar scroll the page as usual, and a calendar with a horizontal axis leaves the page's vertical scroll alone.
- A day of a neighbouring month that is chosen by pointer brings its month into view (CAL-15), announced as any month change is. The keys never need it: the arrow keys show a day's month before Enter can choose it (CAL-11).
- Typing a date is the Text field's job, not the calendar's: a product that wants both puts a Text field beside it, or uses a DateTimePicker opened from the field's trailing button. Typing is the fastest route for many people and the most direct one for screen reader users.
- The calendar has no words of its own. Month and weekday names, digits and date forms are the platform's locale data, in the locale the calendar was given. Every other word it shows or announces is the product's: a prop of this instance, or the product's words set once.
- The web exposes the grid with role grid, rows, column headers and gridcells with aria-selected and aria-disabled, and today with aria-current="date" (so todayLabel is not added there). The platforms without a grid role expose the rows and columns through their collection semantics where they have them (Compose's collection info), and otherwise each day as a button in reading order.
- How a state looks is a value decision of the active design system; components read the state's tokens and never compute the effect themselves.

### Behaviour

Requirements in EARS form, each with its stable id.

- **CAL-1** THE calendar SHALL lay out, from top to bottom, the navigation row, the weekday row and six rows of seven cells, each cell a square of comp.calendar.cell.size but never smaller than sys.target.touch for touch input and sys.target.pointer for fine pointers (a platform that cannot tell the input type SHALL use sys.target.touch), because the cells are the days' hit areas and cannot extend over each other; the first day of the displayed month SHALL sit in the column of its weekday, and all six rows SHALL be kept when the month needs fewer, so that the calendar keeps its height when the month changes. The navigation row and the weekday row are each one cell tall. THE calendar SHALL draw no surface and no padding of its own: it is as wide as its seven columns, and the page or the picker that holds it gives it its background and inset.
- **CAL-2** THE calendar SHALL draw each day from exactly one condition, by precedence: selected for the date of single mode and for a range's start and end; in-range for a day strictly between a range's start and end; today for the date today names (or the device's current date) when it is neither; adjacent for a shown day of the previous or next month (CAL-15) when it is none of these; otherwise unselected. It SHALL draw each year of the year view from selected for the displayed year, today for the current year when it is not displayed, and otherwise unselected, and the view toggle from unselected. WHILE a day is unavailable THE calendar SHALL draw it from the disabled tokens of its condition.
- **CAL-3** THE calendar SHALL draw a day's number in the locale's digits, centred in its cell, in comp.calendar.day.label.typescale and the label color of its condition (comp.calendar.unselected.label.color, and so on), on a container of side comp.calendar.day.container.size centred in the cell, with comp.calendar.day.container.shape corners eased by sys.shape.corner-smoothing, in the container color of its condition, with an outline of comp.calendar.outline.width in the outline color of its condition. The container of an in-range day SHALL instead span the full width of its cell at the same height, so that the containers of consecutive in-range days join into one band that breaks at the ends of a row with square ends; WHILE a range's start and end differ each of them SHALL also draw, behind its own container, the half of its cell toward the other in comp.calendar.in-range.container.color, so that the band runs into it. The band SHALL cover every day the grid shows between start and end, and no empty cell.
- **CAL-4** THE calendar SHALL draw each weekday's narrow name from the locale ("S", "M" in English; "日", "月" in Japanese) centred in its column, in comp.calendar.weekday.typescale and comp.calendar.weekday.color, and SHALL expose it as a column header named by the weekday's full name; weekday names SHALL take no focus.
- **CAL-5** THE calendar SHALL draw the navigation row with the view toggle at the start and, in the day view, the previous-month and next-month buttons at the end, comp.calendar.navigation.gap apart, each a Tanrei Button of the text variant and the medium size with an icon and no label, named previousMonthLabel and nextMonthLabel. WHILE comp.calendar.navigation.axis is vertical their glyphs SHALL be arrow_upward and arrow_downward; WHILE it is horizontal they SHALL be arrow_back for the previous month and arrow_forward for the next in every layout direction, pointing toward the start and the end: in a right-to-left layout the icon helper mirrors both, because the icon set marks them as mirrored, and the calendar SHALL NOT exchange the two names or mirror a glyph itself. The previous button SHALL be disabled while the displayed month is min's month and the next button while it is max's month, and in the year view both SHALL be hidden. THE calendar SHALL NOT change the buttons' variant, size or tokens.
- **CAL-6** THE calendar SHALL draw the view toggle as a container at least comp.calendar.view-toggle.container.height tall with comp.calendar.view-toggle.container.shape corners eased by sys.shape.corner-smoothing, padded by comp.calendar.view-toggle.container.padding-inline at each end, holding the displayed month and year in the locale's form ("September 2026", "2026年9月") in comp.calendar.view-toggle.label.typescale and, comp.calendar.view-toggle.gap after it, the expand_more glyph at comp.calendar.view-toggle.icon.size with the family and fill of an unselected icon, sys.icon.weight and an optical size clamped between sys.icon.optical-size.min and sys.icon.optical-size.max, both in comp.calendar.unselected.label.color on comp.calendar.unselected.container.color; the glyph SHALL be turned half a turn while the year view is shown, over comp.calendar.motion.state-duration with comp.calendar.motion.state-easing (at once where motion is reduced). WHERE the view toggle is shorter than the minimum target it SHALL extend its hit area to sys.target.touch for touch input and sys.target.pointer for fine pointers without changing its visual size. WHEN it is activated it SHALL switch between the day view and the year view.
- **CAL-7** WHEN the year view is shown THE calendar SHALL draw, in the space of the weekday row and the six rows of days, the years from min's year to max's year (1900 to 2100 when min or max is empty) in rows of three cells, each a third of the grid's width and comp.calendar.cell.size tall, each year centred in its cell in the locale's digits in comp.calendar.year.label.typescale on a container of comp.calendar.year.container.width by comp.calendar.year.container.height with comp.calendar.year.container.shape corners, colored and outlined as a day of its condition is; the list SHALL scroll, and SHALL open scrolled so that the displayed year's row is the third row in view, or as near to it as the list's ends allow. WHEN the user chooses a year THE calendar SHALL show the day view of the month with the displayed month's number in that year, clamped between min's and max's months, move focus to its active day and SHALL NOT change the choice; WHEN the view toggle is activated in the year view it SHALL show the day view unchanged.
- **CAL-8** WHILE mode is single, WHEN the user chooses an available day by pointer, touch, Enter or Space THE calendar SHALL set value to it and, if it differs from the value before, emit change once with it.
- **CAL-9** WHILE mode is range, WHEN the user chooses an available day THE calendar SHALL make it the start and clear the end when no start is chosen, when both are chosen, or when the day is before the start, and otherwise SHALL make it the end (a start and end on the same day make a one-day range); after each choice it SHALL set start and end and emit change once with both, end empty after a start alone. A range SHALL be one contiguous span from start to end inclusive; unavailable days between them SHALL stay inside it, drawn and exposed as unavailable (a product that cannot accept such a span says so after change).
- **CAL-10** THE calendar SHALL give the day grid one tab stop, the active day: WHEN a month is shown the active day SHALL be the chosen date (single mode) or start (range mode) when it lies in that month, otherwise today when it does, otherwise the first available day of the month, otherwise its first day; only the active day SHALL take focus from Tab, and the keys of CAL-11 move it.
- **CAL-11** WHILE focus is on a day THE calendar SHALL move the active day and focus with Right Arrow and Left Arrow one day in the direction the arrow points (the next and the previous day left to right, the previous and the next right to left), with Down Arrow and Up Arrow one week later and earlier, with Home and End to the first and last day of its week row, with Page Down and Page Up to the same day of the next and previous month (the last day of that month when it is shorter), and with Shift+Page Down and Shift+Page Up to the same day of the next and previous year, whatever comp.calendar.navigation.axis is; WHEN the new active day lies in another month THE calendar SHALL show that month; it SHALL NOT move the active day before min or after max; unavailable days SHALL be reachable but SHALL NOT be chosen; Enter and Space SHALL choose the active day.
- **CAL-12** WHILE focus is in the year view THE calendar SHALL give the list one tab stop, the displayed year, and SHALL move focus with Right Arrow and Left Arrow one year in the direction the arrow points, with Down Arrow and Up Arrow one row, and with Home and End to the first and last year, scrolling the focused year into view; Enter and Space SHALL choose the focused year (CAL-7).
- **CAL-13** WHEN the previous-month or next-month button is activated, a step of a detented wheel moves the month (CAL-33), a scroll or a drag ends on another month than the one shown when it began (CAL-34, CAL-31) or a day of a neighbouring month is chosen (CAL-15), THE calendar SHALL show that month, keep focus where it was, make the active day the same day number of the new month (its last day when the month is shorter; the chosen day, WHEN a day was chosen) and announce the new month and year once, politely; WHEN a year is chosen in the year view it SHALL announce the month and year it shows the same way. A month that a scroll only passes through is shown without an announcement (CAL-34).
- **CAL-14** WHEN the displayed month changes by a button, a key, a step of a detented wheel (CAL-33) or a chosen day THE calendar SHALL play the change over comp.calendar.motion.page-duration with comp.calendar.motion.page-easing: WHILE comp.calendar.navigation.axis is vertical as one continuous scroll of the weeks, every frame of it being the in-between look of CAL-29 at the progress the easing gives, so that the two months are never drawn as two separate grids and a week they share is neither drawn twice nor replaced; WHILE the axis is horizontal as two pages, the old month's grid sliding out toward the start and the new one in from the end for a later month, and the reverse for an earlier one. Every way of moving the month SHALL move the same thing — on the vertical axis the one progress of CAL-29, on the horizontal one the place of the two pages — and none SHALL have a look of its own: a scroll of a wheel or a trackpad is followed as CAL-33 says and settles as CAL-34 says, and a drag is followed as CAL-30 says and settles as CAL-31 says. WHEN one of them begins while the days are still moving — a month change is playing, or the days are settling after a scroll or a drag — THE calendar SHALL carry on from what is on screen, without a jump and without waiting for the motion to end. WHEN it switches between the day view and the year view it SHALL cross-fade them over the same duration and easing; WHERE motion is reduced it SHALL make all of these changes at once. The weekday row, the navigation row and the calendar's size SHALL NOT move.
- **CAL-15** WHILE showAdjacentDays is true THE calendar SHALL fill the cells before the displayed month's first day and after its last with the days of the previous and next month, in order, drawn from the adjacent condition unless a higher one applies (CAL-2), and treated as any day for availability (CAL-16), choosing (CAL-8, CAL-9) and the range's band (CAL-3); WHEN the user chooses such a day by pointer or touch THE calendar SHALL choose it as any day, emitting change as CAL-8 and CAL-9 say, and SHALL then show that day's month (CAL-13) with the motion of CAL-14, where the chosen day is one of the month's own and the active day, taking focus if focus was in the grid; a range's start and its end SHALL both do so. A press on an unavailable one SHALL choose nothing and show no other month. WHILE showAdjacentDays is false those cells SHALL be empty, take no press and be hidden from assistive technology.
- **CAL-16** THE calendar SHALL treat as unavailable every day before min, after max, listed in disabledDates or named by the platform's isDateDisabled predicate. WHILE a day is unavailable THE calendar SHALL render its container, label, outline and opacity from the disabled tokens of its condition (comp.calendar.unselected.container.color at rest, and so on), SHALL NOT make it the chosen date, start or end, SHALL expose it to assistive technology as unavailable (disabled), and SHALL keep it reachable by the arrow keys. WHEN value, start or end names an unavailable day THE calendar SHALL still draw it as chosen, from the disabled tokens of the selected condition, until the person chooses another.
- **CAL-17** WHEN the calendar is first shown THE calendar SHALL show the month of value or start, otherwise of today, clamped between min's month and max's month, or the year view WHERE startView is year; WHEN the product sets value, start or end to a date outside the displayed month THE calendar SHALL show that date's month, without the motion of CAL-14.
- **CAL-18** WHILE a day, a year or the view toggle is hovered, keyboard-focused or pressed THE calendar SHALL render its container, label, outline and opacity from the component tokens of its condition in the highest-precedence active state, with precedence pressed over focused over hovered; focus gained by pointer or touch SHALL NOT select the focused state. WHEN these colors change between states THE calendar SHALL animate them over comp.calendar.motion.state-duration with comp.calendar.motion.state-easing, and SHALL change them at once where motion is reduced.
- **CAL-19** WHEN a day, a year or the view toggle receives keyboard focus THE calendar SHALL show the focus ring around its container — around the circle of comp.calendar.day.container.size for every day, an in-range day included — without animation; focus gained by pointer or touch SHALL NOT show it.
- **CAL-20** WHEN the calendar's day, year or view toggle is pressed by pointer, touch, Enter or Space THE calendar SHALL show the pressed state without delay and SHALL keep it visible for at least sys.motion.duration.press-hold, even when the press ends sooner.
- **CAL-21** WHILE pressed THE calendar's day, year or view toggle SHALL answer as sys.behavior.pressed-feedback names: overlay applies the pressed tokens to the whole container at once; ripple grows the pressed state layer from the point of contact (from the center for keyboard activation); dim applies the pressed opacity to the whole control and draws no state layer; none draws no answer to the press. Under ripple and dim only the container color stays with the state under the press; label, outline, opacity and shape still come from the pressed tokens. Under none every part stays with the state under the press. The ripple grows over comp.calendar.motion.feedback-duration with comp.calendar.motion.state-easing, fades over comp.calendar.motion.state-duration with comp.calendar.motion.state-easing once the press has ended and press-hold has passed, and appears whole where motion is reduced.
- **CAL-22** THE calendar's day, year and view toggle SHALL ease their container corners by sys.shape.corner-smoothing (0 is a quarter circle, 0.6 the iOS continuous corner; a platform with only the OS curve draws it for any amount above 0) and show their keyboard focus as sys.behavior.focus-style names (ring or glow); a platform that cannot draw a choice SHALL fall back to the first value and state that in its README.
- **CAL-23** THE calendar SHALL expose itself as a group named by label while label is not empty, the day grid as a grid named by the displayed month and year with the weekday names as its column headers, and each shown day as a cell that can be activated, named by its full date in the locale ("Thursday, September 17, 2026") followed by todayLabel for today where the platform has no property for the current date, with its selected state (true for the chosen date and for every day of a range), described by startLabel or endLabel for a range's start and end, and with its disabled state while unavailable; empty cells SHALL be hidden. It SHALL expose the view toggle as a button named by its visible month and year, described by yearViewLabel, with its expanded state (true while the year view is shown), and each year as a button with its selected state and todayLabel for the current year.
- **CAL-24** THE calendar SHALL take month and weekday names, digits and the locale's first day of the week from locale, or from the platform's current locale while locale is empty, and SHALL use firstDayOfWeek instead of the locale's while it is not locale; WHERE the platform has no week data for the locale it SHALL use the first days of CLDR's weekData, which the platform package embeds. Every date SHALL be a Gregorian calendar date without a time or a time zone, and WHILE today is empty THE calendar SHALL read the device's current date in its own time zone WHEN it is first shown and WHEN the date changes while it is shown.
- **CAL-25** THE calendar's days, years, weekday names and view toggle SHALL grow with enlarged text rather than clip it: a day's container SHALL grow to hold its number. WHERE the space given to the calendar is narrower than seven minimum targets, the cells SHALL narrow equally so that the grid fits it, keeping their numbers at their size, and the calendar SHALL never be wider than the space it is given.
- **CAL-26** WHEN the layout direction is right-to-left THE calendar SHALL mirror its layout — the first day of the week in the rightmost column, the view toggle at the right and the navigation buttons at the left, and, on the horizontal axis, the slide of CAL-14 and the side each neighbouring month lies on (the next month toward the left, so that a drag toward the right brings it, CAL-30) — and SHALL NOT mirror the vertical scroll of the weeks, a vertical drag or the vertical arrows. A drag SHALL move the days where the pointer goes, and a scroll of a wheel or a trackpad SHALL move them where it moves the content of a scrolled list, in every layout direction.
- **CAL-27** WHEN mode, startView or firstDayOfWeek names a value the calendar does not define THE calendar SHALL behave as its default value and in debug builds report the unknown value; WHEN value, start, end, min, max, today or an entry of disabledDates is not an ISO 8601 calendar date, end is before start, value is set in range mode or start or end in single mode THE calendar SHALL ignore it and in debug builds report it; WHEN min is later than max THE calendar SHALL ignore both and report it in debug builds.
- **CAL-28** THE calendar SHALL take its layout direction from where it is used — the direction of the nearest ancestor that sets one, and the platform's own where none does — and SHALL have no direction setting of its own; WHEN that direction changes while the calendar is shown THE calendar SHALL draw itself for the new direction at once, without a motion of its own, keeping the displayed month, the selection and its focus.
- **CAL-29** WHILE comp.calendar.navigation.axis is vertical THE calendar SHALL treat the days as one unbroken column of week rows, in which a month's view is the six rows from the row of its first day, and SHALL draw every look between the views of two neighbouring months from one progress, 0 at the one and 1 at the other, with no threshold. The rows SHALL be offset along the column by the progress times the distance between the two views' first rows (four or five rows, each comp.calendar.cell.size tall) and clipped to the grid's six rows, so that a week the two views share is one row that travels from its place in the one to its place in the other. Every day SHALL be drawn at the linear interpolation by the progress between what the one view draws for it and what the other draws: its container, label and outline colors between the tokens of the adjacent condition and those of the unselected condition (comp.calendar.adjacent.label.color and comp.calendar.unselected.label.color, and so on, in the state the day is in) WHERE it is a neighbouring month's day in the one view and the month's own in the other (CAL-2), and its opacity between 1 where a view draws it and 0 where a view does not — a row outside that view's six, or a cell that view leaves empty because showAdjacentDays is false — so that a day coming into view fades in and a day leaving it fades out as far as the weeks have moved. A day that both views draw alike SHALL NOT change, and the band of a range (CAL-3) SHALL follow the same rule, each piece with its day. WHERE the two months are not neighbours (a step of a year, CAL-11; a date the keys reach in a further month) the distance SHALL be six rows, as if the later month's view began where the earlier one's ends.
- **CAL-30** WHILE the day view is shown, WHEN the user presses the day grid with touch, a pen or the primary button of a mouse and drags past the platform's touch slop (8 points where the platform names none, for every kind of pointer), further along the axis comp.calendar.navigation.axis names than across it, THE calendar SHALL take the drag: the press SHALL choose no day when it ends (CAL-8, CAL-9), the day under it SHALL leave its pressed state as it does when a press ends (CAL-20), and from then until the drag ends THE calendar SHALL move the days by the distance the pointer has travelled along the axis since the press, in the frame the pointer moves and without a motion of its own — toward the next month for a drag toward the top (vertical) or the start (horizontal), toward the previous month for the reverse — never further than the view of the month next to the displayed one, and never past min's or max's month, where the days SHALL stop. WHILE the axis is vertical THE calendar SHALL draw the in-between look of CAL-29 at the progress that travel gives (the travel over the distance between the two views), so that the scroll of the weeks, the fading in of the days coming into view and the change of a neighbouring month's day into one of the month's own all follow the pointer continuously; WHILE the axis is horizontal the two pages of CAL-14 SHALL travel with the pointer, side by side. A press that ends before it has passed the slop SHALL choose the day under it as CAL-8 and CAL-9 say, with every kind of pointer, so that a click of a mouse on a day still chooses it. WHILE a drag the calendar has taken lasts, THE calendar SHALL follow the pointer wherever it goes, outside the grid and the calendar included; no day SHALL be drawn hovered; nothing the calendar draws SHALL be selected as text or picked up by the platform's drag and drop; and a fine pointer SHALL show the platform's cursor for something held and dragged (grabbing) until the drag ends. At rest the cursor over a day SHALL stay the one a pressable day shows: the grid does not advertise the drag, which is a shortcut. A press of another mouse button SHALL take no drag. A drag that is further across the axis than along it when it passes the slop SHALL be left to the scrolling ancestor for its whole length; a scroll of a wheel or a trackpad is followed as CAL-33 says.
- **CAL-31** WHEN the user releases a drag the calendar has taken THE calendar SHALL choose one month: the neighbouring month the days have moved toward WHERE they are a quarter or more of the distance from the displayed month's view toward its view, and otherwise the displayed month — the same quarter that ends a scroll (CAL-34) — or, WHEN the release is a fling along the axis, the one of the two the fling is toward; a fling is a release faster than the platform's minimum fling velocity (Android's ViewConfiguration.getScaledMinimumFlingVelocity, Flutter's kMinFlingVelocity) or, where the platform names none (the web, SwiftUI, React Native), 0.5 points per millisecond, with every kind of pointer. A release's velocity is the pointer's over its last moves: WHERE the pointer has not moved for longer than the time after which the platform's velocity tracker takes a pointer as stopped (40 milliseconds in Android's VelocityTracker, Jetpack Compose's VelocityTracker and Flutter's VelocityTracker) or, where the platform names none (the web, SwiftUI, React Native), 40 milliseconds before the release THE calendar SHALL take the release as having no velocity, so that a drag that stops where it is wanted and is let go there is no fling, however fast it moved before it stopped. WHERE the chosen month is not the displayed one THE calendar SHALL show it as CAL-13 says and bring the days from where the release left them to its view over comp.calendar.motion.page-duration with comp.calendar.motion.page-easing; WHERE it is the displayed month THE calendar SHALL return them over the same motion and announce nothing. The quarter decides only what a release chooses; nothing drawn changes at it. The release SHALL NOT also choose the day under the pointer, and a drag SHALL never change value, start or end, nor emit change.
- **CAL-32** WHEN a drag the calendar has taken is cancelled — the platform cancels the pointer (a system gesture, a lost capture), Escape is pressed while it lasts, the product sets value, start or end (CAL-17), or the year view is shown — THE calendar SHALL return the days to the view of the displayed month (the month the product's date has just named, where it named one) over comp.calendar.motion.page-duration with comp.calendar.motion.page-easing and announce nothing; WHEN the product sets value, start or end, or the year view is shown, while a scroll is being followed THE calendar SHALL do the same and SHALL ignore what is left of that scroll (CAL-34). WHILE a drag or a scroll holds the days between two views THE calendar SHALL NOT change the month and year that the view toggle and the grid's name show, the active day, keyboard focus or what it exposes to assistive technology: the days of the month coming into view SHALL be hidden from assistive technology and take no focus and no press until that month is the displayed one, which it becomes by the release that chooses it (CAL-31), by the end of the scroll that chooses it or by the scroll reaching its view (CAL-34). WHERE motion is reduced THE calendar SHALL still move the days with the pointer and with the scroll and draw the in-between look, which the person's own hand moves, and SHALL put them at the chosen month's view at once when the drag is released or cancelled or the scroll ends.
- **CAL-33** WHILE the day view is shown, WHEN a wheel or a trackpad scrolls over the day grid further along the axis comp.calendar.navigation.axis names than across it, THE calendar SHALL take the scroll and move the days by the distance the scroll reports, one point for each point and in the frame it arrives, as the content of a scrolled list moves — toward the next month for a scroll toward the bottom (vertical) or toward the end (horizontal), toward the previous month for the reverse — without a motion of its own and with no least amount: the slightest scroll moves the days by that much and no further. WHILE the axis is vertical THE calendar SHALL draw the in-between look of CAL-29 at the progress that travel gives; WHILE it is horizontal the two pages of CAL-14 SHALL travel side by side. A scroll that goes on SHALL go on through month after month: WHEN the days reach the view of the neighbouring month THE calendar SHALL make that month the displayed one (CAL-34) and carry on toward the month after it with the distance that is left, in the same frame, so that a scroll never waits for an earlier one to end and no part of it is dropped. The days SHALL stop at the view of min's or max's month; a scroll along the axis that can move them no further SHALL still be the calendar's and SHALL NOT be passed on to a scrolling ancestor. A scroll further across the axis than along it SHALL be left to the scrolling ancestor, and so SHALL a scroll that zooms (a pinch on a trackpad, a wheel turned with the platform's zoom key). WHERE the platform reports a scroll as steps of a detented wheel and not as a distance (a delta in lines or pages on the web; a mouse wheel's notch where the platform tells it from a trackpad), each step SHALL instead move one month, as a press of the previous or next button does (CAL-13, CAL-14), one step after another without waiting for the motion to end.
- **CAL-34** THE calendar SHALL take a scroll it follows (CAL-33) as ended WHEN the platform says that it has ended, its momentum included, where the platform reports that; where it does not (the web), WHEN no scroll event has arrived for 150 milliseconds, or WHEN the scroll has slowed below 0.1 points per millisecond, measured over its events of the last 100 milliseconds — the tail of a momentum scroll, which need not be waited out: THE calendar SHALL then ignore the events that follow for as long as they are no faster than that, until a faster one arrives or 150 milliseconds pass without any. WHEN a scroll ends with the days between two views THE calendar SHALL choose one of the two — the neighbouring month WHERE the days are a quarter or more of the distance from the displayed month's view toward its view, and otherwise the displayed month, so that a slight scroll returns and a deliberate one need not reach halfway — and SHALL bring the days to the chosen view over comp.calendar.motion.page-duration with comp.calendar.motion.page-easing (at once where motion is reduced, CAL-32). The displayed month SHALL be the month whose view the days last rested on or reached: WHEN a scroll that goes on reaches a neighbouring month's view THE calendar SHALL show that month in the view toggle and the grid's name at once, make its days the ones that take focus and presses, and move the active day and a focused day as CAL-13 says, but SHALL announce nothing yet; the quarter is then counted from that view. THE calendar SHALL announce the month and year once, politely, WHEN a scroll has ended and the days have been given their view, and only WHERE that month is not the one that was displayed when the scroll began; a scroll that returns SHALL announce nothing. WHEN a faster scroll event arrives, a drag is taken or a button or a key moves the month while the days are settling THE calendar SHALL carry on from what is on screen (CAL-14). A scroll SHALL never change value, start or end, nor emit change.
- **CAL-35** THE calendar SHALL have no words of its own: it SHALL take startLabel, endLabel, previousMonthLabel, nextMonthLabel, yearViewLabel and todayLabel from the instance's prop and, WHILE that is empty, from the product's words of the same name; WHILE neither gives todayLabel it SHALL take the word from the platform's locale data WHERE the platform has the locale's own word for the present day, and it SHALL take no other word from anywhere. IF neither gives a navigation button its name, or a range's start or end its description, THE calendar SHALL still draw the part and keep it working, and in debug builds SHALL report the missing words once, naming the prop. WHILE neither gives yearViewLabel the view toggle SHALL have no description: its name and its expanded state (CAL-23) still say what it shows.
- **CAL-36** WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the host, by the attribute or by the ARIA property that reflects it, THE calendar SHALL move it to the data-aria- attribute of the same name, so that the host is no second node with its name, description or state, and SHALL expose it on its group, which it SHALL be WHILE it has a name: its label, or one given so — an id reference by the elements it names in the host's tree or a tree around it, a description after its own. WHILE label is not empty it is the group's name, over a name given so. WHEN the product removes the aria- attribute or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaDescribedByElements…) to null, THE calendar SHALL take it off that element.

### Tokens

As written in the token documents (DTCG): an alias in braces names the token it takes its value from, which each design system resolves. On the web each token is a CSS custom property: `--tanrei` and the name after `tanrei`, joined by hyphens (`tanrei.comp.calendar.…` → `--tanrei-comp-calendar-…`).

#### `tanrei.comp.calendar`

| Token | Type | Value |
| --- | --- | --- |
| `comp.calendar.view-toggle.container.height` | `dimension` | `36px` |
| `comp.calendar.view-toggle.container.shape` | `dimension` | `{tanrei.sys.shape.corner.full}` |
| `comp.calendar.view-toggle.container.padding-inline` | `dimension` | `{tanrei.sys.space.space150}` |
| `comp.calendar.view-toggle.gap` | `dimension` | `{tanrei.sys.space.space50}` |
| `comp.calendar.view-toggle.label.typescale` | `typography` | `{tanrei.sys.typescale.label}` |
| `comp.calendar.view-toggle.icon.size` | `dimension` | `{tanrei.sys.icon.small}` |
| `comp.calendar.navigation.gap` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.calendar.navigation.axis` | `number` | `0` |
| `comp.calendar.weekday.typescale` | `typography` | `{tanrei.sys.typescale.label-small}` |
| `comp.calendar.weekday.color` | `color` | `{tanrei.sys.color.on-surface-variant}` |
| `comp.calendar.cell.size` | `dimension` | `48px` |
| `comp.calendar.day.container.size` | `dimension` | `40px` |
| `comp.calendar.day.container.shape` | `dimension` | `{tanrei.sys.shape.corner.full}` |
| `comp.calendar.day.label.typescale` | `typography` | `{tanrei.sys.typescale.body}` |
| `comp.calendar.year.container.width` | `dimension` | `72px` |
| `comp.calendar.year.container.height` | `dimension` | `36px` |
| `comp.calendar.year.container.shape` | `dimension` | `{tanrei.sys.shape.corner.full}` |
| `comp.calendar.year.label.typescale` | `typography` | `{tanrei.sys.typescale.body}` |
| `comp.calendar.outline.width` | `dimension` | `{tanrei.sys.border.hairline}` |
| `comp.calendar.motion.page-duration` | `duration` | `{tanrei.sys.motion.duration.medium1}` |
| `comp.calendar.motion.page-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.standard}` |
| `comp.calendar.motion.state-duration` | `duration` | `{tanrei.sys.motion.role.state.compact}` |
| `comp.calendar.motion.state-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.state}` |
| `comp.calendar.motion.feedback-duration` | `duration` | `{tanrei.sys.motion.role.feedback}` |
| `comp.calendar.unselected.container.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.calendar.unselected.label.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.calendar.unselected.outline.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.calendar.unselected.state-layer.color` | `color` | `{tanrei.comp.calendar.unselected.label.color}` |
| `comp.calendar.adjacent.container.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.calendar.adjacent.label.color` | `color` | `{tanrei.sys.color.on-surface-variant}` |
| `comp.calendar.adjacent.outline.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.calendar.adjacent.state-layer.color` | `color` | `{tanrei.comp.calendar.adjacent.label.color}` |
| `comp.calendar.today.container.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.calendar.today.label.color` | `color` | `{tanrei.sys.color.primary}` |
| `comp.calendar.today.outline.color` | `color` | `{tanrei.sys.color.primary}` |
| `comp.calendar.today.state-layer.color` | `color` | `{tanrei.comp.calendar.today.label.color}` |
| `comp.calendar.selected.container.color` | `color` | `{tanrei.sys.color.primary}` |
| `comp.calendar.selected.label.color` | `color` | `{tanrei.sys.color.on-primary}` |
| `comp.calendar.selected.outline.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.calendar.selected.state-layer.color` | `color` | `{tanrei.comp.calendar.selected.label.color}` |
| `comp.calendar.in-range.container.color` | `color` | `{tanrei.sys.color.surface-dim}` |
| `comp.calendar.in-range.label.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.calendar.in-range.outline.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.calendar.in-range.state-layer.color` | `color` | `{tanrei.comp.calendar.in-range.label.color}` |

Per-state tokens, valued by the design system's pattern rules (not written by hand):

- `comp.calendar.<variant>.<state>.<property>` for the variants `unselected`, `adjacent`, `today`, `selected`, `in-range`, the states `hovered`, `focused`, `pressed`, `disabled` and the properties `container.color`, `label.color`, `outline.color`, `state-layer.color`.
- `comp.calendar.<state>.<property>` for the states `hovered`, `focused`, `pressed`, `disabled` and the properties `opacity`.

## 指南

### 何时使用

当所选日期前后的日期也有意义时，使用日历：住宿、预订、报表的时间范围，或者星期几很重要的日期。如果选择日期是页面任务的一部分，就把日历放在页面里。如果日期只是表单中的一项，请使用 `type` 为 `date` 的[文本字段](https://tanrei.chnotchy.com/zh-cn/components/text-field/)，并从它的选择器按钮打开 [DateTimePicker](https://tanrei.chnotchy.com/zh-cn/components/date-time-picker/)。已经知道日期的人，直接输入比在日历里找更快。

### 选择

- **一个日期。** 在 `single` 模式下，在可选的日期上按下或按 Enter，会让它成为 `value` 并触发 `change`。
- **一段范围。** 在 `range` 模式下，第一次选择的是开始日期，第二次选择的是结束日期。选择早于开始日期的一天，或者在两端都已确定后再选择，都会重新开始。每次选择后都会触发 `change`，只选了开始日期时 `end` 为空，所以产品可以显示选到一半的范围。开始日期和结束日期是同一天时，就是一天的范围。
- **值**是 ISO 8601 的日历日期（`2026-09-17`），不含时间和时区。`today` 默认是设备的当前日期；你可以按场地所在的时区设置它，或者为测试设置它。

范围是从开始日期到结束日期的一条色带。它在一周的行尾断开，在下一行继续。

### 不可选的日期

不可选的日期由 `min`、`max`、`disabledDates` 以及平台的 `isDateDisabled` 判定函数决定；后者用于列表无法表达的规则，例如每个周末。这些日期画得更淡，不能被选择，但仍然可以用方向键到达，这样用户能知道这个日期是存在的。“上一个”和“下一个”按钮在 `min` 和 `max` 所在的月份停下，滚动和拖动也是如此。

跨过不可选日期的范围会把它包含在内；如果你的产品不能接受这样的范围，请在 `change` 之后告知用户。

### 在月份之间移动

导航行的起始侧是视图切换按钮（“2026年9月”和一个 V 形箭头），末尾侧是“上一个”和“下一个”按钮。有四种方式可以移动月份，它们移动的是同一样东西，所以任何一种都可以中途接替另一种，画面不会跳动：

- **按钮**和**按键**（Page Up 和 Page Down，或者移出当前月份的方向键）以一段短促的动效完成切换。
- 用触控板或滚轮在日期上**滚动**，日期会随滚动量移动；只要滚动不停，就一个月接一个月地移过去。滚动停止时，日历会停在一个月份上：日期已经向相邻月份移动了四分之一，就停在相邻月份，否则回到原来的月份。按整步报告的滚轮，每一步移动一个月。
- 用手指、触控笔或鼠标**拖动**日期，效果相同。松开时按同样的四分之一规则停下；如果是快速滑动，则停在滑动所朝的那一侧。没有移动的按下，仍然选择它下面的那一天。

在默认系统中，月份沿垂直轴排列，是一列不间断的周。一个月是这一列上六行高的一个窗口。两个月共有的一周是同一行，它从自己在一个月里的位置移到在另一个月里的位置；进入视野的日期淡入；随着周的移动，相邻月份的日期会变成本月的日期。日历从不绘制两个分开的月份网格，无论显示哪个月，它的高度都不变。

在减弱动态效果时，按钮或按键会立即切换月份。日期仍然跟随滚动和拖动，因为那是用户自己的手在移动它们；操作结束时，日期立即就位。

轴是产品级的设置 `comp.calendar.navigation.axis`，而不是属性，所以一个产品里的每个日历都以相同的方式移动：

| 轴 | 按钮 | 月份 | 滚动和拖动 |
| --- | --- | --- | --- |
| `vertical`（默认） | 向上和向下的箭头 | 一列连续的周 | 垂直方向的移动日期；水平方向的交给页面 |
| `horizontal` | 向后和向前的箭头 | 并排的两页，较晚的月份靠末尾侧 | 水平方向的移动日期；垂直方向的交给页面 |

按键从不随轴变化。

指针位于日期上方时，沿轴方向的滚动归日历所有；在第一个月和最后一个月，日期停住不动时也是如此。在以相同方向滚动的页面上，从这里开始的滚动不会滚动页面：在长页面上，请在日历旁边或周围留出空间，让滚动能够到达页面。星期行、导航行和日历周围的一切，都照常滚动页面。

### 年份视图

视图切换按钮会切换到年份视图，这是一个可滚动的列表，从 `min` 的年份到 `max` 的年份（没有设置时为 1900 到 2100）。选择一个年份会显示该年的同一个月，不改变任何已选内容。`startView` 设为 `year` 时，日历会先打开年份视图，适合生日这类离今天很远的日期。

### 相邻月份的日期

`showAdjacentDays` 用相邻月份的日期填满网格中空余的单元格，并把它们画得更淡。这些日期只要可选就能被选择，范围也可以在其中一个上开始或结束。用指针选择这样的日期时，日历先选中它，再显示它所在的月份，在那里它是本月的日期。不设置这个属性时，这些单元格是空的，不响应按下。

### 区域设置

星期名称、月份标题和数字都取自区域设置（`locale`，或平台的区域设置）。`firstDayOfWeek` 也取自区域设置，除非你指定了星期几。在任何区域设置下，日历都是公历。不属于区域数据的文字——导航按钮的名称、视图切换按钮的描述、范围的开始和结束——是你的：Tanrei 不自带任何文字。可以把它们写在日历上，也可以像本页这样，按语言为整个产品只给一次：

```js
import { setWords } from '@tanrei/web';

setWords(
  {
    previousMonthLabel: '上个月',
    nextMonthLabel: '下个月',
    yearViewLabel: '选择年份',
    todayLabel: '今天',
    startLabel: '开始日期',
    endLabel: '结束日期',
  },
  { lang: 'zh-CN' },
);
```

这段调用就是本页实际运行的那一段：页面上的每个日历都由这些文字命名，示例里一个也没有写。某个日历的说法不同时，由它自己给出——下面的日语日历用属性给出了它的四个文字，而属性总是优先。一个都不给时，导航按钮仍然可以使用，但没有名称，调试构建会逐一报告缺少的文字。只有表示今天的那个词，在你没有给出时取自浏览器的区域数据。

### 推荐

- 页面上有不止一个日历，或者附近没有任何内容说明这个日期的用途时，给日历设置 `label`（“入住日期”）。
- 让 `min` 和 `max` 只包含确实可以选择的日期，这样导航会在可选范围的尽头停下。
- 对于用户已经知道的日期，在日历旁边提供文本字段，或者用文本字段代替日历。
- 让按钮始终触手可及：滚动和拖动是捷径，并不是每个人都能使用。

### 避免

- 不要按屏幕分别设置轴。它是整个产品的一项决定。
- 不要隐藏不可选的日期。把它们显示为不可选，日历的网格才保持完整。
- 不要把垂直轴的日历贴着两侧边缘放在垂直滚动的页面里。从它的日期上开始的滚动永远到不了页面。
- 不要把月份进入视野当作一次选择。滚动和拖动改变的是显示的内容，从不改变 `value`，也不触发任何事件。

## 无障碍

### 组件负责的部分

- 日历是一个以 `label` 命名的组。网格以它显示的月份命名，列标题是星期的全称，每一天以完整的日期命名，并暴露它是否选中、是否不可选、是否是今天。范围的两端由 `startLabel` 和 `endLabel` 描述。
- Tab 顺序中只有一天是活动日期。方向键把它移动一天或一周，Home 和 End 移到一周的两端，Page Up 和 Page Down 移动一个月，加上 Shift 则移动一年，并在需要时切换月份。Enter 或 Space 选择它。
- 视图切换按钮是一个按钮，以它显示的月份和年份命名，由 `yearViewLabel` 描述，并表明年份视图是否打开。年份列表也是一个 Tab 停靠点：方向键移动一年或一行，Home 和 End 移到列表两端。
- 月份变化时，会以礼貌（polite）的方式朗读一次。滚动经过多个月份时，标题跟随每一个月份，但只朗读滚动结束时所在的月份；回到原处的滚动或拖动不朗读任何内容。
- 拖动把日期停在两个月之间时，日历暴露的内容没有任何变化：标题、活动日期和焦点保持不动；进入视野的日期在它们的月份成为当前显示的月份之前，不接受焦点，也不响应按下。
- 滚动和拖动是捷径：按钮以及 Page Up 和 Page Down 能做同样的事，所以没有任何操作依赖手势。
- 每个单元格都不小于最小目标，文本放大后依然如此。在比七个目标更窄的空间里，单元格等比例变窄，数字保持原来的大小，所以日历永远不会比分配给它的空间更宽。导航按钮是 Tanrei 的按钮。
- 从右到左的布局会镜像网格、导航、水平方向的箭头，以及下一页所在的一侧。周的垂直滚动不镜像，拖动时日期始终朝指针移动的方向移动。

已知的平台差距：

- SwiftUI、Compose、Flutter 和 React Native 尚未实现日历：各平台跟上时会按这份规格来实现。
- Web：年份的选中状态是它的按钮上的 `aria-current`，因为按钮本身没有选中状态。
- Web：浏览器既不报告滚动的结束，也不报告滚动的惯性，所以日历根据短暂的静默或减速来判断滚动结束。如果浏览器对某个滚轮鼠标每次只报告几个像素，慢慢转动它时，每转过一格，日历都会回到原来的月份。
- Web：拖动尚未在真机上用手指检查过，并且有一些测试在 Firefox 和 WebKit 中失败。

### 需要你做的事

- 设置 `label`，并给出日历使用的文字（`previousMonthLabel`、`nextMonthLabel`、`yearViewLabel`、`todayLabel`、`startLabel`、`endLabel`）：写在日历上，或者用 `setWords()` 为整个产品只给一次。Tanrei 不自带任何文字。没有这些文字时，导航按钮可以使用，但没有名称，范围的两端没有描述，每一处都会在开发时报告；只有表示今天的那个词取自浏览器的区域数据。参见[产品的文字](https://tanrei.chnotchy.com/zh-cn/foundations/words/)。
- 提供输入日期的方式，例如 `type` 为 `date` 的文本字段。对许多人来说，这是最直接的途径。
- 如果你的产品不能接受跨过不可选日期的范围，请在 `change` 之后告知用户。
