组件
日期选择器 DatePicker
基于日历弹层的现代化单日期选择控件,支持多语言本地化、日期范围约束、自定义禁用规则与快捷清空。
基础用法
最简单的日期选择器用法。点击触发按钮唤起日历浮层,点击目标日期即可完成选定:
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"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 支持以下核心配置属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | Date | string | — | 受控模式下的选中日期,支持 Date 对象或 `YYYY-MM-DD` 字符串。 |
| defaultValue | Date | string | — | 非受控模式下的初始选中日期,支持 Date 对象或 `YYYY-MM-DD` 字符串。 |
| onValueChange | (date: Date | undefined) => void | — | 选中日期或点击清空按钮时触发的回调函数,清空时返回 undefined。 |
| placeholder | string | "选择日期" | 未选中日期时在触发器按钮中显示的提示文案。 |
| locale | string | "zh-CN" | 用于本地化日期格式化与星期/月份显示的 BCP 47 语言环境代码。 |
| formatOptions | Intl.DateTimeFormatOptions | — | 自定义 Intl.DateTimeFormat 日期格式化配置选项对象。 |
| clearable | boolean | true | 在已有选中日期时是否允许在触发器中一键清除。 |
| min | Date | string | — | 允许选择的最早起始日期,早于该日期的单元格自动禁用。 |
| max | Date | string | — | 允许选择的最晚截止日期,晚于该日期的单元格自动禁用。 |
| disabledDate | (date: Date) => boolean | — | 自定义特定日期的禁用判定函数(例如禁用所有周末或法定节假日)。 |
| presets | { label: string; value: Date }[] | — | 显示在日历左侧的快捷日期列表,点击后直接选中并收起弹层。 |
| size | "sm" | "default" | "lg" | "default" | 触发器的物理高度与内边距尺寸密度。 |
| disabled | boolean | false | 是否禁用日期选择器交互与弹层展开。 |
| className | string | — | 应用于触发器外层按钮的额外 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。