MonthGrid 月份日历

在月份网格中展示日程、签到、价格或每日状态。

团队日程 · 2026年9月
周一周二周三周四周五周六周日

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>
  )
}
tsx

用法

import { MonthGrid } from '@hina-ui/react'
ts

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>
tsx
场景组件
选择一个日期,写入表单Calendar / DatePicker
按月查看每天的内容或执行当天的操作MonthGrid

MonthGrid 没有选中日期、选中高亮或整格点击回调。需要按钮、链接或浮层时,在 children 或 renderDay 返回的内容中组合;不会生成嵌套按钮,也不会拦截这些控件的键盘事件。

示例

保留日期的局部定制

renderDayTrailing 在日期旁附加状态、节假日或数量,默认日期的格式、今天标记和无障碍名称仍由组件负责。只修改日期数字的呈现时用 renderDate,内容会渲染在原有 time 内;renderDate 适合文本与装饰,不要放交互控件。

下面隐藏内置头部,使用 cellClass 为已签到和休息日铺满整格背景。签到按钮使用 children,签到记录由业务保存。

每月签到

2026 年 9 月

九月签到记录 · 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>
  )
}
tsx

自定义整格与价格日历

renderDay 替换整个内容区,包括日期头部和默认内容。表格的 td 和内部布局容器仍由组件保留。用 dayPadding={0} 消除组件内边距,再由 renderDay 返回的按钮负责内边距,就能让操作铺满内容区。

这个示例组合了日期、价格、库存、售罄与业务选择状态;按钮自行管理禁用和 aria-pressed。MonthGrid 不持有预订值。

每日房价

2026 年 9 月 · 每晚

九月房价 · 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>
  )
}
tsx

保留默认导航的工具栏

顶部日程示例通过 renderHeaderActions 添加分类筛选。月份标题、月份/年份选择器和翻页逻辑完整保留;窄容器下操作区自动换到下一行。renderHeaderActions 接收与 renderHeader 相同的上下文。

<MonthGrid
  month={month}
  onMonthChange={setMonth}
  renderHeaderActions={() => (
    <Select
      value={category}
      onValueChange={setCategory}
      options={categories}
      aria-label="日程分类"
    />
  )}
/>
tsx

使用 renderHeader 时整体接管头部,renderHeaderActions 不再自动渲染。showHeader={false} 会移除整块头部及其间距,表格仍保留可访问名称。

单元格样式与布局

入口作用位置常见用途
cellClass日期的 td铺满整格的状态背景、边框、单元格间距
dayClasstd 内的内容容器内容对齐、间距、布局方式
dayMinHeight内容容器的最小高度独立于头部控件大小调整日格
dayPadding内容容器的内边距紧凑排版或整格操作入口

两个 class 属性都接受字符串或 (day: MonthGridDay) => string | undefined。函数获得与 children 相同的上下文,对相邻月份及被隐藏的空格也会执行。调用方样式经过合并,可以覆盖默认样式,不需要后代选择器。

两个尺寸属性接受数值(px)或 CSS 长度,如 112、'7rem'、'var(--my-calendar-spacing)';dayPadding 也接受 '4px 8px'。未设置时使用 Hina 默认尺寸与响应式间距。最小高度不截断内容,内容增多时仍会撑高所在行;限制展示数量和“更多”入口由业务实现。

数据加载

onRangeChange 在客户端挂载后首次调用,之后在月份、一周起始日或周数改变时调用。参数包含显示月份及整个表格的起止日期,均为 ISO 字符串,可据此请求包括相邻月份在内的数据。

<MonthGrid month={month} onMonthChange={setMonth} onRangeChange={loadRange}>
  {({ date }) => <Text size="xs">{summaries[date]}</Text>}
</MonthGrid>
tsx

请求、加载提示、错误和过期响应处理由调用方管理。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
回到今天所在月份,遵循范围与禁用状态