深度解析 radix-vue YearPickerGrid:年份选择网格的渲染、组合与无障碍实现
【免费下载链接】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
YearPickerGrid 是 radix-vue(原 Radix Vue)YearPicker 组件族中负责承载年份选择网格的容器组件。本篇文章围绕该组件的 Props 定义、源码实现、网格数据生成机制与无障碍语义展开,帮助你理解它在 YearPicker 结构中的定位,并掌握用as/asChild将默认<table>渲染为任意元素或组件的组合技巧。读完本文,你将能够独立搭建、定制并正确无障碍地使用基于年份网格的选择器界面。
YearPickerGrid 在 YearPicker 结构中的定位
YearPicker 是一个"以日历视图形式选择年份"的组件,官方文档(docs/content/docs/components/year-picker.md)给出了完整的 Anatomy(部件组装)结构:
<script setup> import { YearPickerCell, YearPickerCellTrigger, YearPickerGrid, YearPickerGridBody, YearPickerGridRow, YearPickerHeader, YearPickerHeading, YearPickerNext, YearPickerPrev, YearPickerRoot, } from 'reka-ui' </script> <template> <YearPickerRoot> <YearPickerHeader> <YearPickerPrev /> <YearPickerHeading /> <YearPickerNext /> </YearPickerHeader> <YearPickerGrid> <YearPickerGridBody> <YearPickerGridRow> <YearPickerCell> <YearPickerCellTrigger /> </YearPickerCell> </YearPickerGridRow> </YearPickerGridBody> </YearPickerGrid> </YearPickerRoot> </template>在这个结构中,部件之间的层级关系与原生 HTML 表格一一对应:
| 部件 | 默认渲染元素 | 语义角色 |
|---|---|---|
YearPickerGrid | <table> | 网格容器 |
YearPickerGridBody | <tbody> | 网格主体容器 |
YearPickerGridRow | <tr> | 网格行容器 |
YearPickerCell | <td> | 单元格容器 |
YearPickerCellTrigger | <button> | 可交互的年选择按钮 |
YearPickerGrid位于Header(包含 Prev / Heading / Next 导航按钮)与单元格区域之间,是整个网格部分的最外层容器。它的核心职责有两个:包装网格内容、向上层传递只读与禁用状态的无障碍语义(见下文源码分析)。
Props 详解:as 与 asChild
根据 docs/content/meta/YearPickerGrid.md 的定义,YearPickerGrid暴露两个 Props,均继承自 Primitive 组件体系:
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
as | The element or component this component should render as. Can be overwritten by asChild. | AsTag \| Component | No | "table" |
asChild | Change the default rendered element for the one passed as a child, merging their props and behavior. Read our Composition guide for more details. | boolean | No | - |
两者含义与配套关系如下:
as:指定组件最终渲染为哪个元素或组件。默认值是'table',即默认渲染为原生表格。你可以传入任意合法标签名(如'section'、'div')或一个 Vue 组件(如Transition、自定义布局组件)。asChild:当设为true时,组件不再自己渲染元素,而是将自身的 props 与行为合并到传入的单个子元素(slot 根节点)上,由子元素承担最终渲染。此时as将被覆盖(文档原文 "Can be overwritten by asChild")。
这一设计在源码中有直接体现。YearPickerGrid.vue 中 Props 接口继承自PrimitiveProps,并通过withDefaults设置默认渲染元素:
export interface YearPickerGridProps extends PrimitiveProps {} const props = withDefaults(defineProps<YearPickerGridProps>(), { as: 'table' })实际渲染时使用Primitive组件完成(packages/core/src/YearPicker/YearPickerGrid.vue模板部分):
<Primitive v-bind="props" tabindex="-1" role="application" :aria-labelledby="rootContext.headingId" :aria-readonly="readonly" :aria-disabled="disabled" :data-readonly="readonly && ''" :data-disabled="disabled && ''" > <slot /> </Primitive>Primitive(位于 packages/core/src/Primitive)负责解析as/asChild,将属性和事件正确绑定到目标元素或组件上。这意味着无论你把网格渲染成<table>、<section>还是自定义组件,无障碍属性(aria-labelledby、aria-readonly、aria-disabled)都会被一并透传。
源码级实现:网格容器的无障碍语义与状态透传
YearPickerGrid看似只是一个"包装容器",但从源码可以看到它承担了重要的可访问性职责。它通过injectYearPickerRootContext()从YearPickerRoot注入共享上下文(注入定义见 YearPickerRoot.vue 第 92-93 行的createContext),进而读取根组件的禁用 / 只读状态:
const rootContext = injectYearPickerRootContext() const disabled = computed(() => rootContext.disabled.value ? true : undefined) const readonly = computed(() => rootContext.readonly.value ? true : undefined)这些状态最终转化为以下输出(packages/core/src/YearPicker/YearPickerGrid.vue):
| 属性 | 值 | 含义 |
|---|---|---|
tabindex="-1" | 固定 | 网格容器可被程序化聚焦(供初始聚焦使用),但不进入 Tab 键顺序 |
role="application" | 固定 | 将容器声明为应用程序区域,配合内部键盘导航逻辑使用 |
aria-labelledby | 根上下文的headingId | 指向YearPickerHeading生成的标题元素,为网格提供可访问名称 |
aria-readonly | 只读时存在 | 向辅助技术声明只读状态 |
aria-disabled | 禁用时存在 | 向辅助技术声明禁用状态 |
data-readonly | 只读时存在 | 供 CSS 选择器使用,例如[data-readonly]样式定制 |
data-disabled | 禁用时存在 | 供 CSS 选择器使用,例如[data-disabled]样式定制 |
对应的官方数据属性表(year-picker.md 中 Grid 一节)确认了data-readonly与data-disabled两个属性在 "Present when readonly / disabled" 时出现。注意:只读与禁用是不同的语义——禁用会完全阻止交互,而只读仍然允许聚焦与浏览,但不允许修改选择。
网格数据从何而来:createYearGrid 与 3×4 网格结构
YearPickerGrid本身只负责渲染容器,网格中的年份数据则由YearPickerRoot通过useYearPicker组合式函数生成。在 useYearPicker.ts 中,网格以响应式ref保存:
const grid = ref<Grid<DateValue>>(createYearGrid({ dateObj: props.placeholder.value, yearsPerPage: props.yearsPerPage.value, })) as Ref<Grid<DateValue>>createYearGrid定义于 packages/core/src/date/calendar.ts,其行为如下:
/** * Creates a 3x4 grid of years (decade-aligned). * The grid starts from the decade that contains the given date. */ export function createYearGrid(props: CreateSelectProps & { yearsPerPage?: number, decadeAligned?: boolean }): Grid<DateValue> { const { dateObj, yearsPerPage = 12, decadeAligned = true } = props let startYear: number if (decadeAligned) { startYear = startOfDecade(dateObj).year } else { startYear = dateObj.year } const years = Array.from({ length: yearsPerPage }, (_, i) => startOfYear(dateObj.set({ year: startYear + i }))) const firstYear = years[0] return { value: firstYear, cells: years, rows: chunk(years, 4) } }关键细节:
- 默认 3×4 网格:默认
yearsPerPage = 12,生成 12 个年份,并通过chunk(years, 4)每行 4 个、共 3 行,与文档"导航按钮默认一次翻页 12 年"的描述吻合。 - 十年对齐:
decadeAligned默认为true,网格从包含当前日期的那个"十年"起点开始(startOfDecade)。例如 placeholder 位于 2026 年时,网格第一格是 2020 年,展示 "2020 - 2031" 这样的标题区间。 - 翻页重排:
nextPage/prevPage会基于当前网格首年加减yearsPerPage生成新网格,并传入decadeAligned: false以保证翻页严格按页步进(见 useYearPicker.ts 第 135-163 行)。 - placeholder 驱动重绘:
useYearPicker内部watch(props.placeholder, ...)会检查新年份是否仍落在当前网格区间内,若超出则重新生成网格;同时监听locale与yearsPerPage变化以重建网格(useYearPicker.ts 第 165-176 行)。
Grid<DateValue>类型的数据结构为{ value, cells, rows },可通过YearPickerRoot的默认插槽解构获得(见 YearPickerRoot.vue 中插槽签名{ date, grid, locale, modelValue }),因此你完全可以在外层访问grid.cells/grid.rows来自定义网格排版。
GridBody 与 GridRow:层级化组合的分工
与YearPickerGrid配套的还有两个同属网格体系的容器部件,它们同样是"纯容器 + 默认元素"的 Primitive 组件:
YearPickerGridBody.vue:默认渲染为<tbody>,Props 同样只有as(默认'tbody')与asChild,模板仅包含<Primitive v-bind="props"><slot /></Primitive>。
YearPickerGridRow.vue:默认渲染为<tr>,并固定附加role="row":
<Primitive v-bind="props" role="row" > <slot /> </Primitive>从源码结构看,这套层级刻意模拟了原生表格的 DOM 语义:table > tbody > tr > td。这样默认形态下可以免费获得原生表格的无障碍与样式基础;而当你需要非表格布局(例如用 CSS Grid 做 3×4 卡片网格)时,只需借助as/asChild把各层替换为div、section等元素,语义层仍由组件自身补充role与 ARIA 属性。
对应地,YearPickerGridBody.md 与 YearPickerGridRow.md 中各自记录了两个标准 Props:
| Name | Description | Type | Required | Default(GridBody / GridRow) |
|---|---|---|---|---|
as | The element or component this component should render as. Can be overwritten by asChild. | AsTag \| Component | No | "tbody"/"tr" |
asChild | Change the default rendered element for the one passed as a child, merging their props and behavior. | boolean | No | - |
无障碍与键盘交互
YearPickerGrid及其兄弟部件共同构成了完整的键盘导航体验。官方文档(year-picker.md 的 Accessibility 一节)记录了如下键盘交互约定:
| 按键 | 行为 |
|---|---|
Tab | 焦点移入年份选择器时,聚焦第一个导航按钮 |
Space | 焦点在YearPickerNext/YearPickerPrev上时翻页;否则选中年份 |
Enter | 同上,翻页或选中年份 |
ArrowLeft / ArrowRight / ArrowUp / ArrowDown | 焦点在YearPickerCellTrigger上时在年份间移动,必要时自动切换页面 |
PageUp | 焦点在YearPickerCellTrigger上时跳到上一页年份 |
PageDown | 焦点在YearPickerCellTrigger上时跳到下一页年份 |
这些行为与YearPickerRoot在onMounted中调用handleCalendarInitialFocus(parentElement.value)(当initialFocus为真时,见 YearPickerRoot.vue 第 255-258 行)相互配合:网格容器上的tabindex="-1"允许程序化聚焦,而方向键导航逻辑作用于内部的单元格触发器。
实战:从默认表格到完全自定义布局
场景一:默认表格形态
最直接的用法就是采用默认形态,配合YearPickerRoot的插槽数据自定义单元格样式:
<template> <YearPickerRoot v-model="selected" locale="zh-CN"> <YearPickerHeader> <YearPickerPrev /> <YearPickerHeading /> <YearPickerNext /> </YearPickerHeader> <YearPickerGrid> <YearPickerGridBody> <YearPickerGridRow v-for="(row, i) in grid.rows" :key="i"> <YearPickerCell v-for="year in row" :key="year.toString()"> <YearPickerCellTrigger /> </YearPickerCell> </YearPickerGridRow> </YearPickerGridBody> </YearPickerGrid> </YearPickerRoot> </template>在YearPickerRoot的默认插槽中解构出grid,即可按 3 行 × 4 列的数据结构自由驱动模板。
场景二:用 asChild 替换网格容器元素
当需要把网格放进自定义组件(例如过渡动画容器)时,将asChild设为true,并把目标组件作为唯一子元素:
<YearPickerGrid as-child> <TransitionGroup name="years" tag="div" class="grid"> <!-- YearPickerGridBody / Row / Cell 内容 --> </TransitionGroup> </YearPickerGrid>YearPickerGrid会把它携带的role="application"、aria-labelledby、aria-readonly、aria-disabled以及data-*属性全部合并到子元素上,保证自定义布局下无障碍语义不丢失。
场景三:配置每页年份数
网格的页大小由YearPickerRoot的yearsPerPage属性控制(默认 12),这也是生成 3×4 网格的依据:
<YearPickerRoot v-model="selected" :years-per-page="8"> <!-- 每页 8 年,rows 按每行 4 个切分为 2 行 --> </YearPickerRoot>修改后createYearGrid会生成对应数量的年份,useYearPicker的watch([props.locale, props.yearsPerPage], ...)会自动重建网格(useYearPicker.ts 第 173-176 行),翻页逻辑也会同步按新页大小步进。
小结
YearPickerGrid是 YearPicker 网格体系的顶层容器,默认渲染为<table>,通过as/asChild支持任意元素或组件替换;- 它从
YearPickerRoot注入上下文,负责输出role="application"、aria-labelledby、aria-readonly、aria-disabled及data-readonly/data-disabled等无障碍与样式钩子属性; - 网格数据由
createYearGrid生成:默认每页 12 年、十年对齐、每行 4 个(3×4),页大小可通过yearsPerPage调整; - 与
YearPickerGridBody(默认tbody)、YearPickerGridRow(默认tr,固定role="row")形成与原生表格一致的层级,既开箱即用,又可深度定制; - 完整的键盘导航(方向键、PageUp/PageDown、Space/Enter)依赖这套网格结构实现,替换渲染元素不会破坏交互语义。
如需继续深入,可以阅读 YearPickerRoot.vue(上下文与状态管理)、useYearPicker.ts(翻页与网格生命周期)以及 packages/core/src/date/calendar.ts(网格生成算法),并结合 docs/content/docs/components/year-picker.md 的完整 API 参考进行实践。
【免费下载链接】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),仅供参考