radix-vue YearRangePickerCell 与 YearRangePickerCellTrigger 深度指南:Props、Slots 与交互实现全解
【免费下载链接】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
本文以 radix-vue 仓库(组件对外以
reka-ui包名分发)中YearRangePicker的单元格组件为研究对象,完整拆解YearRangePickerCell与YearRangePickerCellTrigger的属性定义、作用域插槽、渲染输出与底层交互逻辑,并结合源码给出可直接复用的实战示例。读完本文,你将掌握一年粒度区间选择器中"单元格"层的全部 API 与实现原理,能够熟练定制单元格样式、理解其选中/高亮/禁用状态机,并能为自己的日期区间组件复刻这套交互范式。
组件定位:一年粒度区间选择视图中的"单元格"
YearRangePickerCell与YearRangePickerCellTrigger是 YearRangePicker(一年粒度区间选择器,文档标注为 Alpha 阶段)的两层核心单元:YearRangePickerCell负责承载并标记"某一年的格子",YearRangePickerCellTrigger则是格子内真正可交互的触发元素——点击它即选中该年份,键盘聚焦时它承接完整的区间选择导航能力。
从组件树看,它们处于YearRangePickerRoot → YearRangePickerGrid → YearRangePickerGridBody → YearRangePickerGridRow → YearRangePickerCell → YearRangePickerCellTrigger链路的末端,是用户最终"看得见、点得着"的节点。整条链路的所有公共部件均从 packages/core/src/YearRangePicker/index.ts 统一导出。
在深入 Props 之前,先明确两个前提(源自 year-range-picker.md 的 Preface 与 Installation 章节):
- 该组件依赖
@internationalized/date包,DateValue、DateRange、CalendarDate等类型均来自该包,使用前需要先安装它; - 组件本身随
reka-ui包一起安装,YearRangePickerCell、YearRangePickerCellTrigger可直接从reka-ui具名导入。
组件结构:Cell 与 CellTrigger 的分工
两个组件职责清晰分离,对应两份元数据文档:
| 组件 | 元数据文档 | 默认渲染元素 | 核心职责 |
|---|---|---|---|
YearRangePickerCell | YearRangePickerCell.md | td(role="gridcell") | 容器:标注选中/禁用状态,承载 trigger |
YearRangePickerCellTrigger | YearRangePickerCellTrigger.md | div(role="button") | 交互:点击选中、键盘导航、暴露全部状态插槽 |
从源码看,YearRangePickerCell渲染的是一个无逻辑的Primitive容器:
<Primitive :as="as" :as-child="asChild" role="gridcell" :aria-selected="rootContext.isSelected(date) ? true : undefined" :aria-disabled="rootContext.isYearDisabled(date) || rootContext.isYearUnavailable?.(date)" :data-disabled="rootContext.isYearDisabled(date) ? '' : undefined" > <slot /> </Primitive>对应源码见 YearRangePickerCell.vue:它只做三件事——设置gridcell角色、根据区间选择状态输出aria-selected、根据禁用/不可用判定输出aria-disabled与data-disabled。真正的交互逻辑全部下沉到 Trigger 层。
YearRangePickerCell Props 详解
依据 YearRangePickerCell.md,YearRangePickerCell共暴露 3 个属性:
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
as | 组件应渲染为的元素或组件,可被asChild覆盖 | AsTag \| Component | No | "td" |
asChild | 将默认渲染元素替换为传入的子元素,并合并其 props 与行为 | boolean | No | - |
date | 单元格对应的日期值 | DateValue | Yes | - |
as 与 asChild
as与asChild是 radix-vue 全库统一的 Primitive 组合能力(来自@/Primitive,类型定义见 YearRangePickerCell.vue):
as:默认渲染为<td>,可改为任意 HTML 标签或 Vue 组件;asChild:一旦开启,默认元素被完全替换为你在插槽里写的第一个元素,两者通过运行时合并 props 与事件实现"继承行为"。
典型场景是单元格需渲染为<th>(表头语义)或自定义组件时使用;绝大多数情况下保持默认td即可。
date
date是必填属性,类型为@internationalized/date的DateValue。它是单元格的"身份标识",Root 层的isSelected、isYearDisabled、isYearUnavailable等判定函数都以它为准。实际取值通常由上一级YearRangePickerGridBody在遍历grid(Grid<DateValue>)时逐格生成,用户一般不手工构造。
YearRangePickerCellTrigger Props 详解
依据 YearRangePickerCellTrigger.md,Trigger 层同样暴露 3 个属性:
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
as | 组件应渲染为的元素或组件,可被asChild覆盖 | AsTag \| Component | No | "div" |
asChild | 将默认渲染元素替换为传入的子元素,并合并其 props 与行为 | boolean | No | - |
year | 提供给单元格触发器的日期值 | DateValue | Yes | - |
as默认值为"div"(注意与 Cell 的"td"不同),结合role="button"构成语义化的可点击节点;asChild用法与 Cell 一致,常用于把年份数字渲染进<button>或<span>;year与 Cell 的date对应,传同一个DateValue即可;Trigger 内部通过toDate(props.year)与formatter.fullYear将其格式化为年份文本(见 YearRangePickerCellTrigger.vue)。
CellTrigger 的 10 个作用域插槽状态
这是 Trigger 层最具价值的能力:默认插槽暴露10 个布尔/字符串状态,让单元格样式完全由数据驱动。完整清单见 YearRangePickerCellTrigger.md:
| Name | Description | Type |
|---|---|---|
yearValue | 当前年份值 | string |
disabled | 当前禁用状态 | boolean |
selected | 当前选中状态 | boolean |
today | 当前年份是否为今年 | boolean |
unavailable | 当前不可用状态 | boolean |
highlighted | 当前高亮状态(用户拖选/键盘框选过程中) | boolean |
highlightedStart | 当前是否为高亮区间的起点 | boolean |
highlightedEnd | 当前是否为高亮区间的终点 | boolean |
selectionStart | 当前是否为选中区间的起点 | boolean |
selectionEnd | 当前是否为选中区间的终点 | boolean |
源码中这些状态的来源(YearRangePickerCellTrigger.vue):
today:用toCalendar(today(getLocalTimeZone()), props.year.calendar)换算当前日期后与year做isSameYear比较,因此支持非公历日历系统;unavailable:直接调用 Root 注入的isYearUnavailable?.(props.year);highlighted:判断year是否落在highlightedRange区间内,区间由 useRangeYearPicker.ts 计算得出(见下文"高亮区间计算");selected/selectionStart/selectionEnd:分别对应isSelected、isSelectionStart、isSelectionEnd。
一个典型用法是把这些状态映射为 Tailwind/CSS 类:
<YearRangePickerCellTrigger v-slot="{ yearValue, selected, today, selectionStart, selectionEnd, disabled }"> <span :class="{ 'bg-blue-500 text-white': selected, 'rounded-l': selectionStart, 'rounded-r': selectionEnd, 'ring-1 ring-blue-200': today && !selected, 'opacity-50': disabled, }" >{{ yearValue }}</span> </YearRangePickerCellTrigger>渲染输出与 Data Attributes 映射
Trigger 的渲染结果远不止一个年份文本,它向外暴露了一套完整的data-*属性供样式选择器与测试使用(见 year-range-picker.md 中 Cell Trigger 一节及 YearRangePickerCellTrigger.vue 模板):
| Attribute | 含义 |
|---|---|
[data-selected] | 存在即选中 |
[data-value] | 日期的 ISO 字符串值(如2024-01-01) |
[data-disabled] | 存在即禁用 |
[data-unavailable] | 存在即不可用 |
[data-today] | 存在即当年 |
[data-selection-start]/[data-selection-end] | 选中区间起点 / 终点 |
[data-highlighted]/[data-highlighted-start]/[data-highlighted-end] | 高亮区间及端点 |
[data-focused] | 存在即当前聚焦项 |
[data-reka-year-range-picker-cell-trigger] | 组件标记(固定输出) |
同时输出的 ARIA 属性包括:role="button"、aria-label(年份文本)、aria-pressed(选中时,且仅在allowNonContiguousRanges开启或年份未不可用时)、aria-disabled。tabindex采用 roving tabindex 策略——聚焦年份为0、其余为-1、禁用年份不设tabindex。
CSS 定位一个高亮区间中的普通年份只需:
[data-reka-year-range-picker-cell-trigger][data-highlighted] { background-color: var(--highlight-bg); } [data-reka-year-range-picker-cell-trigger][data-selection-start] { border-start-start-radius: 6px; }源码实现:基于 Primitive 与注入式上下文
两个组件都是典型的"薄壳 + 上下文"实现,核心依赖两件事:
Primitive:radix-vue 的通用渲染原语(@/Primitive),负责as/asChild的运行时解析、属性合并与事件透传,两个组件都通过withDefaults(defineProps<...>(), { as: 'td' | 'div' })声明默认渲染元素;injectYearRangePickerRootContext:Root 在 YearRangePickerRoot.vue 通过provideYearRangePickerRootContext注入的上下文对象,包含startValue/endValue、isSelected、isYearDisabled、isYearUnavailable、allowNonContiguousRanges、highlightedRange、minValue/maxValue、yearsPerPage、prevPage/nextPage等二十余项状态与函数。Cell 与 CellTrigger 的所有判定都从该上下文读取,保证单一数据源、无状态漂移。
这套上下文类型定义在 YearRangePickerRoot.vue,createContext工具位于@/shared,任何不处于 Root 内的孤立使用都会得到明确报错提示。
高亮区间计算原理
highlightedRange由 useRangeYearPicker.ts 计算,规则可以总结为:
- 已选完完整区间(start 与 end 都存在)且未设
fixedDate时,返回null(不再高亮); - 仅选中起点、焦点(
focusedValue)落在另一侧时,以起点与焦点为端点构造候选区间; - 若设置了
maximumYears,候选区间会被钳制在anchor ± (maximumYears - 1)年内; - 最后通过
areAllYearsBetweenValid校验区间内所有年份是否可用(allowNonContiguousRanges开启时跳过不可用校验,允许选择非连续区间;否则任一年份不可用即视为非法,返回null)。
isInvalid的判定逻辑同样在此文件中:起点/终点被isYearDisabled判为禁用、或终点年份早于起点年份时,整个区间置为无效(useRangeYearPicker.ts),Root 会相应输出[data-invalid]。
交互原理:点击选择与键盘导航
点击选择的状态机
handleClick → changeYear实现了完整的区间选择状态机(YearRangePickerCellTrigger.vue),核心分支包括:
- 只选了起点、未选终点:点击新年份时以
e.preventDefault()阻止事件并更新lastPressedDateValue,待下一次点击确定终点(同时支持"再次点击同一位置调整起点"); - 已选完整区间且起点=终点=当前年:再次点击清空整个选区(除非
preventDeselect); - 从空状态开始:第一次点击设
startValue,第二次点击设endValue; - 已选完整区间再点击:未设
fixedDate时以新点击为起点重新开始新区间;设fixedDate="start"/"end"时,按与固定端比较结果决定只移动另一端; - 选中/点击不可用或禁用年份:直接 return,不产生任何副作用。
readonly状态下点击完全被忽略;Esc 键在编辑进行中(isEditing)会回滚到validModelValue(上一个合法区间),见 YearRangePickerRoot.vue。
键盘导航
Trigger 上挂载了@keydown.up.down.left.right.space.enter.page-up.page-down(YearRangePickerCellTrigger.vue),行为与文档中的 Keyboard Table 一致:
| 按键 | 行为 |
|---|---|
Tab | 首次聚焦进入选择器时聚焦第一个导航按钮 |
Space/Enter | 焦点在 Next/Prev 上翻页;在 CellTrigger 上选中该年份 |
ArrowLeft/Right/Up/Down | 在年网格内移动(Up/Down 每次 ±4 年),跨页时自动翻页并继续移动;RTL 下左右方向翻转 |
PageUp/PageDown | 跳转到上一页/下一页年份(每页yearsPerPage年,默认 12) |
Escape | 取消当前选择,恢复上一个合法区间 |
方向键实现中有两个值得注意的细节(YearRangePickerCellTrigger.vue):
- 候选年份超出
minValue/maxValue范围时停止移动;目标元素不存在时自动调用nextPage/prevPage,nextTick后再递归查找,最多回溯 48 层(防止死循环); Ctrl/Meta/Alt + Enter/Space组合键会被放行,让事件冒泡给上层(如表单提交),避免被网格拦截。
这些行为在 YearRangePicker.test.ts 中有自动化测试覆盖,例如"给定默认区间后重新选择会重置选区""受控模式下保持 end 值"以及axe无障碍零违规断言(该测试同时覆盖CalendarDate、CalendarDateTime、ZonedDateTime三种日期类型,均得到相同选择结果,说明 Cell/Trigger 完全基于统一的DateValue抽象工作)。
完整示例:从 Anatomy 到自定义样式
将上述 API 组合起来,得到一份开箱即用的完整实现(Anatomy 源自 year-range-picker.md,这里补充了状态样式与受控绑定):
<script setup> import { ref } from 'vue' import { CalendarDate } from '@internationalized/date' import { YearRangePickerCell, YearRangePickerCellTrigger, YearRangePickerGrid, YearRangePickerGridBody, YearRangePickerGridRow, YearRangePickerHeader, YearRangePickerHeading, YearRangePickerNext, YearRangePickerPrev, YearRangePickerRoot, } from 'reka-ui' const range = ref({ start: new CalendarDate(2020, 1, 1), end: undefined }) </script> <template> <YearRangePickerRoot v-model="range" :maximum-years="10"> <YearRangePickerHeader> <YearRangePickerPrev /> <YearRangePickerHeading /> <YearRangePickerNext /> </YearRangePickerHeader> <YearRangePickerGrid> <YearRangePickerGridBody> <YearRangePickerGridRow> <YearRangePickerCell> <YearRangePickerCellTrigger v-slot="{ yearValue, selected, highlighted, selectionStart, selectionEnd, today, disabled }"> <span :class="{ 'bg-blue-500 text-white': selected, 'bg-blue-100': highlighted && !selected, 'rounded-l-full': selectionStart, 'rounded-r-full': selectionEnd, 'underline decoration-dotted': today, 'text-gray-300': disabled, }" >{{ yearValue }}</span> </YearRangePickerCellTrigger> </YearRangePickerCell> </YearRangePickerGridRow> </YearRangePickerGridBody> </YearRangePickerGrid> </YearRangePickerRoot> </template>其中maximum-years="10"会把可选区间钳制在起点 ± 9 年内,超出部分的年份由rangeIsYearDisabled判定为禁用(useRangeYearPicker.ts),在 Trigger 上体现为data-disabled与disabled插槽状态。
无障碍与测试验证
- 无障碍:Cell 承担
gridcell语义与选中/禁用 ARIA 标注,Trigger 承担button语义与aria-pressed/aria-label/aria-disabled,Root 同时提供隐藏的role="heading"区域(aria-level="2")输出fullCalendarLabel,供读屏器完整感知选区(见 YearRangePickerRoot.vue); - 键盘全覆盖:方向键、翻页键、Esc 取消均已实现并有测试佐证;
- 自动化测试:YearRangePicker.test.ts 断言了默认值渲染(1980–1983 区间高亮 4 个年份、标题显示
1980 - 1991页)、modelValue为null不崩溃、重新选择时区间重置、以及axe无障碍零违规。
小结
YearRangePickerCell与YearRangePickerCellTrigger是 radix-vue 区间选择体系中最贴近用户的一层:前者用最薄的容器承担语义与状态标注,后者承载完整的选中状态机、10 维插槽状态与键盘导航。理解这两层,你既能在几分钟内定制出带完整选中/高亮/禁用反馈的年区间选择器,也能顺着 YearRangePickerRoot.vue、useRangeYearPicker.ts 的上下文设计与状态机逻辑,把"选中 → 高亮 → 确认 → 可回滚"这套交互范式迁移到任何自定义的网格型选择组件中。
【免费下载链接】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),仅供参考