| 周一 | 周二 | 周三 | 周四 | 周五 | 周六 | 周日 |
|---|---|---|---|---|---|---|
1 需求讨论 | 2 交互评审 键盘走查 | |||||
1 文档校对 | 1 组件评审 | 2 主题验收 发布准备 | 1 版本回顾 | |||
1 需求讨论 | 1 交互评审 | 1 键盘走查 | 1 文档校对 | |||
5 组件评审 主题验收 | 1 交互评审 | 1 键盘走查 | 2 文档校对 组件评审 | 1 主题验收 | ||
1 发布准备 | 1 版本回顾 | |||||
本月 24 项日程,已完成 0 项。
按分类筛选,保留默认月份导航。每天展示两条日程,更多内容通过浮层展开;窄屏只保留数量入口。
'use client'
import { useMemo, useState } from 'react'
import { Button, MonthGrid, Popover, SegmentedControl, Stack, Tag, Text } from '@hina-ui/react'
import { monthGridSchedule } from '../../month-grid'
const events = monthGridSchedule('zh-CN')
const filters = [
{ value: 'all', label: '全部' },
{ value: 'design', label: '设计' },
{ value: 'release', label: '发布' },
]
export default function Demo() {
const [month, setMonth] = useState('2026-09')
const [category, setCategory] = useState<string | number>('all')
const [completed, setCompleted] = useState<number[]>([])
const filtered = useMemo(
() => events.filter(event => category === 'all' || event.kind === category),
[category],
)
const visible = filtered.filter(event => event.date.startsWith(month))
const byDate = useMemo(() => {
const result = new Map<string, typeof events>()
for (const event of filtered) result.set(event.date, [...(result.get(event.date) ?? []), event])
return result
}, [filtered])
const onDate = (date: string) => byDate.get(date) ?? []
function toggle(id: number) {
setCompleted(
completed.includes(id) ? completed.filter(value => value !== id) : [...completed, id],
)
}
return (
<Stack className="w-full max-w-4xl" gap="sm">
<MonthGrid
month={month}
onMonthChange={value => setMonth(value ?? '')}
today="2026-09-21"
dayMinHeight={124}
dayClass="@max-[520px]/hn-month-grid:min-h-20"
label="团队日程"
renderHeaderActions={() => (
<SegmentedControl
value={category}
onValueChange={setCategory}
options={filters}
size="sm"
aria-label="日程分类"
/>
)}
renderDayTrailing={({ date }) =>
onDate(date).length ? (
<Text
size="xs"
tone="muted"
aria-label={`${onDate(date).length} 项日程`}
className="@max-[520px]/hn-month-grid:hidden"
>
{onDate(date).length}
</Text>
) : null
}
renderFooter={() => (
<Text size="sm" tone="muted">
本月 {visible.length} 项日程,已完成{' '}
{visible.filter(event => completed.includes(event.id)).length} 项。
</Text>
)}
>
{({ date, label }) =>
onDate(date).length ? (
<Stack gap="xs" className="min-w-0">
{onDate(date)
.slice(0, 2)
.map(event => (
<Text
key={event.id}
size="xs"
className={`min-w-0 truncate rounded-sm border-s-2 bg-subtle px-1 py-0.5 @max-[520px]/hn-month-grid:hidden ${event.kind === 'design' ? 'border-accent' : 'border-warning'} ${completed.includes(event.id) ? 'text-muted line-through' : ''}`}
>
{event.title}
</Text>
))}
<Popover
aria-label={`${label} · 项日程`}
content={
<Stack className="w-60" gap="lg">
<Text size="sm" weight="medium">
{label}
</Text>
{onDate(date).map(event => (
<Stack key={event.id} gap="xs">
<Text size="sm">
{event.time} · {event.title}
</Text>
<Button
size="sm"
variant="link"
tone="neutral"
className="self-start"
onClick={() => toggle(event.id)}
>
{completed.includes(event.id) ? '撤销完成' : '标记完成'}
</Button>
{completed.includes(event.id) && (
<Tag size="sm" tone="success" className="self-start">
已完成
</Tag>
)}
</Stack>
))}
</Stack>
}
>
<Button
asChild
variant="ghost"
tone="neutral"
size="sm"
ripple={false}
className="hn-press-none h-auto w-full min-w-0 justify-start px-1 py-1"
>
<Text
as="button"
{...{ type: 'button' }}
size="xs"
className="truncate text-start"
aria-label={`${label}, ${onDate(date).length} 项日程`}
>
<Text as="span" size="inherit" className="@max-[520px]/hn-month-grid:hidden">
{onDate(date).length > 2 ? `+${onDate(date).length - 2} 项` : '查看'}
</Text>
<Text
as="span"
size="inherit"
className="hidden @max-[520px]/hn-month-grid:inline"
>
{onDate(date).length}项
</Text>
</Text>
</Button>
</Popover>
</Stack>
) : null
}
</MonthGrid>
<Text size="sm" tone="muted">
按分类筛选,保留默认月份导航。每天展示两条日程,更多内容通过浮层展开;窄屏只保留数量入口。
</Text>
</Stack>
)
}
用法
import { MonthGrid } from '@hina-ui/react'
month / onMonthChange 绑定显示月份,格式为 YYYY-MM。children 是一个函数,接收每天的信息,返回的内容渲染在日期数字下方,可以放文本、状态或独立的操作入口。children 与各个 render* 都是函数,使用它们的组件需要声明 'use client'。
<MonthGrid month={month} onMonthChange={setMonth} today="2026-09-21" label="团队日程">
{({ date }) =>
eventsByDate[date]?.map(event => (
<Text key={event.id} size="xs">
{event.title}
</Text>
))
}
</MonthGrid>
| 场景 | 组件 |
|---|---|
| 选择一个日期,写入表单 | Calendar / DatePicker |
| 按月查看每天的内容或执行当天的操作 | MonthGrid |
MonthGrid 没有选中日期、选中高亮或整格点击回调。需要按钮、链接或浮层时,在 children 或 renderDay 返回的内容中组合;不会生成嵌套按钮,也不会拦截这些控件的键盘事件。
示例
保留日期的局部定制
renderDayTrailing 在日期旁附加状态、节假日或数量,默认日期的格式、今天标记和无障碍名称仍由组件负责。只修改日期数字的呈现时用 renderDate,内容会渲染在原有 time 内;renderDate 适合文本与装饰,不要放交互控件。
下面隐藏内置头部,使用 cellClass 为已签到和休息日铺满整格背景。签到按钮使用 children,签到记录由业务保存。
每月签到
2026 年 9 月
| 周一 | 周二 | 周三 | 周四 | 周五 | 周六 | 周日 |
|---|---|---|---|---|---|---|
休 | ||||||
本月已签到 14 天。浅色底表示已签到;25 日为团队休假日。
'use client'
import { useState } from 'react'
import { Check } from 'lucide-react'
import { Button, Inline, MonthGrid, Stack, Text, type MonthGridDay } from '@hina-ui/react'
export default function Demo() {
const [signed, setSigned] = useState([1, 2, 3, 4, 7, 8, 9, 10, 11, 14, 15, 16, 17, 18])
function cellClass(day: MonthGridDay) {
if (day.isOutside) return
if (signed.includes(day.day)) return 'bg-accent-soft/40'
if (day.weekday === 0 || day.weekday === 6 || day.date === '2026-09-25') return 'bg-subtle'
}
return (
<Stack className="w-full max-w-lg" gap="sm">
<Inline justify="between">
<Text weight="medium">每月签到</Text>
<Text size="sm" tone="muted">
2026 年 9 月
</Text>
</Inline>
<MonthGrid
month="2026-09"
today="2026-09-21"
showHeader={false}
showOutsideDays={false}
dayMinHeight={72}
fixedWeeks={false}
cellClass={cellClass}
size="sm"
label="九月签到记录"
renderDayTrailing={({ date, day, label }) =>
signed.includes(day) ? (
<Text as="span" tone="accent" className="inline-flex" aria-label={`${label} · 已签到`}>
<Check className="size-3" aria-hidden="true" />
</Text>
) : date === '2026-09-25' ? (
<Text size="xs" tone="muted" aria-label="团队休假日">
休
</Text>
) : null
}
>
{({ isToday, day, label }) =>
isToday && !signed.includes(day) ? (
<Button
size="sm"
variant="soft"
className="hn-press-none h-6 min-w-0 px-1 text-xs"
aria-label={`${label} · 签到`}
onClick={() => setSigned([...signed, day])}
>
签到
</Button>
) : null
}
</MonthGrid>
<Text size="sm" tone="muted" role="status">
本月已签到 {signed.length} 天。浅色底表示已签到;25 日为团队休假日。
</Text>
</Stack>
)
}
自定义整格与价格日历
renderDay 替换整个内容区,包括日期头部和默认内容。表格的 td 和内部布局容器仍由组件保留。用 dayPadding={0} 消除组件内边距,再由 renderDay 返回的按钮负责内边距,就能让操作铺满内容区。
这个示例组合了日期、价格、库存、售罄与业务选择状态;按钮自行管理禁用和 aria-pressed。MonthGrid 不持有预订值。
每日房价
2026 年 9 月 · 每晚
| 周一 | 周二 | 周三 | 周四 | 周五 | 周六 | 周日 |
|---|---|---|---|---|---|---|
选择可预订的日期。已过期及售罄日期不可操作。
整格按钮铺满内部空间,背景由 cellClass 控制。价格、库存与选中状态均由业务持有。
'use client'
import { useState } from 'react'
import { Button, Inline, MonthGrid, Stack, Text, type MonthGridDay } from '@hina-ui/react'
import { monthGridPrices } from '../../month-grid'
const prices = monthGridPrices()
const price = (date: string) => prices[date]
export default function Demo() {
const [selected, setSelected] = useState('')
function cellClass(day: MonthGridDay) {
if (selected === day.date) return 'bg-accent-soft'
if (!day.isOutside && (day.isPast || !price(day.date)?.rooms)) return 'bg-subtle'
}
return (
<Stack className="w-full max-w-2xl" gap="sm">
<Inline justify="between" wrap>
<Text weight="medium">每日房价</Text>
<Text size="sm" tone="muted">
2026 年 9 月 · 每晚
</Text>
</Inline>
<MonthGrid
month="2026-09"
today="2026-09-21"
showHeader={false}
showOutsideDays={false}
fixedWeeks={false}
dayMinHeight={104}
dayPadding={0}
cellClass={cellClass}
dayClass="gap-0 @max-[480px]/hn-month-grid:min-h-24"
label="九月房价"
renderDay={({ date, dayLabel, label, isPast, isToday }) => (
<Button
asChild
variant="ghost"
tone="neutral"
size="sm"
ripple={false}
disabled={isPast || !price(date)?.rooms}
className="hn-press-none h-auto w-full flex-1 items-center justify-between gap-1 rounded-none border-0 px-1 py-2 text-center focus-visible:outline-offset-[-2px] @min-[480px]/hn-month-grid:items-start @min-[480px]/hn-month-grid:px-3 @min-[480px]/hn-month-grid:text-start"
onClick={() => setSelected(date)}
>
<Stack
as="button"
{...{ type: 'button', disabled: isPast || !price(date)?.rooms }}
aria-pressed={selected === date}
aria-label={`${label},${price(date)?.rooms ? `¥${price(date)!.price},剩余 ${price(date)!.rooms} 间` : '售罄'}`}
>
<Text
as="time"
{...{ dateTime: date }}
aria-current={isToday ? 'date' : undefined}
size="sm"
tone={isToday ? 'accent' : 'default'}
weight={isToday ? 'medium' : 'normal'}
>
{dayLabel}
</Text>
{price(date)?.rooms ? (
<Text
size="xs"
tone={isPast ? 'muted' : 'accent'}
className="@min-[480px]/hn-month-grid:text-sm"
>
¥{price(date)!.price}
</Text>
) : (
<Text size="xs" tone="muted" className="max-w-full truncate">
售罄
</Text>
)}
<Text size="xs" tone="muted" aria-hidden="true">
<Text as="span" size="inherit" className="@max-[480px]/hn-month-grid:hidden">
{isPast ? '已过期' : price(date)?.rooms ? `余 ${price(date)!.rooms} 间` : '无房'}
</Text>
<Text as="span" size="inherit" className="hidden @max-[480px]/hn-month-grid:inline">
{!isPast && price(date)?.rooms ? `${price(date)!.rooms}间` : '—'}
</Text>
</Text>
</Stack>
</Button>
)}
renderFooter={() => (
<Text size="sm" tone="muted" role="status">
{selected
? `已选择 ${selected},¥${price(selected)!.price} / 晚`
: '选择可预订的日期。已过期及售罄日期不可操作。'}
</Text>
)}
/>
<Text size="sm" tone="muted">
整格按钮铺满内部空间,背景由 cellClass 控制。价格、库存与选中状态均由业务持有。
</Text>
</Stack>
)
}
保留默认导航的工具栏
顶部日程示例通过 renderHeaderActions 添加分类筛选。月份标题、月份/年份选择器和翻页逻辑完整保留;窄容器下操作区自动换到下一行。renderHeaderActions 接收与 renderHeader 相同的上下文。
<MonthGrid
month={month}
onMonthChange={setMonth}
renderHeaderActions={() => (
<Select
value={category}
onValueChange={setCategory}
options={categories}
aria-label="日程分类"
/>
)}
/>
使用 renderHeader 时整体接管头部,renderHeaderActions 不再自动渲染。showHeader={false} 会移除整块头部及其间距,表格仍保留可访问名称。
单元格样式与布局
| 入口 | 作用位置 | 常见用途 |
|---|---|---|
cellClass | 日期的 td | 铺满整格的状态背景、边框、单元格间距 |
dayClass | td 内的内容容器 | 内容对齐、间距、布局方式 |
dayMinHeight | 内容容器的最小高度 | 独立于头部控件大小调整日格 |
dayPadding | 内容容器的内边距 | 紧凑排版或整格操作入口 |
两个 class 属性都接受字符串或 (day: MonthGridDay) => string | undefined。函数获得与 children 相同的上下文,对相邻月份及被隐藏的空格也会执行。调用方样式经过合并,可以覆盖默认样式,不需要后代选择器。
两个尺寸属性接受数值(px)或 CSS 长度,如 112、'7rem'、'var(--my-calendar-spacing)';dayPadding 也接受 '4px 8px'。未设置时使用 Hina 默认尺寸与响应式间距。最小高度不截断内容,内容增多时仍会撑高所在行;限制展示数量和“更多”入口由业务实现。
自定义头部与日期范围
renderHeader 提供当前月份、显示区间和受范围约束的翻页方法,可换成下拉框或自己的工具栏。下面同时演示了非固定周数、相邻月份显隐和周日起始。
min / max 使用 YYYY-MM-DD。翻页不会超出边界月份,日格通过 isDisabled 标出范围外的日期。children、renderDay 返回的按钮需要自己设置 disabled={isDisabled},组件不会干预业务内容。
| 周日 | 周一 | 周二 | 周三 | 周四 | 周五 | 周六 |
|---|---|---|---|---|---|---|
— | — | — | — | — | ||
— | — | — | — | 开放 | 开放 | 开放 |
开放 | 开放 | 开放 | 开放 | 开放 | 开放 | 开放 |
开放 | 开放 | 开放 | 开放 | 开放 | 开放 | 开放 |
开放 | 开放 | 开放 | 开放 |
开放范围:9 月 10 日至 11 月 20 日。每周从周日开始。
'use client'
import { useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { IconButton, Inline, MonthGrid, Select, Stack, Switch, Text } from '@hina-ui/react'
const options = [
{ label: '2026 年 9 月', value: '2026-09' },
{ label: '2026 年 10 月', value: '2026-10' },
{ label: '2026 年 11 月', value: '2026-11' },
]
export default function Demo() {
const [month, setMonth] = useState('2026-09')
const [fixed, setFixed] = useState(false)
const [outside, setOutside] = useState(false)
return (
<Stack className="w-full max-w-lg">
<Inline gap="lg" wrap>
<Switch checked={fixed} onCheckedChange={setFixed}>
固定六周
</Switch>
<Switch checked={outside} onCheckedChange={setOutside}>
显示相邻月份
</Switch>
</Inline>
<MonthGrid
month={month}
onMonthChange={value => setMonth(value ?? '')}
today="2026-09-21"
min="2026-09-10"
max="2026-11-20"
fixedWeeks={fixed}
showOutsideDays={outside}
weekStartsOn={0}
size="sm"
label="开放日期范围"
renderHeader={({ prev, next, canPrev, canNext }) => (
<>
<IconButton label="上个月" size="sm" disabled={!canPrev} onClick={prev}>
<ChevronLeft className="rtl:rotate-180" />
</IconButton>
<Select
value={month}
onValueChange={value => setMonth(String(value))}
options={options}
size="sm"
aria-label="月份"
className="min-w-0 flex-1"
/>
<IconButton label="下个月" size="sm" disabled={!canNext} onClick={next}>
<ChevronRight className="rtl:rotate-180" />
</IconButton>
</>
)}
renderFooter={() => (
<Text size="xs" tone="muted">
开放范围:9 月 10 日至 11 月 20 日。每周从周日开始。
</Text>
)}
>
{({ isDisabled, isOutside }) =>
!isOutside ? (
<Text size="xs" tone="muted" className="text-center">
{isDisabled ? '—' : '开放'}
</Text>
) : null
}
</MonthGrid>
</Stack>
)
}
数据加载
onRangeChange 在客户端挂载后首次调用,之后在月份、一周起始日或周数改变时调用。参数包含显示月份及整个表格的起止日期,均为 ISO 字符串,可据此请求包括相邻月份在内的数据。
<MonthGrid month={month} onMonthChange={setMonth} onRangeChange={loadRange}>
{({ date }) => <Text size="xs">{summaries[date]}</Text>}
</MonthGrid>
请求、加载提示、错误和过期响应处理由调用方管理。SSR 数据应在页面层按已知月份预取,不能依赖只在客户端调用的 onRangeChange。范围始终描述整张表格,即使相邻月份的内容被隐藏。
首屏与布局
- 服务端直接输出星期标题、完整日期格和传入的内容;布局没有浏览器测量阶段。
- 默认以 UTC 计算今天,避免服务器与浏览器本地时区不同。业务有固定时区时传
timeZone;要求跨午夜水合也完全一致时,由页面传入同一份today和month。 - 默认固定六周,切换月份时行数保持不变。
fixedWeeks={false}时为当月所需的四至六周。 size提供日格和控件的默认尺寸,dayMinHeight、dayPadding可独立覆盖日格布局。内容可以自然撑高;窄屏默认减少格内间距。长日程建议限制条数或显示数量,再通过 Popover 或 Dialog 展开。- 使用 Gregorian 日期与本地化的月份、星期名称。一周的起始日默认跟随语言,中文从周一开始。方向继承页面,支持 RTL。
- 无法解析的月份回退到今天所在月,合法但越界的月份显示最近的边界月;非法日期边界被忽略,反向的范围整体忽略。回退不会调用
onMonthChange,调用方的month保持原值。
无障碍
展示区使用原生 table、caption 和带 scope="col" 的星期表头。默认日期使用具备完整日期名称的 time 节点,今天带有 aria-current="date"。日期格不是可选择的 ARIA grid,没有额外的 Tab 停靠点。
children 与 render* 返回的按钮、链接正常参与 Tab 顺序。替换整格时,保留日期名称,并为图标或操作补齐可访问名称。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
month | string | 今天所在月 | YYYY-MM,可受控 |
dir | 'ltr' | 'rtl' | 继承 | 表格及月份、年份选择器的方向 |
today | string | 按时区计算 | YYYY-MM-DD,指定今天并覆盖自动计算 |
timeZone | string | 'UTC' | 计算今天使用的 IANA 时区 |
min / max | string | — | 日期边界, YYYY-MM-DD |
weekStartsOn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 随语言 | 一周起始日,0 为周日 |
weekdayFormat | 'narrow' | 'short' | 'long' | 'short' | 星期显示格式 |
fixedWeeks | boolean | true | 固定六周 |
showOutsideDays | boolean | true | 是否渲染相邻月份内容;关闭时保留空格 |
showToday | boolean | true | 默认头部显示回到本月按钮 |
disabled | boolean | false | 停用默认导航,并传递禁用状态 |
size | 'sm' | 'md' | 'lg' | 'md' | 日期格与默认导航尺寸 |
label | string | 本地化“日历” | 表格名称,自动附带当前月份 |
className | string | — | 根节点样式;原生属性及 style 也传到根节点 |
showHeader | boolean | true | 显示内置头部或 renderHeader;关闭时不留间距 |
dayMinHeight | number | string | 随 size | 日格最小高度;数值为 px,内容可撑高 |
dayPadding | number | string | 响应式间距 | 日格内边距;数值为 px,0 适合整格操作 |
cellClass | string | ((day: MonthGridDay) => string | undefined) | — | 日期 td 样式 |
dayClass | string | ((day: MonthGridDay) => string | undefined) | — | 日期内容容器样式 |
内容属性
| 属性 | 参数 | 说明 |
|---|---|---|
children | MonthGridDay | 日期头部下方的内容 |
renderDay | MonthGridDay | 替换内容区,保留 td 与布局容器;优先于 renderDate、renderDayTrailing 和 children |
renderDate | MonthGridDay | 替换 time 内的日期内容,保留日期语义与今天标记 |
renderDayTrailing | MonthGridDay | 默认日期旁的补充内容 |
renderHeader | MonthGridHeader | 替换整个导航栏 |
renderHeaderActions | MonthGridHeader | 默认导航旁的操作区;窄屏换行 |
renderWeekday | { day, label, fullLabel } | 星期表头,day 为 0–6 |
renderFooter | { month, start, end } | 表格底部补充内容 |
MonthGridDay 包含 date(ISO 日期)、day(日数)、dayLabel(本地化日数文本)、weekday(0–6,0 为周日)、label(完整日期名称)、isToday、isPast、isFuture、isOutside、isDisabled。过去/未来相对于同一份 today 按日期判断;周末、节假日及业务可用性由调用方定义。
MonthGridHeader 包含 month、start、end、label、canPrev、canNext、canToday、prev()、next()、goToToday()。
回调
| 回调 | 参数 | 说明 |
|---|---|---|
onMonthChange | string | 用户切换显示月份 |
onRangeChange | { month, start, end } | 客户端初次挂载及显示日期范围改变 |
Ref
| 名称 | 类型 | 说明 |
|---|---|---|
range | MonthGridRange | 当前月份与表格日期范围 |
prev / next | () => void | 切换月份,遵循范围与禁用状态 |
goToToday | () => void | 回到今天所在月份,遵循范围与禁用状态 |