组件
时间选择器 Time Picker
通过小时、分钟与 AM/PM 时制分栏,用于在表单中快速定位与设定特定时刻的紧凑选择控件。
基础用法
最简单的时间选择器。点击弹出小时与分钟双栏面板,点击“确定”确认时间:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/time-picker安装基础依赖与动效库
pnpm add radix-ui motion lucide-react class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/time-picker.tsx"use client"
import * as React from "react"
import { Clock3Icon } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { SlidingNumber } from "@/components/ui/sliding-number"
function parseTime(value?: string) {
const match = value?.match(/^(\d{1,2}):(\d{2})$/)
if (!match) return { hour: 9, minute: 0 }
return {
hour: Math.min(23, Number(match[1])),
minute: Math.min(59, Number(match[2])),
}
}
function formatTime(hour: number, minute: number) {
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`
}
const timePickerVariants = cva(
"bg-background shadow-xs hover:border-foreground/25 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-[placeholder=true]:text-muted-foreground flex min-w-40 items-center gap-2 rounded-md border text-left outline-none transition-[border-color,box-shadow] duration-200 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50",
{
variants: {
size: {
sm: "h-8 px-2.5 text-xs",
default: "h-10 px-3 text-sm",
lg: "h-12 px-4 text-base",
},
},
defaultVariants: { size: "default" },
}
)
const indicatorSpring = {
type: "spring",
stiffness: 520,
damping: 38,
mass: 0.7,
} as const
export interface TimePickerProps extends Omit<
React.ComponentProps<"button">,
"value" | "defaultValue" | "onChange"
> {
/** 受控模式下的 24 小时制时间,格式为 HH:mm。 */
value?: string
/** 非受控模式下的初始时间。 */
defaultValue?: string
/** 点击“确定”后触发,返回 24 小时制 HH:mm 字符串。 */
onValueChange?: (value: string) => void
/** 展示 12 小时制或 24 小时制。@default 24 */
hourCycle?: 12 | 24
/** 分钟列的步进间隔。@default 5 */
minuteStep?: 1 | 5 | 10 | 15 | 30
/** 未选择时间时显示的占位文本。@default "选择时间" */
placeholder?: string
/** 尺寸密度。@default "default" */
size?: "sm" | "default" | "lg"
/** 是否在面板底部显示“此刻”快捷按钮。@default true */
showNow?: boolean
}
/** 由时、分(及上下午)滚动列组成的紧凑时间选择器。 */
function TimePicker({
className,
value,
defaultValue,
onValueChange,
hourCycle = 24,
minuteStep = 5,
placeholder = "选择时间",
size = "default",
showNow = true,
disabled,
...props
}: TimePickerProps) {
const [open, setOpen] = React.useState(false)
const [internalValue, setInternalValue] = React.useState(defaultValue)
const current = value ?? internalValue
const parsed = parseTime(current)
const [draft, setDraft] = React.useState(parsed)
const period = draft.hour >= 12 ? "PM" : "AM"
const shownHour = hourCycle === 12 ? draft.hour % 12 || 12 : draft.hour
const hours =
hourCycle === 12
? Array.from({ length: 12 }, (_, i) => i + 1)
: Array.from({ length: 24 }, (_, i) => i)
const minutes = React.useMemo(() => {
const steps = Array.from(
{ length: Math.ceil(60 / minuteStep) },
(_, i) => i * minuteStep
)
// 保留不在步进上的已有分钟值,避免已选时间在面板中“消失”。
return steps.includes(draft.minute)
? steps
: [...steps, draft.minute].sort((a, b) => a - b)
}, [draft.minute, minuteStep])
function changeOpen(nextOpen: boolean) {
if (nextOpen) setDraft(parseTime(current))
setOpen(nextOpen)
}
function commit(next = draft) {
const formatted = formatTime(next.hour, next.minute)
if (value === undefined) setInternalValue(formatted)
onValueChange?.(formatted)
setOpen(false)
}
function pickNow() {
const now = new Date()
const minute = Math.floor(now.getMinutes() / minuteStep) * minuteStep
commit({ hour: now.getHours(), minute })
}
function chooseHour(next: number) {
if (hourCycle === 24) setDraft((state) => ({ ...state, hour: next }))
else
setDraft((state) => ({
...state,
hour: (next % 12) + (state.hour >= 12 ? 12 : 0),
}))
}
const display = current
? new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: hourCycle === 12,
}).format(new Date(2000, 0, 1, parsed.hour, parsed.minute))
: placeholder
return (
<PopoverPrimitive.Root open={open} onOpenChange={changeOpen}>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
data-slot="time-picker"
data-size={size}
data-placeholder={!current || undefined}
className={cn(timePickerVariants({ size }), className)}
disabled={disabled}
{...props}
>
<Clock3Icon className="text-muted-foreground size-4 shrink-0" />
<span className="flex-1 truncate tabular-nums">{display}</span>
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="time-picker-content"
sideOffset={6}
align="start"
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 w-64 origin-(--radix-popover-content-transform-origin) rounded-lg border p-2 shadow-md outline-none motion-reduce:animate-none"
>
<div className="mb-2 flex items-center justify-between px-1.5 py-1">
<span className="text-muted-foreground text-xs font-medium">
设定时间
</span>
<span
aria-live="polite"
className="flex items-baseline gap-1 text-lg font-semibold tracking-tight"
>
<span className="inline-flex items-baseline tabular-nums">
<SlidingNumber value={shownHour} padStart />
<span className="text-muted-foreground mx-px">:</span>
<SlidingNumber value={draft.minute} padStart />
</span>
{hourCycle === 12 ? (
<span className="text-muted-foreground text-xs font-medium">
{period}
</span>
) : null}
</span>
</div>
<div
className={cn(
"grid gap-1 border-y py-2",
hourCycle === 12 ? "grid-cols-3" : "grid-cols-2"
)}
>
<TimeColumn
label="时"
values={hours}
selected={shownHour}
onSelect={chooseHour}
/>
<TimeColumn
label="分"
values={minutes}
selected={draft.minute}
onSelect={(minute) => setDraft((state) => ({ ...state, minute }))}
/>
{hourCycle === 12 ? (
<TimeColumn
label="上/下午"
values={["AM", "PM"]}
selected={period}
onSelect={(next) =>
setDraft((state) => ({
...state,
hour:
next === "PM" ? (state.hour % 12) + 12 : state.hour % 12,
}))
}
/>
) : null}
</div>
<div className="mt-2 flex items-center justify-between gap-2">
{showNow ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground px-2"
onClick={pickNow}
>
此刻
</Button>
) : (
<span />
)}
<Button
type="button"
size="sm"
className="min-w-16"
onClick={() => commit()}
>
确定
</Button>
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
)
}
function TimeColumn<T extends string | number>({
label,
values,
selected,
onSelect,
}: {
label: string
values: T[]
selected: T
onSelect: (value: T) => void
}) {
const reduceMotion = useReducedMotion()
const selectionId = React.useId()
const listRef = React.useRef<HTMLDivElement>(null)
const mounted = React.useRef(false)
// 打开时把选中项瞬间定位到列中部,之后的切换平滑滚动。
React.useEffect(() => {
const list = listRef.current
const target = list?.querySelector<HTMLElement>('[aria-selected="true"]')
if (!list || !target) return
const top = target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2
list.scrollTo({
top,
behavior: mounted.current && !reduceMotion ? "smooth" : "auto",
})
mounted.current = true
}, [reduceMotion, selected])
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {
const nextIndex =
event.key === "ArrowDown"
? Math.min(index + 1, values.length - 1)
: event.key === "ArrowUp"
? Math.max(index - 1, 0)
: event.key === "Home"
? 0
: event.key === "End"
? values.length - 1
: -1
if (nextIndex < 0) return
event.preventDefault()
onSelect(values[nextIndex])
requestAnimationFrame(() =>
listRef.current
?.querySelector<HTMLElement>('[aria-selected="true"]')
?.focus()
)
}
return (
<div>
<div className="text-muted-foreground pb-1 text-center text-[10px] font-medium tracking-wider">
{label}
</div>
<motion.div
ref={listRef}
role="listbox"
aria-label={label}
layoutScroll
className="relative h-36 overflow-y-auto overscroll-contain px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{values.map((item, index) => {
const active = item === selected
return (
<button
key={item}
type="button"
role="option"
aria-selected={active}
tabIndex={active ? 0 : -1}
className={cn(
"hover:bg-accent focus-visible:ring-ring/30 relative isolate mb-0.5 flex h-8 w-full items-center justify-center rounded-md text-sm tabular-nums outline-none transition-colors duration-150 focus-visible:ring-2",
active && "text-primary-foreground hover:bg-transparent font-medium"
)}
onClick={() => onSelect(item)}
onKeyDown={(event) => handleKeyDown(event, index)}
>
{active ? (
<motion.span
aria-hidden
layoutId={`${selectionId}-selection`}
className="bg-primary absolute inset-0 z-[-1] rounded-md"
transition={reduceMotion ? { duration: 0 } : indicatorSpring}
/>
) : null}
{typeof item === "number" ? String(item).padStart(2, "0") : item}
</button>
)
})}
</motion.div>
</div>
)
}
export { TimePicker, formatTime, parseTime, timePickerVariants }
属性 Props
TimePicker 支持以下配置属性,并继承原生 <button> 的 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 受控模式下的 24 小时制时间字符串,格式为 `HH:mm`(例如 `'09:30'`、`'18:45'`)。 |
| defaultValue | string | — | 非受控模式下的初始时间字符串(格式同 `HH:mm`)。 |
| onValueChange | (value: string) => void | — | 用户在面板中确认选择时间后触发的回调函数,返回标准的 24 小时制 `HH:mm` 字符串。 |
| hourCycle | 12 | 24 | 24 | 时间显示进制。设为 12 时展示 1~12 小时并增加 AM/PM 上下午时制分栏。 |
| minuteStep | 1 | 5 | 10 | 15 | 30 | 5 | 分钟列的步进间隔。可设为 1(全精度)、5、10、15 或 30 分钟。 |
| placeholder | string | "选择时间" | 未选定任何时间时输入框内展示的占位提示文本。 |
| size | "sm" | "default" | "lg" | "default" | 触发器尺寸密度,高度分别为 32 / 40 / 48px,与 Select、DatePicker 对齐。 |
| showNow | boolean | true | 是否在面板底部显示“此刻”按钮,点击后按分钟步进取整并立即确认。 |
| disabled | boolean | false | 是否禁用时间选择器的交互。 |
| className | string | — | 应用于触发器按钮的额外 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onValueChange | (value: string) => void | — | 当用户在浮层中选中时间并点击底部的“确定”按钮后触发。 |
| onFocus | (event: React.FocusEvent<HTMLButtonElement>) => void | — | 触发按钮获得焦点时触发。 |
| onBlur | (event: React.FocusEvent<HTMLButtonElement>) => void | — | 触发按钮失去焦点时触发。 |
使用场景与设计规范
TimePicker 适用于精确时刻输入(如会议开始时间、课程时段、每日定时提醒、营业时间段)。
- TimePicker vs Select vs DatePicker:
- TimePicker(时间选择):专为时分秒刻度设计,分栏滚动选择并可自由控制分钟精度(1/5/15/30 分钟)。
- Select(下拉选择):适合固定稀疏的几个粗粒度预设项(如“上午 9:00”、“下午 2:00”)。
- DatePicker(日期选择):面向年月日维度的日历日期选择。
- 数据格式统一规范:无论前台
hourCycle设置为 12 还是 24 小时制,onValueChange回调输出的数值均始终维持标准的 24 小时制HH:mm格式,避免后端数据存储和业务逻辑处理产生歧义。 - 草稿确认机制:用户在浮层列中切换时仅暂存为内部 Draft 状态,只有点击“确定”按钮才会正式提交生效;若用户点击外部区域关闭,已选时间将自动恢复,防止误触。
场景示例
12 小时制 (AM/PM)
开启 hourCycle={12} 时,面板会自动呈现 AM / PM 上下午切换列,适合跨国业务或特定海外习惯:
Loading…
会议室起止时间区间校验
组合两个 TimePicker 构建预约时间范围,并实时联动校验“结束时间晚于开始时间”:
Loading…
分钟步进粒度控制
通过 minuteStep 在 1 分钟高精度打点与 30 分钟粗粒度日程预约之间灵活配置:
Loading…
无障碍与交互 Accessibility
- ARIA 规范:触发按钮由 Popover 管理
aria-haspopup="dialog"与aria-expanded;每一列为带标签的role="listbox",选项为role="option"并同步aria-selected。 - 键盘支持:
- Tab:在时、分、上/下午三列与底部按钮之间移动(每列只有选中项可聚焦)。
- ↑ / ↓、Home / End:在当前列内切换数值。
- Esc:放弃修改并关闭弹层。
- 动效:打开时选中项自动定位到列中部,切换时平滑滚动;选中背景以弹簧动画在单元格间滑动;标题中的时间数字逐位滚动。开启
prefers-reduced-motion时全部瞬间切换。 - 数据兜底:当已有值不在分钟步进上(如步进 5 分钟而值为
08:03)时,该分钟会被补入列表,保证已选时间始终可见。