wui
组件

日历 Calendar

具备完整月份网格、流畅悬停跟随动效、无障碍键盘移动与事件排期能力的月视图日历与面板组件。

第三方依赖 · lucide-react第三方依赖 · motion

基础用法

最简单的单月日历。支持点击日期、键盘方向键穿梭、月度翻页与今天快速标记:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/calendar
安装基础依赖与动效库
pnpm add motion lucide-react clsx tailwind-merge
复制组件源码到 components/ui/calendar.tsx
components/ui/calendar.tsx
"use client"

import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type Variants,
} from "motion/react"

import { cn } from "@/lib/utils"

function startOfMonth(date: Date) {
  return new Date(date.getFullYear(), date.getMonth(), 1)
}

function sameDay(a: Date, b: Date) {
  return (
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
  )
}

function monthDays(month: Date) {
  const first = startOfMonth(month)
  const gridStart = new Date(first)
  gridStart.setDate(1 - first.getDay())
  return Array.from({ length: 42 }, (_, index) => {
    const date = new Date(gridStart)
    date.setDate(gridStart.getDate() + index)
    return date
  })
}

function chunk<T>(items: T[], size: number) {
  return Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
    items.slice(index * size, index * size + size)
  )
}

function dateAttr(date: Date) {
  return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
}

type CalendarView = "day" | "month" | "year"
type ViewTransition = { kind: "slide" | "zoom"; direction: 1 | -1 }

const EASE = [0.22, 1, 0.36, 1] as const

const viewVariants: Variants = {
  enter: ({ kind, direction }: ViewTransition) =>
    kind === "slide"
      ? { opacity: 0, x: direction * 28 }
      : { opacity: 0, scale: direction > 0 ? 1.08 : 0.92 },
  center: { opacity: 1, x: 0, scale: 1 },
  exit: ({ kind, direction }: ViewTransition) =>
    kind === "slide"
      ? { opacity: 0, x: direction * -28 }
      : { opacity: 0, scale: direction > 0 ? 0.92 : 1.08 },
}

const cellSpring = {
  type: "spring",
  stiffness: 520,
  damping: 38,
  mass: 0.7,
} as const

export interface CalendarProps extends Omit<
  React.ComponentProps<"div">,
  "onChange" | "defaultValue"
> {
  /** 受控模式下的选中日期。 */
  value?: Date
  /** 非受控模式下的初始选中日期。 */
  defaultValue?: Date
  /** 选中日期时触发。 */
  onValueChange?: (date: Date) => void
  /** 受控模式下的可见月份。 */
  month?: Date
  /** 非受控模式下的初始可见月份。 */
  defaultMonth?: Date
  /** 切换可见月份(含切换年份)时触发。 */
  onMonthChange?: (month: Date) => void
  /** 可选择的最早日期。 */
  min?: Date
  /** 可选择的最晚日期。 */
  max?: Date
  /** 自定义禁用日期判断。 */
  disabled?: (date: Date) => boolean
  /** 是否显示相邻月份的补齐日期。@default true */
  showOutsideDays?: boolean
  /** 标题、星期与月份名称使用的语言。@default "zh-CN" */
  locale?: string
  /** 是否允许点击标题切换到月份 / 年份视图。@default true */
  enableViewSwitch?: boolean
}

