radix-vue Time Field 组件完全指南:基于 reka-ui 构建可本地化的分段式时间输入
【免费下载链接】radix-vueAn open-source UI component library for building high-quality, accessible design systems and web apps for Vue. Previously Radix Vue项目地址: https://gitcode.com/GitHub_Trending/ra/radix-vue
本文以 reka-ui(原 Radix Vue)官方文档 Time Field 为骨架,结合仓库内 TimeFieldRoot.vue、TimeFieldInput.vue 源码及 TimeField.test.ts 测试用例,全面讲解如何在 Vue 3 项目中实现支持完整键盘导航、可受控/非受控、默认无障碍的分段式(segment-based)时间输入组件。
Time Field(<Badge>Alpha</Badge>状态)是一个专门用于输入具体时间的字段组件,它把时间拆解为时、分、秒、上下午(day period)、时区等多个可独立聚焦、编辑的"段(segment)",替代传统文本框,让用户以最自然的方式逐段录入时间。本文将带你从安装、组件拼装到 API 全量解析,再深入到源码级实现原理与测试验证,让你能直接在生产项目中正确使用并二次定制它。
核心特性
官方文档明确了该组件的六大设计目标:
- Full keyboard navigation:完整键盘导航,支持方向键逐段移动、数字键入、退格删除等;
- Can be controlled or uncontrolled:受控与非受控均可,通过
v-model绑定值; - Focus is fully managed:焦点完全托管,单击任意段即可聚焦,标签点击聚焦首段;
- Localization support:完整的本地化支持,基于
Intl格式化,随locale切换段顺序与显示; - Highly composable:高度可组合,Root 与 Input 拆分为独立部分,可自由定制 DOM 结构;
- Accessible by default:默认无障碍,通过
role="group"、隐藏原生 input、aria-*属性与视觉隐藏元素实现。
前置依赖:@internationalized/date
官方文档明确指出,Time Field 组件依赖@internationalized/date包(Adobe React Spectrum 生态的国际化日期工具库),它解决了 JavaScript 中处理日期时间的大量痛点:不可变时间对象、时区运算、日历换算、格式化与解析等。reka-ui 中所有 date 相关组件(DateField、DatePicker、Calendar、RangeCalendar 等)均建立在该包之上。
从源码看,TimeFieldRoot.vue 直接导入了getLocalTimeZone、isEqualDay、Time、toCalendarDateTime、today等工具,测试文件 TimeField.test.ts 也使用了CalendarDateTime、now、parseAbsoluteToLocal、Time、toZoned五种时间值类型,因此强烈建议先通读该包文档,理解Time、CalendarDateTime、ZonedDateTime的区别后再上手组件。
安装
首先安装日期基础包:
npm install @internationalized/date然后从命令行安装组件(reka-ui 为当前项目发布名,即原 Radix Vue 的现名):
npm install reka-ui组件解剖(Anatomy)
Time Field 由两个部分构成:
TimeFieldRoot:容器,持有全部状态与逻辑;TimeFieldInput:渲染时间字段的某一个"段"(segment),需传入part指定渲染哪一部分。
最简拼装如下:
<script setup> import { TimeFieldInput, TimeFieldRoot, } from 'reka-ui' </script> <template> <TimeFieldRoot> <TimeFieldInput /> </TimeFieldRoot> </template>注意:上面是最小骨架,实际使用时需要为TimeFieldInput指定part,或通过 Root 的segments插槽自动遍历渲染(见下文实战示例)。
实战示例:带标签的完整时间字段
仓库演示代码 docs/components/demo/TimeField/css/index.vue 给出了贴近生产的完整写法:使用 Root 的默认插槽接收segments数组,v-for遍历每个段,区分literal(冒号等字面分隔符)与可编辑段分别套用样式,同时通过granularity="second"把粒度设为秒:
<script setup lang="ts"> import { Label, TimeFieldInput, TimeFieldRoot } from 'reka-ui' import './styles.css' </script> <template> <div class="TimeFieldWrapper"> <Label class="TimeFieldLabel" for="time-field">Appointment</Label> <TimeFieldRoot id="time-field" v-slot="{ segments }" granularity="second" class="TimeField" > <template v-for="item in segments" :key="item.part"> <TimeFieldInput v-if="item.part === 'literal'" :part="item.part" class="TimeFieldLiteral" > {{ item.value }} </TimeFieldInput> <TimeFieldInput v-else :part="item.part" class="TimeFieldSegment" > {{ item.value }} </TimeFieldInput> </template> </TimeFieldRoot> </div> </template>要点:
- Root 的
v-slot="{ segments }"提供段内容数组,每项为{ part, value }(见 TimeFieldRoot.vue 的segmentContents计算属性); part === 'literal'的段(如:分隔符)只读展示,不参与编辑;Label的for指向 Root 的id,点击标签会将焦点移到首个段(测试focuses first segment on label click验证了该行为)。
API Reference:Root
Root 是包含整个时间字段全部部分的容器,渲染为role="group"的 div(默认标签),提供状态注入、键盘导航与无障碍属性。
Root Props
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
as | 组件应渲染成的元素或组件,可被asChild覆盖 | AsTag \| Component | No | "div" |
asChild | 将默认渲染元素改为传入的子元素,合并其 props 与行为 | boolean | No | - |
defaultPlaceholder | 默认占位时间 | TimeValue | No | - |
defaultValue | 默认值(非受控模式) | TimeValue | No | - |
dir | 阅读方向,省略时继承全局ConfigProvider,否则假定 LTR | "ltr" \| "rtl" | No | - |
disabled | 是否禁用整个时间字段 | boolean | No | false |
granularity | 格式化时间的粒度,字段将渲染到该粒度为止的所有段 | "hour" \| "minute" \| "second" | No | - |
hideTimeZone | 是否隐藏时区段 | boolean | No | - |
hourCycle | 格式化时间使用的小时制,默认跟随本地偏好 | 12 \| 24 | No | - |
id | 元素 id | string | No | - |
locale | 格式化日期使用的区域 | string | No | - |
maxValue | 可选择的最大时间 | TimeValue | No | - |
minValue | 可选择的最小时间 | TimeValue | No | - |
modelValue | 受控状态值,可绑定v-model | TimeValue \| null | No | - |
name | 字段名,随所属表单以 name/value 对提交 | string | No | - |
placeholder | 占位时间,用于确定未选中时间时的显示,随用户导航实时更新 | TimeValue | No | - |
readonly | 是否只读 | boolean | No | false |
required | 为 true 时表示用户必须在表单提交前设置值 | boolean | No | - |
step | 步进间隔,默认1 | DateStep | No | - |
stepSnapping | 是否在输入后将值吸附到最近的步进增量,默认false | boolean | No | false |
Root Events
| Name | Description | Type |
|---|---|---|
update:modelValue | 模型值变化时触发 | [date: TimeValue] |
update:placeholder | 占位值变化时触发 | [date: TimeValue] |
Root Slots
| Name | Description | Type |
|---|---|---|
modelValue | 字段当前时间 | TimeValue \| undefined |
segments | 时间字段的段内容 | { part: SegmentPart; value: string; }[] |
isInvalid | 输入是否无效 | boolean |
Root Methods
| Name | Description | Type |
|---|---|---|
setFocusedElement | 设置 DateField 内部聚焦元素的辅助方法 | (el: HTMLElement) => void |
关键 Props 的源码级解读
granularity(粒度):决定渲染哪些段。从 TimeFieldRoot.vue 可见,未显式传入时默认推断为minute;底层 shared/date/utils.ts 的getOptsByGranularity会按粒度裁剪Intl.DateTimeFormatOptions(如granularity: 'hour'时删除minute/second,'minute'时删除second)。测试overrides the default displayed segments with the granularity prop系列验证了hour/minute粒度下的段显隐。
hourCycle(小时制):接受12 | 24。源码通过normalizeHourCycle映射为h23/h11传给Intl格式化器(shared/date/utils.ts)。12 小时制下,内部 24 小时值会被转换为展示值:hour === 0 → 12,hour > 12 → hour - 12(见 TimeFieldRoot.vue 的segmentContents转换逻辑)。测试覆盖了午夜显示12 AM、正午显示12 PM、下午 13-23 点转换,以及 11 PM 上箭头循环到 12 AM 等边界情况。
step与stepSnapping:step的类型DateStep定义于 shared/date/types.ts,允许按hour/minute/second/millisecond分别设置步长;未传时normalizeDateStep用defu合并出全为 1 的默认值(shared/date/utils.ts)。stepSnapping开启后,键入的值会吸附到最近步进增量:测试中step: { minute: 15 }时键入23吸附为30、键入17吸附为15、键入58因越界吸附为45;而方向键调整始终按step走,与stepSnapping无关。
minValue/maxValue:用于合法性校验。Root 的isInvalid计算属性会比对当前值与上下界(TimeFieldRoot.vue),越界时组件挂上data-invalid与aria-invalid。
modelValue的类型兼容:组件接受TimeValue,即Time、CalendarDateTime、ZonedDateTime三者之一。convertValue会把纯时间值通过toCalendarDateTime与"今天"合并(TimeFieldRoot.vue);测试分别验证了三种类型都能正确回填段内容,且ZonedDateTime会额外渲染dayPeriod与timeZoneName段(如PM与EST)。
API Reference:Input
TimeFieldInput渲染时间字段的某个段,是实际可聚焦、可编辑的最小单元。
Input Props
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
as | 组件应渲染成的元素或组件,可被asChild覆盖 | AsTag \| Component | No | "div" |
asChild | 将默认渲染元素改为传入的子元素,合并其 props 与行为 | boolean | No | - |
part | 要渲染的时间段部分 | "day" \| "month" \| "year" \| "hour" \| "minute" \| "second" \| "dayPeriod" \| "literal" \| "timeZoneName" | Yes | - |
从实现看,TimeFieldInput.vue 会为该段设置data-reka-time-field-segment="part"、contenteditable(禁用/只读或literal时为false),并把disabled/readonly/invalid同步为aria-*与data-*属性;所有按键、输入、合成事件处理由useDateField组合式函数统一提供,Root 上下文通过injectTimeFieldRootContext注入。
数据属性(Data Attributes)
Root
| Attribute | Values |
|---|---|
[data-readonly] | Present when readonly |
[data-disabled] | Present when disabled |
[data-invalid] | Present when invalid |
Input
| Attribute | Values |
|---|---|
[data-disabled] | Present when disabled |
[data-invalid] | Present when invalid |
[data-placeholder] | Present when no value is set |
此外每个 Input 段还带data-reka-time-field-segment,其值即part(见 TimeFieldInput.vue),Root 正是靠它来识别、排序并定位各段(TimeFieldRoot.vue)。
无障碍与键盘交互
无障碍设计
- Root 渲染
role="group",禁用时附加aria-disabled; - Root 内部放置一个
VisuallyHidden的隐藏原生input(带name/required/disabled/value),用于随表单提交与屏幕阅读器交互(TimeFieldRoot.vue),其聚焦时自动把焦点转交给首段; - 每个段带
aria-disabled、aria-readonly、aria-invalid,无值时呈现占位内容; - 测试套件通过
vitest-axe的axe(container)断言无无障碍违规(TimeField.test.ts)。
键盘交互
| Keys | Description |
|---|---|
Tab | 焦点移入时间字段时,聚焦第一个段 |
ArrowLeft/ArrowRight | 在时间字段的各段之间导航 |
ArrowUp/ArrowDown | 递增/改变当前段的值(按step步进,12 小时制下小时段会联动上下行切换) |
0-9 | 焦点在数值型TimeFieldInput上时输入数字,若下一个输入将产生无效值则自动跳到下一段 |
Backspace | 从聚焦的数值段删除一位数字 |
A/P | 焦点在 day period(上下午)段时,分别设为 AM 或 PM |
源码层面的键盘实现要点:
- 段间导航:Root 监听
keydown.left.right(TimeFieldRoot.vue),通过data-reka-time-field-segment计算当前段索引并移动焦点;方向键导航感知书写方向(dir === 'rtl'时左右键语义互换),而数字键入后的自动前进始终按 DOM 顺序(即 locale 的格式顺序)进行——测试advances focus through segments in DOM order when typing in RTL专门验证了 RTL 下键入数字时焦点仍按段顺序推进; - 键入行为:数字段支持连击,
1连打会一路走完 hour → minute → second → dayPeriod(测试takes you all the way through the segment),0的焦点推进规则(段值为 0 时不过早切换焦点)也有专门测试; - 合成输入保护:
e.isComposing时跳过导航处理,避免与 IME 候选词导航冲突; - 12 小时制联动:在
dayPeriod段按A/P或方向键会同步换算modelValue的小时(如 10 PM 内部存为 22 时、12 AM 内部存为 0 时),对应测试updates the hour on the modelValue if the dayPeriod is updated及 12-hour input handling 全套用例。
本地化行为
- 未传
locale时继承ConfigProvider或回退到浏览器环境; - 切换 locale 会重建段顺序与显示:Root 的
watch(locale)会重新抓取段元素(因为不同 locale 下格式顺序可能不同),并同步更新 formatter(TimeFieldRoot.vue); - 测试验证了
en-UK等不使用上下午制的 locale 不渲染dayPeriod段,而默认 locale 下会渲染;zh-TW下开启hideTimeZone时不再显示时区括号; - 12 小时制显示与 locale 结合:
hourCycle: 12时即使传入 24 小时制内部值也会正确展示为 12 小时格式并联动 AM/PM。
禁用、只读与表单集成
disabled:整场禁用。段不可聚焦、不响应输入,Root 挂data-disabled,隐藏 input 同步disabled;测试prevents interaction when disabled验证所有段点击无焦点、无tabindex;readonly:可聚焦但不可修改。contenteditable置false,方向键等不会改变值;测试prevents modification when readonly验证了这一点;- 表单提交:通过 Root 的
name+ 隐藏 input 完成原生表单集成,required标记必填约束。
源码结构速览
| 文件 | 作用 |
|---|---|
| TimeFieldRoot.vue | Root 组件:状态管理、段注册、键盘导航、locale/hourCycle 格式化、无障碍属性、上下文注入 |
| TimeFieldInput.vue | Input 段组件:按part渲染单个可编辑段,复用useDateField处理输入 |
| shared/date/types.ts | TimeValue、DateStep、SegmentPart、HourCycle、DayPeriod等核心类型 |
| shared/date/utils.ts | 粒度选项构建、步长归一化、小时制映射等工具函数 |
| TimeField.test.ts | 覆盖无障碍、RTL、三种时间值类型、粒度、小时制、步进吸附、禁用只读等 30+ 场景的测试套件 |
组件通过createContext/provideTimeFieldRootContext建立 Root ↔ Input 的上下文通信(TimeFieldRoot.vue),并在 index.ts 统一导出TimeFieldRoot与TimeFieldInput及其类型。
使用建议
- 若只需时分输入,保持默认
granularity: 'minute';需要秒级精度时显式设为'second'; - 跨时区场景(如预约系统)优先使用
ZonedDateTime作为modelValue,并配合hideTimeZone控制时区段显隐; - 需要约束可选时间范围时同时设置
minValue/maxValue,组件会自动计算isInvalid并反映在data-invalid与插槽参数上; - 步进场景(如每 5 分钟、每 15 分钟选择)设置
step: { minute: 5 },需要强制吸附键入值时再开启stepSnapping; - 需要上下午制展示时设置
hourCycle: 12,组件会自动处理午夜/正午与 AM/PM 的往返换算。
该组件当前处于 Alpha 阶段,API 可能随版本演进微调,建议以仓库 docs/content/meta/TimeFieldRoot.md 与 docs/content/meta/TimeFieldInput.md 的自动生成元数据为准核对当前版本参数。
【免费下载链接】radix-vueAn open-source UI component library for building high-quality, accessible design systems and web apps for Vue. Previously Radix Vue项目地址: https://gitcode.com/GitHub_Trending/ra/radix-vue
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考