wui
组件

日期选择器 DatePicker

基于日历弹层的现代化单日期选择控件,支持多语言本地化、日期范围约束、自定义禁用规则与快捷清空。

第三方依赖 · radix-ui第三方依赖 · lucide-react第三方依赖 · motion第三方依赖 · class-variance-authority

基础用法

最简单的日期选择器用法。点击触发按钮唤起日历浮层,点击目标日期即可完成选定:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/date-picker
安装依赖组件与图标库
pnpm add radix-ui motion lucide-react class-variance-authority clsx tailwind-merge
确保项目中包含 Calendar 组件,并将源码复制到 components/ui/date-picker.tsx
components/ui/date-picker.tsx
"use client"

import * as React from "react"
import { CalendarIcon, XIcon } from "lucide-react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"

import { Calendar } from "@/components/ui/calendar"
import { cn } from "@/lib/utils"

const datePickerVariants = cva(
  "bg-background shadow-xs hover:border-foreground/25 focus-within:border-ring focus-within:ring-ring/30 data-[placeholder=true]:text-muted-foreground group flex w-full items-center gap-2 rounded-md border text-left outline-none transition-[border-color,box-shadow] focus-within:ring-[3px] has-[button[aria-invalid=true]]:border-destructive has-[button[aria-invalid=true]]:ring-[3px] has-[button[aria-invalid=true]]:ring-destructive/20 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
  {
    variants: {
      size: {
        sm: "h-8 min-w-44 px-2.5 text-xs",
        default: "h-10 min-w-56 px-3 text-sm",
        lg: "h-12 min-w-64 px-4 text-base",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

/** 安全解析 Date 对象或 YYYY-MM-DD 格式日期字符串 */
function parseDate(value: unknown): Date | undefined {
  if (!value) return undefined
  if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value
  if (typeof value !== "string") return undefined
  const trimmed = value.trim()
  if (!trimmed) return undefined
  if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
    const [year, month, day] = trimmed.split("-").map(Number)
    return new Date(year, month - 1, day)
  }
  const parsed = new Date(trimmed)
  return Number.isNaN(parsed.getTime()) ? undefined : parsed
}

/** 将 Date 对象格式化为标准 YYYY-MM-DD 字符串 */
function formatDate(date: Date | undefined): string {
  if (!date || Number.isNaN(date.getTime())) return ""
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, "0")
  const day = String(date.getDate()).padStart(2, "0")
  return `${year}-${month}-${day}`
}

export interface DatePickerProps
  extends Omit<
    React.ComponentProps<"button">,
    "value" | "defaultValue" | "onChange"
  > {
  /** 选中的日期对象或 YYYY-MM-DD 字符串;传入后进入受控模式。 */
  value?: Date | string
  /** 非受控模式下的初始选中日期或字符串。 */
  defaultValue?: Date | string
  /** 选中日期或清空后触发的回调函数。 */
  onValueChange?: (date: Date | undefined) => void
  /** 未选择日期时的提示文案。@default "选择日期" */
  placeholder?: string
  /** 格式化日期语言环境代码。@default "zh-CN" */
  locale?: string
  /** Intl.DateTimeFormat 本地化格式化配置项。 */
  formatOptions?: Intl.DateTimeFormatOptions
  /** 是否允许一键清空选中日期。@default true */
  clearable?: boolean
  /** 允许选择的最早起始日期。 */
  min?: Date | string
  /** 允许选择的最晚截止日期。 */
  max?: Date | string
  /** 自定义特定日期的禁用判定函数。 */
  disabledDate?: (date: Date) => boolean
  /** 尺寸密度。@default "default" */
  size?: "sm" | "default" | "lg"
  /** 显示在日历左侧的快捷日期,例如“今天”“下周一”。 */
  presets?: DatePickerPreset[]
}

export interface DatePickerPreset {
  /** 快捷项文本。 */
  label: string
  /** 点击后选中的日期。 */
  value: Date
}

/** 基于日历浮层的现代化单日期选择控件。 */
function DatePicker({
  className,
  value,
  defaultValue,
  onValueChange,
  placeholder = "选择日期",
  locale = "zh-CN",
  formatOptions,
  clearable = true,
  min,
  max,
  disabledDate,
  size = "default",
  presets,
  disabled,
  onKeyDown,
  ...props
}: DatePickerProps) {
  const reduceMotion = useReducedMotion()
  const [open, setOpen] = React.useState(false)
  const [internalValue, setInternalValue] = React.useState<Date | undefined>(() =>
    parseDate(defaultValue)
  )
  const selected = parseDate(value) ?? internalValue

  function update(next: Date | undefined) {
    if (value === undefined) setInternalValue(next)
    onValueChange?.(next)
  }

  const label = selected
    ? new Intl.DateTimeFormat(
        locale,
        formatOptions ?? { year: "numeric", month: "long", day: "numeric" }
      ).format(selected)
    : placeholder

  const parsedMin = parseDate(min)
  const parsedMax = parseDate(max)

  return (
    <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
      <div
        data-slot="date-picker"
        data-placeholder={!selected || undefined}
        data-disabled={disabled || undefined}
        className={cn(datePickerVariants({ size }), className)}
      >
        <PopoverPrimitive.Trigger asChild>
          <button
            type="button"
            className="flex min-w-0 flex-1 items-center gap-2 self-stretch rounded-[inherit] text-left outline-none"
            disabled={disabled}
            onKeyDown={(event) => {
              onKeyDown?.(event)
              if (
                !event.defaultPrevented &&
                selected &&
                clearable &&
                (event.key === "Delete" || event.key === "Backspace")
              ) {
                event.preventDefault()
                update(undefined)
              }
            }}
            {...props}
          >
            <CalendarIcon className="text-muted-foreground size-4 shrink-0" />
            <span className="relative flex min-w-0 flex-1 overflow-hidden">
              <AnimatePresence initial={false} mode="popLayout">
                <motion.span
                  key={label}
                  className="min-w-0 flex-1 truncate"
                  initial={reduceMotion ? false : { opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduceMotion ? undefined : { opacity: 0, y: -6 }}
                  transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                >
                  {label}
                </motion.span>
              </AnimatePresence>
            </span>
          </button>
        </PopoverPrimitive.Trigger>
        {selected && clearable && !disabled ? (
          <button
            type="button"
            data-slot="date-picker-clear"
            aria-label="清除已选日期"
            disabled={disabled}
            className="text-muted-foreground hover:bg-accent hover:text-foreground -mr-1 flex size-6 shrink-0 scale-75 items-center justify-center rounded-sm opacity-0 transition-[opacity,scale,color,background-color] duration-150 focus:scale-100 focus:opacity-100 group-hover:scale-100 group-hover:opacity-100"
            onClick={(event) => {
              event.stopPropagation()
              update(undefined)
            }}
          >
            <XIcon className="size-3.5" />
          </button>
        ) : null}
      </div>
      <PopoverPrimitive.Portal>
        <PopoverPrimitive.Content
          sideOffset={6}
          align="start"
          data-slot="date-picker-content"
          className="bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 z-50 flex origin-(--radix-popover-content-transform-origin) rounded-lg border shadow-md outline-none motion-reduce:animate-none"
        >
          {presets?.length ? (
            <div
              data-slot="date-picker-presets"
              role="group"
              aria-label="快捷日期"
              className="flex w-28 shrink-0 flex-col gap-0.5 border-r p-2"
            >
              {presets.map((preset) => {
                const active =
                  selected && formatDate(selected) === formatDate(preset.value)
                return (
                  <button
                    key={preset.label}
                    type="button"
                    aria-pressed={Boolean(active)}
                    className={cn(
                      "hover:bg-accent focus-visible:ring-ring/30 flex h-8 items-center rounded-md px-2.5 text-left text-sm outline-none transition-colors focus-visible:ring-[3px]",
                      active && "bg-accent text-accent-foreground font-medium"
                    )}
                    onClick={() => {
                      update(preset.value)
                      setOpen(false)
                    }}
                  >
                    {preset.label}
                  </button>
                )
              })}
            </div>
          ) : null}
          <Calendar
            value={selected}
            defaultMonth={selected}
            min={parsedMin}
            max={parsedMax}
            disabled={disabledDate}
            locale={locale}
            onValueChange={(date) => {
              update(date)
              setOpen(false)
            }}
          />
        </PopoverPrimitive.Content>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  )
}

export { DatePicker, datePickerVariants, parseDate, formatDate }

属性 Props

DatePicker 支持以下核心配置属性:

属性类型默认值说明
valueDate | string—受控模式下的选中日期,支持 Date 对象或 `YYYY-MM-DD` 字符串。
defaultValueDate | string—非受控模式下的初始选中日期,支持 Date 对象或 `YYYY-MM-DD` 字符串。
onValueChange(date: Date | undefined) => void—选中日期或点击清空按钮时触发的回调函数,清空时返回 undefined。
placeholderstring"选择日期"未选中日期时在触发器按钮中显示的提示文案。
localestring"zh-CN"用于本地化日期格式化与星期/月份显示的 BCP 47 语言环境代码。
formatOptionsIntl.DateTimeFormatOptions—自定义 Intl.DateTimeFormat 日期格式化配置选项对象。
clearablebooleantrue在已有选中日期时是否允许在触发器中一键清除。
minDate | string—允许选择的最早起始日期,早于该日期的单元格自动禁用。
maxDate | string—允许选择的最晚截止日期,晚于该日期的单元格自动禁用。
disabledDate(date: Date) => boolean—自定义特定日期的禁用判定函数(例如禁用所有周末或法定节假日)。
presets{ label: string; value: Date }[]—显示在日历左侧的快捷日期列表,点击后直接选中并收起弹层。
size"sm" | "default" | "lg""default"触发器的物理高度与内边距尺寸密度。
disabledbooleanfalse是否禁用日期选择器交互与弹层展开。
classNamestring—应用于触发器外层按钮的额外 CSS 类名。

事件 Events

属性类型默认值说明
onValueChange(date: Date | undefined) => void—用户在日历中点击某一天、按回车选中或点击清除按钮时触发。
onFocus(event: React.FocusEvent<HTMLButtonElement>) => void—触发器按钮获得焦点时触发。
onBlur(event: React.FocusEvent<HTMLButtonElement>) => void—触发器按钮失去焦点时触发。
onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) => void—焦点位于触发器按钮时按下键盘按键触发(按 Backspace/Delete 键可快速清除已选日期)。

使用场景与设计规范

DatePicker 适用于表单中单一日期的录入、排期与预约:

  • DatePicker vs Calendar:当需要将日历长期平铺在页面或仪表盘中(如日程看板)时,使用 Calendar;当作为表单录入项、需要节省屏幕空间并在弹层中快速点选时,使用 DatePicker。
  • DatePicker vs TimePicker:当需要精确到小时、分、秒的时间录入时,应配合 TimePicker;如只需年月日粒度,保持使用 DatePicker 以降低用户认知负担。
  • 数据持久化格式:组件内部通过标准 JavaScript Date 交互,提交给后端接口时建议转换为标准 ISO 8601 字符串(如 2026-08-19T00:00:00.000Z)或 UTC 时间戳,避免受用户本地时区或本地化格式化字符串影响。
  • 明确的日期边界约束:对于生日选择、历史账单查询或预约等业务,务必配置 min、max 或 disabledDate,避免用户误选未来日期或非法工作日。

场景示例

受控模式与快捷预设

使用受控的 value 与 onValueChange,搭配“今天”、“明天”、“下周”等业务快捷按钮快速设定日期:

Loading…
const [date, setDate] = React.useState<Date | undefined>(new Date())

return (
  <DatePicker
    value={date}
    onValueChange={setDate}
    placeholder="选择会议排期…"
  />
)

快捷日期

通过 presets 在日历左侧放置常用日期,适合截止日期、提醒时间等高频场景:

Loading…

尺寸规格

组件提供 sm(32px)、default(40px)和 lg(48px)三种物理尺寸:

Loading…
  • sm:紧凑尺寸,适合表格行内筛选或紧凑过滤面板。
  • default:标准尺寸,通用业务表单。
  • lg:大尺寸,适用于触控平板或突出型预订页面。

禁用特定日期与日期范围

结合 min、max 限制可选范围,并使用 disabledDate 自定义禁用规则(例如禁用所有周末):

Loading…

国际化与自定义格式化

通过 locale 与 formatOptions 自定义触发器按钮中的日期展示文案格式:

Loading…

任务日程表单集成

在实际业务表单中,将两个 DatePicker 组合为起止日期选择,并自动计算周期天数:

Loading…

无障碍与交互 Accessibility

  • ARIA 规范:
    • 触发器按钮带有 data-slot="date-picker" 并由 Popover 管理 aria-expanded 与 aria-haspopup。
    • 日历面板基于表格语义构建,各日期单元格均带有标准的 role="gridcell" 与 aria-selected 属性。
  • 键盘导航:
    • Space / Enter:在聚焦触发器时打开日历弹层。
    • Backspace / Delete:在聚焦触发器且已有日期时,快速清除已选日期而无需展开弹层。
    • 在日历弹层内:
      • ← / →:移动到前一天 / 后一天。
      • ↑ / ↓:移动到上一周 / 下一周同一天。
      • PageUp / PageDown:快速翻到上个月 / 下个月。
      • Shift + PageUp / PageDown:快速翻到上一年 / 下一年。
      • Home / End:移动到本周第一天 / 最后一天。
      • 点击日历标题可切换到月份 / 年份视图,快速跨月跨年定位。
      • Enter:确认选中高亮日期并自动收起弹层。
      • Escape:关闭日历弹层并归还焦点至触发按钮。
  • 动效:弹层从触发器方向缩放展开;触发器中的日期文本在变更时上移淡入刷新;日历翻月按方向左右滑动。均遵循 prefers-reduced-motion。