/** 支持方向感知切换、月份 / 年份视图与完整键盘操作的单月日历。 */
function Calendar({
  className,
  value,
  defaultValue,
  onValueChange,
  month,
  defaultMonth,
  onMonthChange,
  min,
  max,
  disabled,
  showOutsideDays = true,
  locale = "zh-CN",
  enableViewSwitch = true,
  ...props
}: CalendarProps) {
  const reduceMotion = useReducedMotion()
  const selectionId = React.useId()
  const hoverRef = React.useRef<HTMLDivElement>(null)
  const [hover, setHover] = React.useState<{
    x: number
    y: number
    width: number
    height: number
    jump: boolean
  } | null>(null)
  const today = React.useMemo(() => new Date(), [])
  const [internalValue, setInternalValue] = React.useState(defaultValue)
  const selected = value ?? internalValue
  const [internalMonth, setInternalMonth] = React.useState(() =>
    startOfMonth(defaultMonth ?? value ?? defaultValue ?? today)
  )
  const [view, setView] = React.useState<CalendarView>("day")
  const [transition, setTransition] = React.useState<ViewTransition>({
    kind: "slide",
    direction: 1,
  })
  const visibleMonth = startOfMonth(month ?? internalMonth)
  const visibleYear = visibleMonth.getFullYear()
  const monthKey = `${visibleYear}-${visibleMonth.getMonth()}`
  const decadeStart = visibleYear - (visibleYear % 12)
  const days = React.useMemo(
    () => monthDays(visibleMonth),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [monthKey]
  )
  const weekdays = React.useMemo(() => {
    const sunday = new Date(2026, 7, 2)
    return Array.from({ length: 7 }, (_, index) => {
      const day = new Date(sunday)
      day.setDate(sunday.getDate() + index)
      return {
        short: new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format(day),
        long: new Intl.DateTimeFormat(locale, { weekday: "long" }).format(day),
      }
    })
  }, [locale])
  const dayLabel = React.useMemo(
    () => new Intl.DateTimeFormat(locale, { dateStyle: "full" }),
    [locale]
  )

  const minDay = min
    ? new Date(min.getFullYear(), min.getMonth(), min.getDate())
    : undefined
  const maxDay = max
    ? new Date(max.getFullYear(), max.getMonth(), max.getDate(), 23, 59, 59)
    : undefined
  const transitionConfig = reduceMotion
    ? { duration: 0 }
    : { duration: 0.28, ease: EASE }

  function setMonth(next: Date, kind: ViewTransition["kind"] = "slide") {
    const start = startOfMonth(next)
    if (start.getTime() === visibleMonth.getTime()) return
    setHover(null)
    setTransition({ kind, direction: start > visibleMonth ? 1 : -1 })
    if (month === undefined) setInternalMonth(start)
    onMonthChange?.(start)
  }

  function switchView(next: CalendarView, direction: 1 | -1) {
    setHover(null)
    setTransition({ kind: "zoom", direction })
    setView(next)
  }

  function isDisabled(date: Date) {
    return (
      (minDay ? date < minDay : false) ||
      (maxDay ? date > maxDay : false) ||
      disabled?.(date) === true
    )
  }

  function isMonthDisabled(year: number, monthIndex: number) {
    const first = new Date(year, monthIndex, 1)
    const last = new Date(year, monthIndex + 1, 0, 23, 59, 59)
    return (maxDay ? first > maxDay : false) || (minDay ? last < minDay : false)
  }

  function isYearDisabled(year: number) {
    return (
      (maxDay ? year > maxDay.getFullYear() : false) ||
      (minDay ? year < minDay.getFullYear() : false)
    )
  }

  function select(date: Date) {
    if (isDisabled(date)) return
    if (value === undefined) setInternalValue(date)
    onValueChange?.(date)
    if (date.getMonth() !== visibleMonth.getMonth()) setMonth(date)
  }

  function step(direction: 1 | -1) {
    const offset = view === "day" ? 1 : view === "month" ? 12 : 144
    setMonth(
      new Date(visibleYear, visibleMonth.getMonth() + direction * offset, 1)
    )
  }

  function focusDate(root: Element | null, target: Date, gridKey: string) {
    const selector = `[data-calendar-grid="${gridKey}"] button[data-date="${dateAttr(target)}"]`
    requestAnimationFrame(() =>
      root?.querySelector<HTMLButtonElement>(selector)?.focus()
    )
  }

  function moveFocus(event: React.KeyboardEvent<HTMLButtonElement>, date: Date) {
    const next = new Date(date)
    switch (event.key) {
      case "ArrowLeft":
        next.setDate(date.getDate() - 1)
        break
      case "ArrowRight":
        next.setDate(date.getDate() + 1)
        break
      case "ArrowUp":
        next.setDate(date.getDate() - 7)
        break
      case "ArrowDown":
        next.setDate(date.getDate() + 7)
        break
      case "Home":
        next.setDate(date.getDate() - date.getDay())
        break
      case "End":
        next.setDate(date.getDate() + (6 - date.getDay()))
        break
      case "PageUp":
      case "PageDown": {
        const delta = (event.key === "PageUp" ? -1 : 1) * (event.shiftKey ? 12 : 1)
        const target = new Date(date.getFullYear(), date.getMonth() + delta, 1)
        const lastDay = new Date(
          target.getFullYear(),
          target.getMonth() + 1,
          0
        ).getDate()
        next.setTime(
          new Date(
            target.getFullYear(),
            target.getMonth(),
            Math.min(date.getDate(), lastDay)
          ).getTime()
        )
        break
      }
      default:
        return
    }
    event.preventDefault()
    const nextKey = `${next.getFullYear()}-${next.getMonth()}`
    if (nextKey !== monthKey) setMonth(next)
    focusDate(
      event.currentTarget.closest('[data-slot="calendar"]'),
      next,
      nextKey
    )
  }

  // 选中日期在当前月时聚焦选中日,否则依次回退到今天、本月第一天。
  const focusableDate =
    (selected && dateAttr(selected).startsWith(`${monthKey}-`)
      ? selected
      : undefined) ??
    (today.getFullYear() === visibleYear &&
    today.getMonth() === visibleMonth.getMonth()
      ? today
      : visibleMonth)

  const heading =
    view === "day"
      ? new Intl.DateTimeFormat(locale, { year: "numeric", month: "long" }).format(
          visibleMonth
        )
      : view === "month"
        ? new Intl.DateTimeFormat(locale, { year: "numeric" }).format(
            visibleMonth
          )
        : `${decadeStart} – ${decadeStart + 11}`

  const navLabel =
    view === "day" ? "月" : view === "month" ? "年" : "个 12 年"
  const panelKey =
    view === "day"
      ? `day-${monthKey}`
      : view === "month"
        ? `month-${visibleYear}`
        : `year-${decadeStart}`

  return (
    <div
      data-slot="calendar"
      data-view={view}
      className={cn("w-[19rem] select-none overflow-hidden p-3", className)}
      {...props}
    >
      <div className="mb-3 flex items-center justify-between gap-1">
        <button
          type="button"
          aria-label={`上一${navLabel}`}
          className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/30 flex size-8 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-[3px]"
          onClick={() => step(-1)}
        >
          <ChevronLeftIcon className="size-4" />
        </button>
        <span className="sr-only" aria-live="polite">
          {heading}
        </span>
        <button
          type="button"
          disabled={!enableViewSwitch || view === "year"}
          aria-label={
            enableViewSwitch && view !== "year"
              ? `${heading},切换到${view === "day" ? "月份" : "年份"}视图`
              : undefined
          }
          className="hover:bg-accent focus-visible:ring-ring/30 relative flex h-8 min-w-0 items-center justify-center overflow-hidden rounded-md px-2.5 text-sm font-semibold tracking-tight outline-none transition-colors focus-visible:ring-[3px] disabled:hover:bg-transparent"
          onClick={() => switchView(view === "day" ? "month" : "year", 1)}
        >
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              key={heading}
              className="tabular-nums whitespace-nowrap"
              initial={
                reduceMotion
                  ? false
                  : transition.kind === "slide"
                    ? { opacity: 0, y: transition.direction * 8 }
                    : { opacity: 0, scale: 0.94 }
              }
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={
                reduceMotion
                  ? undefined
                  : transition.kind === "slide"
                    ? { opacity: 0, y: transition.direction * -8 }
                    : { opacity: 0, scale: 1.06 }
              }
              transition={transitionConfig}
            >
              {heading}
            </motion.span>
          </AnimatePresence>
        </button>
        <button
          type="button"
          aria-label={`下一${navLabel}`}
          className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/30 flex size-8 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-[3px]"
          onClick={() => step(1)}
        >
          <ChevronRightIcon className="size-4" />
        </button>
      </div>

      {view === "day" ? (
        <div role="presentation" className="mb-1 grid grid-cols-7">
          {weekdays.map((day) => (
            <abbr
              key={day.long}
              title={day.long}
              className="text-muted-foreground flex h-8 items-center justify-center text-[11px] font-medium no-underline"
            >
              {day.short}
            </abbr>
          ))}
        </div>
      ) : null}

      <div
        ref={hoverRef}
        className="relative"
        onPointerLeave={() => setHover(null)}
      >
        <motion.div
          aria-hidden
          className="bg-accent pointer-events-none absolute left-0 top-0 z-0 rounded-md"
          initial={false}
          animate={
            hover
              ? {
                  x: hover.x,
                  y: hover.y,
                  width: hover.width,
                  height: hover.height,
                  opacity: 1,
                }
              : { opacity: 0 }
          }
          transition={
            reduceMotion || hover?.jump
              ? { duration: 0, opacity: { duration: 0.12 } }
              : { ...cellSpring, opacity: { duration: 0.12 } }
          }
        />
        <AnimatePresence
          initial={false}
          mode="popLayout"
          custom={transition}
        >
          <motion.div
            key={panelKey}
            custom={transition}
            variants={viewVariants}
            initial={reduceMotion ? false : "enter"}
            animate="center"
            exit={reduceMotion ? undefined : "exit"}
            transition={transitionConfig}
            className="relative z-10"
          >
            {view === "day" ? (
              <div
                role="grid"
                aria-label={heading}
                data-calendar-grid={monthKey}
                className="grid gap-y-1"
              >
                {chunk(days, 7).map((week) => (
                  <div
                    key={dateAttr(week[0])}
                    role="row"
                    className="grid grid-cols-7"
                  >
                    {week.map((date) => {
                      const outside =
                        date.getMonth() !== visibleMonth.getMonth()
                      const active = selected ? sameDay(date, selected) : false
                      const current = sameDay(date, today)
                      const unavailable = isDisabled(date)
                      const hidden = outside && !showOutsideDays
                      return (
                        <div
                          key={dateAttr(date)}
                          role="gridcell"
                          aria-selected={active || undefined}
                          className="flex items-center justify-center"
                          onPointerEnter={(event) => {
                            if (
                              event.pointerType === "touch" ||
                              unavailable ||
                              active ||
                              hidden
                            ) {
                              setHover(null)
                              return
                            }
                            const box = hoverRef.current?.getBoundingClientRect()
                            const cell = event.currentTarget.firstElementChild?.getBoundingClientRect()
                            if (!box || !cell) return
                            setHover((previous) => ({
                              x: cell.left - box.left,
                              y: cell.top - box.top,
                              width: cell.width,
                              height: cell.height,
                              jump: previous === null,
                            }))
                          }}
                        >
                          <button
                            type="button"
                            data-date={dateAttr(date)}
                            data-outside={outside || undefined}
                            aria-label={dayLabel.format(date)}
                            aria-current={current ? "date" : undefined}
                            disabled={unavailable}
                            tabIndex={
                              sameDay(date, focusableDate) && !outside ? 0 : -1
                            }
                            className={cn(
                              "focus-visible:ring-ring/30 relative isolate flex size-9 items-center justify-center rounded-md text-sm tabular-nums outline-none transition-colors duration-150 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-30",
                              outside && "text-muted-foreground/55",
                              hidden && "invisible",
                              current && !active && "text-primary font-semibold",
                              active && "text-primary-foreground font-medium"
                            )}
                            onClick={() => select(date)}
                            onKeyDown={(event) => moveFocus(event, date)}
                          >
                            {active ? (
                              <motion.span
                                aria-hidden
                                layoutId={`${selectionId}-${monthKey}`}
                                className="bg-primary absolute inset-0 z-[-1] rounded-md"
                                initial={
                                  reduceMotion ? false : { scale: 0.8, opacity: 0 }
                                }
                                animate={{ scale: 1, opacity: 1 }}
                                transition={
                                  reduceMotion ? { duration: 0 } : cellSpring
                                }
                              />
                            ) : null}
                            {date.getDate()}
                            {current ? (
                              <span
                                aria-hidden
                                className={cn(
                                  "absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full transition-colors",
                                  active ? "bg-primary-foreground" : "bg-primary"
                                )}
                              />
                            ) : null}
                          </button>
                        </div>
                      )
                    })}
                  </div>
                ))}
              </div>
            ) : (
              <div
                role="group"
                aria-label={heading}
                className="grid h-[17rem] grid-cols-3 grid-rows-4 gap-2"
              >
                {(view === "month"
                  ? Array.from({ length: 12 }, (_, index) => index)
                  : Array.from({ length: 12 }, (_, index) => decadeStart + index)
                ).map((item) => {
                  const isMonth = view === "month"
                  const label = isMonth
                    ? new Intl.DateTimeFormat(locale, { month: "short" }).format(
                        new Date(visibleYear, item, 1)
                      )
                    : String(item)
                  const active = isMonth
                    ? item === visibleMonth.getMonth()
                    : item === visibleYear
                  const current = isMonth
                    ? item === today.getMonth() &&
                      visibleYear === today.getFullYear()
                    : item === today.getFullYear()
                  const unavailable = isMonth
                    ? isMonthDisabled(visibleYear, item)
                    : isYearDisabled(item)
                  return (
                    <button
                      key={item}
                      type="button"
                      aria-pressed={active}
                      aria-current={current ? "date" : undefined}
                      disabled={unavailable}
                      className={cn(
                        "hover:bg-accent focus-visible:ring-ring/30 relative isolate flex items-center justify-center rounded-md text-sm tabular-nums outline-none transition-colors duration-150 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-30",
                        current && !active && "text-primary font-semibold",
                        active &&
                          "text-primary-foreground font-medium hover:bg-transparent"
                      )}
                      onClick={() => {
                        if (isMonth) {
                          setMonth(new Date(visibleYear, item, 1), "zoom")
                          switchView("day", -1)
                        } else {
                          setMonth(
                            new Date(item, visibleMonth.getMonth(), 1),
                            "zoom"
                          )
                          switchView("month", -1)
                        }
                      }}
                    >
                      {active ? (
                        <motion.span
                          aria-hidden
                          layoutId={`${selectionId}-${panelKey}`}
                          className="bg-primary absolute inset-0 z-[-1] rounded-md"
                          transition={
                            reduceMotion ? { duration: 0 } : cellSpring
                          }
                        />
                      ) : null}
                      {label}
                    </button>
                  )
                })}
              </div>
            )}
          </motion.div>
        </AnimatePresence>
      </div>
    </div>
  )
}

