深度解析 radix-vue YearPickerGrid:年份选择网格的渲染、组合与无障碍实现
2026/9/17 20:44:14 网站建设 项目流程

深度解析 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 组件体系:

NameDescriptionTypeRequiredDefault
asThe element or component this component should render as. Can be overwritten by asChild.AsTag \| ComponentNo"table"
asChildChange the default rendered element for the one passed as a child, merging their props and behavior. Read our Composition guide for more details.booleanNo-

两者含义与配套关系如下:

  • 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-labelledbyaria-readonlyaria-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-readonlydata-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, ...)会检查新年份是否仍落在当前网格区间内,若超出则重新生成网格;同时监听localeyearsPerPage变化以重建网格(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把各层替换为divsection等元素,语义层仍由组件自身补充role与 ARIA 属性。

对应地,YearPickerGridBody.md 与 YearPickerGridRow.md 中各自记录了两个标准 Props:

NameDescriptionTypeRequiredDefault(GridBody / GridRow)
asThe element or component this component should render as. Can be overwritten by asChild.AsTag \| ComponentNo"tbody"/"tr"
asChildChange the default rendered element for the one passed as a child, merging their props and behavior.booleanNo-

无障碍与键盘交互

YearPickerGrid及其兄弟部件共同构成了完整的键盘导航体验。官方文档(year-picker.md 的 Accessibility 一节)记录了如下键盘交互约定:

按键行为
Tab焦点移入年份选择器时,聚焦第一个导航按钮
Space焦点在YearPickerNext/YearPickerPrev上时翻页;否则选中年份
Enter同上,翻页或选中年份
ArrowLeft / ArrowRight / ArrowUp / ArrowDown焦点在YearPickerCellTrigger上时在年份间移动,必要时自动切换页面
PageUp焦点在YearPickerCellTrigger上时跳到上一页年份
PageDown焦点在YearPickerCellTrigger上时跳到下一页年份

这些行为与YearPickerRootonMounted中调用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-labelledbyaria-readonlyaria-disabled以及data-*属性全部合并到子元素上,保证自定义布局下无障碍语义不丢失。

场景三:配置每页年份数

网格的页大小由YearPickerRootyearsPerPage属性控制(默认 12),这也是生成 3×4 网格的依据:

<YearPickerRoot v-model="selected" :years-per-page="8"> <!-- 每页 8 年,rows 按每行 4 个切分为 2 行 --> </YearPickerRoot>

修改后createYearGrid会生成对应数量的年份,useYearPickerwatch([props.locale, props.yearsPerPage], ...)会自动重建网格(useYearPicker.ts 第 173-176 行),翻页逻辑也会同步按新页大小步进。

小结

  • YearPickerGrid是 YearPicker 网格体系的顶层容器,默认渲染为<table>,通过as/asChild支持任意元素或组件替换;
  • 它从YearPickerRoot注入上下文,负责输出role="application"aria-labelledbyaria-readonlyaria-disableddata-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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询