Calendar(日历)把日期直接显示在页面上,适合预订、排期这类需要日期常驻可见的场景。它负责选择模式、键盘导航、月份翻页与无障碍网格语义,表格结构与每个日期的内容由你渲染。需要「文本框 + 弹出日历」时用日期选择器。
组合
Calendar
├── CalendarPrevious
├── CalendarHeading
├── CalendarNext
└── CalendarGrid
├── CalendarWeekdays
│ └── CalendarWeekday
└── CalendarWeeks
└── CalendarDay标题命名当前显示的月份,Previous 与 Next 翻月,网格里放星期表头与日期。CalendarWeekdays 与 CalendarWeeks 渲染表格结构,你渲染每个表头与日期。空的 Calendar 会渲染默认结构,空的 CalendarGrid 会渲染表头与各周;把部件写出来是为了加自己的类名与内容。每个网格以所在月份命名,并带 grid 角色。
用法
日期是 ISO 字符串,例如 "2026-10-16",不带时间与时区,也不需要日期库。
import {
Calendar,
CalendarDay,
CalendarGrid,
CalendarHeading,
CalendarNext,
CalendarPrevious,
CalendarWeekday,
CalendarWeekdays,
CalendarWeeks,
} from "@ui-sdk/react/calendar";
function Example() {
return (
<Calendar defaultValue="2026-10-16">
<div data-slot="row">
<CalendarPrevious aria-label="上个月">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path d="m10 3-5 5 5 5" fill="none" stroke="currentColor" />
</svg>
</CalendarPrevious>
<CalendarHeading />
<CalendarNext aria-label="下个月">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<path d="m6 3 5 5-5 5" fill="none" stroke="currentColor" />
</svg>
</CalendarNext>
</div>
<CalendarGrid>
<CalendarWeekdays>
{(index) => <CalendarWeekday index={index} />}
</CalendarWeekdays>
<CalendarWeeks>
{(cell) => <CalendarDay value={cell.date} />}
</CalendarWeeks>
</CalendarGrid>
</Calendar>
);
}Previous 与 Next 默认名称是英文的 Previous month / Next month,中文页面请像上面这样传 aria-label。
选择模式与值
| 模式 | 值 |
|---|---|
"single"(默认) |
一个日期字符串或 null。 |
"multiple" |
排好序的日期字符串数组。 |
"range" |
[start, end] 或 null。 |
值的形状与模式不符时按未选择处理。mode="multiple" 每次按下独立切换一个日期,数组始终排序。mode="range" 先选起点再选终点,指针或焦点移动时预览范围,两端都选定后值才变化;选一个早于起点的日期会结束范围并成为新起点,Escape 取消未完成的范围。
<Calendar mode="range" months={2} defaultValue={["2026-10-12", "2026-10-16"]}>
<CalendarPrevious aria-label="上个月" />
<CalendarHeading />
<CalendarNext aria-label="下个月" />
<div data-slot="row">
{[0, 1].map((offset) => (
<CalendarGrid key={offset} offset={offset}>
<CalendarWeekdays>{(index) => <CalendarWeekday index={index} />}</CalendarWeekdays>
<CalendarWeeks>{(cell) => <CalendarDay value={cell.date} />}</CalendarWeeks>
</CalendarGrid>
))}
</div>
</Calendar>单选模式下加 deselectable,再按一次已选日期即可清除选择;不加则保持选中。
不可选日期与边界
unavailable 用数据描述不可选的日期:ISO 日期、{ from, to }、{ weekday: [0, 6] }(周末,0 是周日)、{ before }、{ after }、判断函数,或这些匹配器组成的数组。不可选日期不能被选中,但仍可聚焦,键盘与屏幕阅读器用户能发现它们,并读到 aria-disabled 与 data-unavailable。
const unavailable = [
"2026-10-08",
{ from: "2026-10-20", to: "2026-10-23" },
{ weekday: [0, 6] },
];
<Calendar unavailable={unavailable} min="2026-10-05" max="2026-11-20">
…
</Calendar>;min 与 max 是最早与最晚可选日期:范围外的日期无法获得焦点,方向键停在边界,Previous 与 Next 停在对应月份。disabled 让聚焦、选择与翻月全部失效;readOnly 允许翻月与移动焦点查看日期,但选中值不变。范围模式另有 minDays、maxDays(含两端的天数上下限)与 allowUnavailable(允许范围跨越不可选日期,例如跨过已被预订的一晚)。
多月、月份选择与语言
months 决定并排显示几个月,每个 CalendarGrid 用 offset 指定自己是第几个月(从 0 开始)。方向键在网格之间移动而不翻页;已被其他可见月份显示的日期留空单元格,保证每个日期只出现一次。pageBy="page" 让 Previous、Next 与越过可见网格的导航一次移动全部月份。
fixedWeeks 让每个月都渲染六周,日历高度不再跳动;showOutsideDays={false} 把相邻月份的日期留空。
CalendarMonthSelect 与 CalendarYearSelect 是原生 select,用于直接跳到某个日期(例如生日)。年份选项从 min 到 max,没有边界时取当前显示年份的前后各十年,超出边界的月份被禁用。它们以 select-change 原因更新第一个显示的月份。想用自己的选择器时,改为控制日历的 month,并在日历里保留一个 CalendarHeading(可以视觉隐藏),让网格仍有名称。
locale 决定月份名、星期表头与完整日期标签,不传时挂载后跟随页面 lang,再退回浏览器语言;服务端与客户端首帧用英文。每周第一天跟随地区,也可以用 weekStartsOn 固定(0 是周日)。weekdayFormat 与 monthFormat 取 "narrow"、"short"、"long",屏幕阅读器仍读完整名称。中文月历通常写成 <Calendar locale="zh-CN" weekStartsOn={1} weekdayFormat="short" monthFormat="long">。
日期始终是公历;today 与 timeZone 决定「今天」是哪一天,用于 aria-current 与起始月份,默认在客户端按浏览器时区读取。
受控与控制器
传 value 与 onValueChange 控制选择,传 month 与 onMonthChange 控制显示的月份;非受控用 defaultValue 与 defaultMonth。details.reason 区分 item-press、previous-press、next-press、list-navigation、select-change、imperative,details.cancel() 可以拦截。
要从组件树外操作,用 createCalendarController() 创建控制器传给根。聚焦另一个月的日期会先把那个月显示出来:
import { addDays, endOfMonth, startOfMonth } from "@ui-sdk/core/date";
import { createCalendarController } from "@ui-sdk/react/calendar";
const today = "2026-10-16";
const presets = [
{ label: "最近 7 天", range: [addDays(today, -6), today] },
{ label: "本月", range: [startOfMonth(today), endOfMonth(today)] },
{ label: "未来 30 天", range: [today, addDays(today, 29)] },
] as const;
const [calendar] = React.useState(() => createCalendarController());
function apply(range: readonly string[]) {
calendar.setValue([...range]);
calendar.focus(range[0]);
}
<div data-slot="stack">
<div data-slot="controls">
{presets.map((preset) => (
<button key={preset.label} type="button" onClick={() => apply(preset.range)}>
{preset.label}
</button>
))}
</div>
<Calendar mode="range" controller={calendar} today={today} defaultMonth="2026-10">…</Calendar>
</div>;控制器提供 getValue / setValue / getMonth / setMonth / previous / next / focus,写入方法接受可选的 { reason, event },在被取消、无处可去、禁用或只读时返回 false。参见控制器。
样式与状态
日期用 data-selected、data-today、data-outside、data-unavailable、data-disabled 区分;范围另有起点、中间、终点与未完成预览。导航按钮到达边界时也带 data-disabled。示例里的 data-slot="row"、data-slot="stack"、data-slot="controls" 只是布局容器,用 flex 或 grid 自己定义。
- 根
calendar:data-mode(single、multiple、range)、data-readonly、data-disabled。 calendar-previous、calendar-next、calendar-grid、calendar-month-select、calendar-year-select:data-disabled。calendar-day:data-value(ISO 日期)、data-selected、data-today、data-disabled、data-unavailable、data-outside、data-range-start、data-range-middle、data-range-end、data-preview。
[data-slot="calendar-day"] {
inline-size: 2.25rem;
block-size: 2.25rem;
border-radius: 6px;
}
[data-slot="calendar-day"][data-outside] {
opacity: 0.4;
}
[data-slot="calendar-day"][data-today] {
box-shadow: inset 0 0 0 1px var(--color-border-strong);
}
[data-slot="calendar-day"][data-range-middle] {
background: var(--color-brand-wash);
}
[data-slot="calendar-day"][data-selected] {
background: var(--color-brand);
color: var(--color-background);
}
[data-slot="calendar-day"][data-preview] {
outline: 1px dashed var(--color-brand);
}
[data-slot="calendar-day"][data-unavailable] {
color: var(--color-muted-foreground);
text-decoration: line-through;
}
[data-slot="calendar-day"]:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}日历不额外暴露 CSS 变量,状态全部通过数据属性表达。详见样式与动画。
API
Calendar 常用属性:
| 属性 | 类型 | 说明 |
|---|---|---|
mode |
"single" | "multiple" | "range" |
选择模式,默认单选。 |
value / defaultValue |
CalendarValue |
受控值与非受控初始值。 |
onValueChange |
(value, details) => void |
选择变化时触发,可 details.cancel();范围在两端都选定后触发一次。 |
month / defaultMonth |
string |
第一个显示的月份,例如 "2026-10"。 |
onMonthChange |
(month, details) => void |
显示月份变化时触发。 |
min / max |
string |
最早与最晚可选日期。 |
unavailable |
DateMatchers |
不可选日期,仍聚焦。 |
disabled / readOnly |
boolean |
全部禁用;或可聚焦查看但不能选。 |
deselectable |
boolean |
单选模式下再按一次清除选择。 |
months / pageBy |
number / "month" | "page" |
并排月数;翻月步长。 |
fixedWeeks / showOutsideDays |
boolean |
固定六周;是否显示相邻月份日期。 |
locale / weekStartsOn |
string / number |
语言与每周第一天。 |
weekdayFormat / monthFormat |
"long" | "short" | "narrow" |
星期表头与月份名的写法。 |
today / timeZone |
string |
今天的日期与判断今天所用时区。 |
minDays / maxDays / allowUnavailable |
number / number / boolean |
范围天数上下限;范围是否可含不可选日期。 |
onFocusedChange / onAnchorChange |
(date, details) => void |
焦点移到另一天前;范围起点变化前,取消则保持原状。 |
controller |
object |
createCalendarController() 创建的控制器。 |
部件属性:CalendarGrid 的 offset(number);CalendarWeekday 的 index(number,必填);CalendarDay 的 value(ISO 日期,必填)与 offset。CalendarWeekdays 的子内容是接收星期索引的函数,CalendarWeeks 的子内容是接收单元格(含 date)的函数;自己写表格时要保留标记为 hidden 的空单元格。
CalendarDay 的渲染函数收到 date、label、selected、today、outside、disabled、unavailable、rangeStart、rangeEnd、inRange,可以在日期下方加价格之类的内容。
键盘与无障碍
遵循 WAI-ARIA 日期选择对话框里的网格模式:每个月是一个以月份命名的 grid,星期表头是以完整名称命名的 columnheader,每个日期是可聚焦的 gridcell,以完整日期命名(例如「2026年10月16日星期五」),并带 aria-selected、aria-disabled,今天另有 aria-current="date"。多选或范围模式的网格带 aria-multiselectable。
网格只有一个 Tab 停靠点:可见的选中日期,否则今天,否则第一个可选日期。移到另一个月会先显示那个月再聚焦日期。
| 按键 | 动作 |
|---|---|
ArrowLeft / ArrowRight |
移动一天,跟随页面书写方向。 |
ArrowUp / ArrowDown |
移动一周。 |
Home / End |
移到本周第一天或最后一天。 |
PageUp / PageDown |
移动一个月,尽量保持同一天。 |
Shift + PageUp / PageDown |
移动一年。 |
Enter / Space |
选中聚焦的日期。 |
Escape |
取消未完成的范围。 |