export type CalendarEventTone =
  "primary" | "info" | "success" | "warning" | "neutral"

export interface CalendarEvent {
  /** Stable event identifier. */
  id: string
  /** Date on which the event is displayed. */
  date: Date
  /** Short event label. */
  title: string
  /** Optional time or supporting metadata. */
  meta?: string
  /** Semantic event color. @default "primary" */
  tone?: CalendarEventTone
}

export interface CalendarPanelProps extends Omit<
  React.ComponentProps<"div">,
  "onChange" | "defaultValue"
> {
  /** Selected date. */
  value?: Date
  /** Initially selected date in uncontrolled mode. */
  defaultValue?: Date
  /** Called when a day is selected. */
  onValueChange?: (date: Date) => void
  /** Visible month. */
  month?: Date
  /** Initial visible month in uncontrolled mode. */
  defaultMonth?: Date
  /** Called when month navigation changes the visible month. */
  onMonthChange?: (month: Date) => void
  /** Events rendered inside the month grid. */
  events?: CalendarEvent[]
  /** Maximum visible events in each day cell. @default 3 */
  maxVisibleEvents?: number
  /** Called when an event is activated. */
  onEventClick?: (event: CalendarEvent) => void
  /** Locale used for headings and weekday labels. @default "zh-CN" */
  locale?: string
}

const EVENT_TONES: Record<CalendarEventTone, string> = {
  primary: "bg-primary/9 text-primary hover:bg-primary/14",
  info: "bg-info/10 text-info hover:bg-info/15",
  success: "bg-success/10 text-success hover:bg-success/15",
  warning: "bg-warning/12 text-warning hover:bg-warning/17",
  neutral:
    "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground",
}

function dateKey(date: Date) {
  return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
}

/** A full-width month planner with event density, selection, and animated navigation. */
function CalendarPanel({
  className,
  value,
  defaultValue,
  onValueChange,
  month,
  defaultMonth,
  onMonthChange,
  events = [],
  maxVisibleEvents = 3,
  onEventClick,
  locale = "zh-CN",
  ...props
}: CalendarPanelProps) {
  const reduceMotion = useReducedMotion()
  const selectionId = React.useId()
  const today = React.useMemo(() => new Date(), [])
  const [internalValue, setInternalValue] = React.useState(defaultValue)
  const selected = value ?? internalValue
  const [internalMonth, setInternalMonth] = React.useState(() =>
    startOfMonth(defaultMonth ?? value ?? defaultValue ?? today)
  )
  const [direction, setDirection] = React.useState(0)
  const visibleMonth = startOfMonth(month ?? internalMonth)
  const days = React.useMemo(
    () => monthDays(visibleMonth),
    [visibleMonth.getFullYear(), visibleMonth.getMonth()]
  )
  const weekdays = React.useMemo(() => {
    const sunday = new Date(2026, 7, 2)
    return Array.from({ length: 7 }, (_, index) => {
      const day = new Date(sunday)
      day.setDate(sunday.getDate() + index)
      return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(day)
    })
  }, [locale])
  const eventsByDate = React.useMemo(() => {
    const grouped = new Map<string, CalendarEvent[]>()
    events.forEach((event) => {
      const key = dateKey(event.date)
      grouped.set(key, [...(grouped.get(key) ?? []), event])
    })
    return grouped
  }, [events])

  function changeMonth(next: Date, nextDirection: number) {
    const start = startOfMonth(next)
    setDirection(nextDirection)
    if (month === undefined) setInternalMonth(start)
    onMonthChange?.(start)
  }

  function select(date: Date) {
    if (value === undefined) setInternalValue(date)
    onValueChange?.(date)
    if (startOfMonth(date).getTime() !== visibleMonth.getTime()) {
      changeMonth(date, date < visibleMonth ? -1 : 1)
    }
  }

  const monthLabel = new Intl.DateTimeFormat(locale, {
    year: "numeric",
    month: "long",
  }).format(visibleMonth)

  return (
    <div
      data-slot="calendar-panel"
      className={cn("bg-background min-w-0 border", className)}
      {...props}
    >
      <div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
        <div>
          <p className="text-muted-foreground text-xs font-medium">月视图</p>
          <h2
            aria-live="polite"
            className="relative mt-0.5 overflow-hidden text-lg font-semibold tracking-tight"
          >
            <AnimatePresence initial={false} mode="popLayout">
              <motion.span
                key={monthLabel}
                className="block whitespace-nowrap"
                initial={reduceMotion ? false : { opacity: 0, y: direction >= 0 ? 10 : -10 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduceMotion ? undefined : { opacity: 0, y: direction >= 0 ? -10 : 10 }}
                transition={reduceMotion ? { duration: 0 } : { duration: 0.26, ease: EASE }}
              >
                {monthLabel}
              </motion.span>
            </AnimatePresence>
          </h2>
        </div>
        <div className="flex items-center gap-1">
          <button
            type="button"
            className="hover:bg-accent focus-visible:ring-ring/30 mr-2 h-8 rounded-md border px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]"
            onClick={() => select(today)}
          >
            今天
          </button>
          <button
            type="button"
            aria-label="上个月"
            className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/30 flex size-8 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-[3px]"
            onClick={() =>
              changeMonth(
                new Date(
                  visibleMonth.getFullYear(),
                  visibleMonth.getMonth() - 1,
                  1
                ),
                -1
              )
            }
          >
            <ChevronLeftIcon className="size-4" />
          </button>
          <button
            type="button"
            aria-label="下个月"
            className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/30 flex size-8 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-[3px]"
            onClick={() =>
              changeMonth(
                new Date(
                  visibleMonth.getFullYear(),
                  visibleMonth.getMonth() + 1,
                  1
                ),
                1
              )
            }
          >
            <ChevronRightIcon className="size-4" />
          </button>
        </div>
      </div>

      <div className="overflow-x-auto">
        <div className="relative min-w-[48rem]">
          <div className="bg-muted/25 grid grid-cols-7 border-b">
            {weekdays.map((weekday, index) => (
              <div
                key={`${weekday}-${index}`}
                role="columnheader"
                className="text-muted-foreground px-3 py-2 text-xs font-medium"
              >
                {weekday}
              </div>
            ))}
          </div>
          <AnimatePresence
            initial={false}
            mode="popLayout"
            custom={{ kind: "slide", direction: direction >= 0 ? 1 : -1 }}
          >
            <motion.div
              key={`${visibleMonth.getFullYear()}-${visibleMonth.getMonth()}`}
              role="grid"
              aria-label={monthLabel}
              className="grid grid-cols-7"
              custom={{ kind: "slide", direction: direction >= 0 ? 1 : -1 }}
              variants={viewVariants}
              initial={reduceMotion ? false : "enter"}
              animate="center"
              exit={reduceMotion ? undefined : "exit"}
              transition={
                reduceMotion ? { duration: 0 } : { duration: 0.26, ease: EASE }
              }
            >
              {chunk(days, 7).map((week) => (
                <div key={dateKey(week[0])} role="row" className="contents">
              {week.map((date) => {
                const outside = date.getMonth() !== visibleMonth.getMonth()
                const active = selected ? sameDay(date, selected) : false
                const current = sameDay(date, today)
                const dayEvents = eventsByDate.get(dateKey(date)) ?? []
                const visibleEvents = dayEvents.slice(0, maxVisibleEvents)
                const remaining = dayEvents.length - visibleEvents.length

                return (
                  <div
                    key={date.toISOString()}
                    role="gridcell"
                    aria-selected={active}
                    className={cn(
                      "hover:bg-accent/25 relative isolate min-h-28 border-b border-r p-2 transition-colors",
                      outside && "bg-muted/15 text-muted-foreground"
                    )}
                  >
                    {active ? (
                      <motion.div
                        aria-hidden
                        layoutId={`${selectionId}-panel-selection`}
                        className="border-primary pointer-events-none absolute inset-0 z-0 border-2"
                        transition={
                          reduceMotion
                            ? { duration: 0 }
                            : {
                                type: "spring",
                                stiffness: 360,
                                damping: 35,
                                mass: 0.75,
                              }
                        }
                      />
                    ) : null}
                    <div className="relative z-10 flex items-start justify-between">
                      <button
                        type="button"
                        aria-label={new Intl.DateTimeFormat(locale, {
                          month: "long",
                          day: "numeric",
                        }).format(date)}
                        aria-current={current ? "date" : undefined}
                        className={cn(
                          "focus-visible:ring-ring/40 flex size-7 items-center justify-center rounded-full text-xs font-medium tabular-nums outline-none transition-colors focus-visible:ring-2",
                          current && "bg-primary text-primary-foreground",
                          !current && "hover:bg-accent"
                        )}
                        onClick={() => select(date)}
                      >
                        {date.getDate()}
                      </button>
                      {dayEvents.length ? (
                        <span className="text-muted-foreground text-[10px] tabular-nums">
                          {dayEvents.length}
                        </span>
                      ) : null}
                    </div>
                    <div className="relative z-10 mt-1 grid gap-1">
                      {visibleEvents.map((event) => {
                        const eventClassName = cn(
                          "min-w-0 px-1.5 py-1 text-left text-[11px] leading-4 transition-colors",
                          EVENT_TONES[event.tone ?? "primary"]
                        )
                        const content = (
                          <>
                            {event.meta ? (
                              <span className="mr-1 opacity-70">
                                {event.meta}
                              </span>
                            ) : null}
                            <span className="font-medium">{event.title}</span>
                          </>
                        )

                        return onEventClick ? (
                          <button
                            type="button"
                            key={event.id}
                            className={eventClassName}
                            onClick={() => onEventClick(event)}
                          >
                            {content}
                          </button>
                        ) : (
                          <div key={event.id} className={eventClassName}>
                            {content}
                          </div>
                        )
                      })}
                      {remaining > 0 ? (
                        <span className="text-muted-foreground px-1.5 text-[11px] font-medium">
                          还有 {remaining} 项
                        </span>
                      ) : null}
                    </div>
                  </div>
                )
              })}
                </div>
              ))}
            </motion.div>
          </AnimatePresence>
        </div>
      </div>
    </div>
  )
}

export { Calendar, CalendarPanel, sameDay, startOfMonth }

属性 Props

Calendar

<Calendar> 用于标准月度日期选择:

属性类型默认值说明
valueDate—受控模式下的当前选中日期对象。
defaultValueDate—非受控模式下的初始选中日期对象。
onValueChange(date: Date) => void—当用户在网格中点击或通过键盘选择新日期时触发的回调函数。
monthDate—受控模式下当前可见的月份(只需传入目标月份的任意一天)。
defaultMonthDate—非受控模式下初始展示的月份,默认为今天所在月或 `defaultValue` 所在月。
onMonthChange(month: Date) => void—用户点击上一月/下一月按钮或跨月键盘移动切换月份时触发。
minDate—允许选择的最早起始日期,早于此日期的格子将被禁用。
maxDate—允许选择的最晚截止日期,晚于此日期的格子将被禁用。
disabled(date: Date) => boolean—自定义日期禁用判断函数,返回 true 的日期将不可交互并呈现禁用置灰样式。
showOutsideDaysbooleantrue是否在网格中显示属于上个月末尾和下个月开头的相邻月份补齐天数。
localestring"zh-CN"月份标题与星期表头的国际化语言标识符(如 'zh-CN'、'en-US')。
enableViewSwitchbooleantrue是否允许点击标题在日 / 月 / 年视图间切换,用于快速跨月、跨年定位。
classNamestring—应用于日历根容器的额外 CSS 类名。

CalendarPanel

<CalendarPanel> 用于日程安排、排期看板与全尺寸月视图规划:

属性类型默认值说明
eventsCalendarEvent[][]渲染在对应日期格子内的日程事件列表。
maxVisibleEventsnumber3每个日期格子内最多同时直接展示的事件数量,超出部分自动折叠为“还有 X 项”。
onEventClick(event: CalendarEvent) => void—用户点击特定日程事件徽章标签时触发的回调。
valueDate—受控选中的激活高亮日期。
onValueChange(date: Date) => void—选中格子变化时的回调。

CalendarEvent 数据结构

export type CalendarEventTone = "primary" | "info" | "success" | "warning" | "neutral"

export interface CalendarEvent {
  /** 日程唯一标识 */
  id: string
  /** 日程发生的具体日期 */
  date: Date
  /** 日程标题文本 */
  title: string
  /** 补充时间或元信息(例如 '10:00'、'全天') */
  meta?: string
  /** 语义化颜色主题 */
  tone?: CalendarEventTone
}

事件 Events

属性类型默认值说明
onValueChange(date: Date) => void—用户选择日期时触发,参数为最新选中的 Date 对象。
onMonthChange(month: Date) => void—月份视图翻页时触发,适合在此处发起异步日程数据请求。
onEventClick(event: CalendarEvent) => void—在 CalendarPanel 中点击具体日程条目时触发。

使用场景与设计规范

Calendar 与 CalendarPanel 适用于需要直观展示整月时间维度上下文的业务界面。

  • Calendar vs DatePicker vs CalendarPanel:
    • Calendar(日历):常驻内嵌于页面,适合表单旁直接操作选日、工作日排期、预约看板。
    • DatePicker(日期选择器):由 Input 触发并以 Popover 浮层弹出 Calendar,适合空间紧凑的标准表单。
    • CalendarPanel(大型月度日程面板):全宽大格视图,承载丰富的团队日程、待办清单与状态色块。
  • 平滑物理弹性动效:当鼠标掠过网格时,悬停胶囊会以弹簧动力学(Spring Physics)平滑跟随;当用户选定日期或切换“今天”时,指示圆点会动态伸缩过渡为全选底色。
  • 禁用规则与边界控制:利用 disabled 函数可轻松剔除周末、节假日或不可预约的历史时段。

场景示例

自定义禁用规则与工作日限制

通过 disabled={(date) => ...} 禁用过去的历史日期以及所有周末,仅允许选择未来的工作日:

Loading…

会议与专家咨询时段预约

将 Calendar 与右侧可选时段芯片(Time Slots)组合,构建完整的预约订购链路:

Loading…

大型月度日程与排期面板

在运营控制台或项目管理面板中,通过 CalendarPanel 展示团队周会、发版冻结等关键里程碑事件:

Loading…

无障碍与交互 Accessibility

  • ARIA 标准:日期网格为 role="grid",按周划分 role="row",日期单元格为 role="gridcell" 并在选中项上标记 aria-selected;日期按钮带完整日期(含星期)的 aria-label,当天标记 aria-current="date"。
  • 键盘导航矩阵:
    • ← / →:在前后一天之间移动焦点。
    • ↑ / ↓:在上下周之间移动焦点,到达月份边缘时自动翻月。
    • Home / End:移动到本周第一天 / 最后一天。
    • PageUp / PageDown:翻到上个月 / 下个月的同一天;配合 Shift 按年翻页。
    • Enter / Space:选定当前聚焦的日期。
    • 可见月份中始终有一个日期可通过 Tab 聚焦(选中日 → 今天 → 当月 1 日)。
  • 视图切换:点击标题从日视图进入月份视图,再次点击进入 12 年的年份视图;选择后逐级返回。
  • 动态无障碍播报:标题变化通过 aria-live="polite" 区域播报。
  • 动效:翻月按方向左右滑动,标题同步上下滚动;日 / 月 / 年视图之间以缩放淡入切换;选中背景在日期间滑动并在新月份中弹出;悬停背景跟随指针。全部遵循 prefers-reduced-motion。