radix-vue(Reka UI)YearPickerNext 组件完全指南:年份翻页按钮的 Props、Slot 与源码级实现解析
【免费下载链接】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
导读:
YearPickerNext是 radix-vue(现已更名为 Reka UI)日期组件体系中用于"向后翻页"的导航按钮,负责让用户从当前页面向未来方向切换一页年份(默认每页 12 年)。本文以 YearPickerNext 官方 API 文档 为骨架,结合 YearPickerNext.vue 源码、根组件 YearPickerRoot.vue 与 useYearPicker.ts 组合式函数,以及 YearPicker.test.ts 测试用例,完整讲解其 Props、Slot、数据属性、禁用判定逻辑、自定义翻页函数覆盖机制,并给出可直接运行的完整示例代码。读完本文,你将能够独立使用、定制并深入理解 YearPicker 的"下一页"导航行为。
YearPickerNext 是什么
YearPickerNext是 Year Picker(年份选择器)头部导航区中的"下一页"按钮。在 radix-vue 中,YearPicker 是一套由多个可组合部件构成的组件族,YearPickerNext与YearPickerPrev对称分布于 YearPickerHeader 两侧,中间是显示当前年份范围的 YearPickerHeading:
<template> <YearPickerRoot> <YearPickerHeader> <YearPickerPrev /> <YearPickerHeading /> <YearPickerNext /> <!-- 本篇文章的主角 --> </YearPickerHeader> <YearPickerGrid>...</YearPickerGrid> </YearPickerRoot> </template>在官方文档(year-picker.md)中,它的定位被描述为:
Calendar navigation button. It navigates the calendar one page (default: 12 years) in the future.
即"日历导航按钮,将日历向前(未来方向)翻动一页,默认一页为 12 年"。当用户点击它时,网格中的年份范围会整体向后平移,例如从1980 - 1991变为1992 - 2003。这一行为在 YearPicker.test.ts 中有对应的集成测试:
it('navigates to next decade using next button', async () => { const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDate } }) const heading = getByTestId('heading') const nextBtn = getByTestId('next-button') expect(heading).toHaveTextContent('1980 - 1991') await user.click(nextBtn) expect(heading).toHaveTextContent('1992 - 2003') await user.click(nextBtn) expect(heading).toHaveTextContent('2004 - 2015') })Props 详解
根据 YearPickerNext.md,YearPickerNext暴露 3 个 Props:as、asChild与nextPage。
as
| 属性 | 类型 | 必填 | 默认值 |
|---|---|---|---|
as | AsTag \| Component | 否 | "button" |
as决定该组件最终渲染成什么元素或组件,且可以被asChild覆盖。它来自 Primitive 基础层(PrimitiveProps),因此你可以把默认的<button>换成任意 HTML 标签或自定义组件。
在 YearPickerNext.vue 源码中可以看到默认值确实为button:
const props = withDefaults(defineProps<YearPickerNextProps>(), { as: 'button' })值得注意的是,模板中对type属性做了与as联动的处理(YearPickerNext.vue):
:type="props.as === 'button' ? 'button' : undefined"只有当渲染元素是原生<button>时才显式设置type="button",避免在表单场景中误触发提交行为;当as被改成其他元素时则不注入type。
asChild
| 属性 | 类型 | 必填 | 默认值 |
|---|---|---|---|
asChild | boolean | 否 | - |
asChild用于"将默认渲染元素替换为传入的子元素,并合并它们的 props 与行为"。这是 radix-vue/Reka UI 的核心组合模式:当你希望用图标、链接或自定义按钮组件替代默认<button>时,把它作为子元素传入并设置asChild,本组件的点击、禁用、无障碍属性都会透传合并到子元素上。
结合源码(YearPickerNext.vue)可以看到,渲染由Primitive完成:
<Primitive :as="props.as" :as-child="props.asChild" aria-label="Next page" :type="props.as === 'button' ? 'button' : undefined" :aria-disabled="disabled || undefined" :data-disabled="disabled || undefined" :disabled="disabled" @click="handleClick" > <slot :disabled> Next page </slot> </Primitive>因此无论渲染成什么元素,aria-label="Next page"、aria-disabled、data-disabled、disabled与点击处理都会被统一注入。
nextPage
| 属性 | 类型 | 必填 | 默认值 |
|---|---|---|---|
nextPage | ((placeholder: DateValue) => DateValue) | 否 | - |
nextPage是本文的核心 Props,官方文档描述为:
The function to be used for the next page. Overwrites the
nextPagefunction set on theYearPickerRoot.
它的语义是"用于计算下一页的函数,覆盖在YearPickerRoot上设置的nextPage函数"。类型签名对应 YearPickerNext.vue 中的接口定义:
export interface YearPickerNextProps extends PrimitiveProps { /** The function to be used for the next page. Overwrites the `nextPage` function set on the `YearPickerRoot`. */ nextPage?: (placeholder: DateValue) => DateValue }这实现了"两级覆盖"机制:根组件定义全局默认翻页策略,单个按钮可以局部覆盖它。测试用例 YearPicker.test.ts 演示了如何通过nextPage: date => date.add({ years: 13 })自定义翻页步长,让每次翻页前进 13 年而非默认的 12 年:
it('falls back to the nearest enabled year when paged candidate is missing on PageDown', async () => { const { getByTestId, user } = setup({ pickerProps: { placeholder: calendarDate, nextPage: date => date.add({ years: 13 }), }, }) // ... await user.keyboard(kbd.PAGE_DOWN) expect(getByTestId('heading')).toHaveTextContent('1993 - 2004') expect(getByTestId('year-1993')).toHaveFocus() })Slot 详解
YearPickerNext暴露 1 个默认 Slot,文档定义如下:
| 名称 | 描述 | 类型 |
|---|---|---|
disabled(默认 slot 作用域) | 当前禁用状态 | boolean |
其接口在 YearPickerNext.vue 中定义:
export interface YearPickerNextSlot { default?: (props: { /** Current disable state */ disabled: boolean }) => any }也就是说,插槽的作用域对象上只有一个disabled布尔值。源码中的渲染方式是(YearPickerNext.vue):
<slot :disabled> Next page </slot>当不提供插槽内容时,默认渲染文本Next page。使用插槽时可以获得实时的禁用状态,例如在禁用时切换图标颜色或样式:
<YearPickerNext v-slot="{ disabled }"> <ChevronRightIcon :class="disabled ? 'text-gray-300' : 'text-gray-700'" /> </YearPickerNext>禁用判定与翻页逻辑的源码实现
YearPickerNext本身逻辑极简,关键行为都委托给根组件通过provide/inject注入的上下文(injectYearPickerRootContext)。完整流程如下(YearPickerNext.vue):
const rootContext = injectYearPickerRootContext() const disabled = computed(() => rootContext.disabled.value || rootContext.isNextButtonDisabled(props.nextPage)) function handleClick() { if (disabled.value) return rootContext.nextPage(props.nextPage) }禁用状态的两层来源
disabled计算属性由两部分构成:
- 根组件的
disabled:当整个 YearPicker 被设置为禁用时,翻页按钮必然不可用; isNextButtonDisabled(props.nextPage):根据当前网格的最后一页年份与maxValue边界判断"是否还有下一页"。
后者实现在 useYearPicker.ts:
const isNextButtonDisabled = (nextPageFunc?: (date: DateValue) => DateValue) => { if (!props.maxValue.value) return false if (props.disabled.value) return true const lastYearInView = grid.value.cells.at(-1)! if (nextPageFunc || props.nextPage.value) { const nextDate = (nextPageFunc || props.nextPage.value)!(lastYearInView) return isAfter(startOfYear(nextDate), props.maxValue.value) } const nextPageStart = startOfYear(lastYearInView.add({ years: 1 })) return isAfter(nextPageStart, props.maxValue.value) }逻辑要点:
- 未设置
maxValue时永远可点:没有上限约束,按钮不会被禁用; - 自定义
nextPage函数时按函数结果判定:先计算"用该函数翻页后的年份",再与该页起始年份比较是否越过maxValue; - 默认行为按
yearsPerPage平移判定:即"当前页最后一年的下一年"是否超出maxValue。
当判定为禁用时,组件会同时输出aria-disabled、data-disabled与原生disabled属性,测试 YearPicker.test.ts 对此进行了验证:
it('should not allow navigation after the `maxValue` (next button)', async () => { const { getByTestId, user } = setup({ pickerProps: { modelValue: calendarDate, maxValue: new CalendarDate(1991, 12, 31), }, }) const nextBtn = getByTestId('next-button') expect(nextBtn).toHaveAttribute('aria-disabled', 'true') expect(nextBtn).toHaveAttribute('data-disabled') await user.click(nextBtn) expect(getByTestId('heading')).toHaveTextContent('1980 - 1991') })翻页函数如何更新网格
点击后调用的rootContext.nextPage(props.nextPage)实现在 useYearPicker.ts:
const nextPage = (nextPageFunc?: (date: DateValue) => DateValue) => { const firstYearInGrid = grid.value.value if (nextPageFunc || props.nextPage.value) { const newDate = (nextPageFunc || props.nextPage.value)!(firstYearInGrid) grid.value = createYearGrid({ dateObj: newDate, yearsPerPage: props.yearsPerPage.value, decadeAligned: false }) props.placeholder.value = newDate.set({ month: props.placeholder.value.month, day: props.placeholder.value.day }) return } const newDate = firstYearInGrid.add({ years: props.yearsPerPage.value }) grid.value = createYearGrid({ dateObj: newDate, yearsPerPage: props.yearsPerPage.value, decadeAligned: false }) props.placeholder.value = newDate.set({ month: props.placeholder.value.month, day: props.placeholder.value.day }) }其工作流可以概括为:
- 取当前网格的第一个年份作为基准(
grid.value.value); - 若传入自定义
nextPage(按钮级优先于根组件级),则调用它得到新基准日期;否则默认+ yearsPerPage年; - 用新基准重新调用 createYearGrid 生成新网格(注意此处传入
decadeAligned: false,即不再对齐到十年整段,而是以新基准年份为页首); - 同步更新
placeholder(保留原有的月份与日期,只替换年份),使根组件的网格、标题与聚焦逻辑保持一致。
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) } }这也解释了为什么测试中点击一次nextBtn后标题从1980 - 1991变为1992 - 2003:默认yearsPerPage为 12,网格按 3 行 × 4 列排列(chunk(years, 4)),翻页即整体平移 12 年。
与键盘交互的关系
YearPickerNext不仅支持鼠标点击,还响应键盘。根据 year-picker.md 的 Keyboard Interactions 章节:
Space/Enter:焦点在YearPickerNext或YearPickerPrev上时,触发翻页;焦点在年份单元格上时则选中该年份;PageDown/PageUp:焦点在年份单元格上时,分别翻到下一页/上一页年份(内部同样复用nextPage/prevPage逻辑)。
测试 YearPicker.test.ts 验证了PageDown/PageUp与点击按钮产生相同的翻页结果。此外,当自定义nextPage导致目标年份被禁用时,组件会自动聚焦到最近的可选年份(见 YearPicker.test.ts),保证键盘导航永不"落空"。
完整可用示例
下面是一个结合minValue/maxValue边界、自定义nextPage覆盖与插槽用法的完整示例,可直接在安装reka-ui(即 radix-vue 的新包名)与@internationalized/date的项目中运行:
<script setup lang="ts"> import { ref } from 'vue' import { CalendarDate } from '@internationalized/date' import { YearPickerCell, YearPickerCellTrigger, YearPickerGrid, YearPickerGridBody, YearPickerGridRow, YearPickerHeader, YearPickerHeading, YearPickerNext, YearPickerPrev, YearPickerRoot, } from 'reka-ui' const value = ref<CalendarDate>() // 按钮级覆盖:让"下一页"按钮每次前进 5 年(默认 12 年) function customNext(placeholder: CalendarDate) { return placeholder.add({ years: 5 }) } </script> <template> <YearPickerRoot v-model="value" :min-value="new CalendarDate(2020, 1, 1)" :max-value="new CalendarDate(2045, 12, 31)" :years-per-page="12" calendar-label="Select a year" > <YearPickerHeader> <YearPickerPrev> <template #default="{ disabled }"> <span :class="disabled ? 'text-gray-300' : 'text-gray-800'">← 上一页</span> </template> </YearPickerPrev> <YearPickerHeading /> <YearPickerNext :next-page="customNext"> <template #default="{ disabled }"> <span :class="disabled ? 'text-gray-300' : 'text-gray-800'">下一页 →</span> </template> </YearPickerNext> </YearPickerHeader> <YearPickerGrid> <YearPickerGridBody> <YearPickerGridRow v-for="(row, i) in 3" :key="`row-${i}`"> <YearPickerCell v-for="year in 4" :key="year" :date="/* 由 grid 提供 */"> <YearPickerCellTrigger :year="/* 当前年份 */" /> </YearPickerCell> </YearPickerGridRow> </YearPickerGridBody> </YearPickerGrid> </YearPickerRoot> </template>说明:上述模板中网格行与单元格建议按官方 Anatomy 推荐的方式,通过根组件的作用域插槽
v-slot="{ grid }"遍历grid.rows渲染,具体写法可参考 story/_YearPicker.vue 中的示例实现。minValue/maxValue与nextPage的组合使用即可完整复现"边界自动禁用 + 自定义步长"的行为。
边界行为与易错点小结
- 自定义
nextPage的禁用判定会变:一旦传入nextPage,isNextButtonDisabled将基于"函数计算结果"而非"默认平移 12 年"来判定是否越界(useYearPicker.ts),所以自定义步长不会意外把页面翻出maxValue; - 按钮级优先于根组件级:
YearPickerNext的nextPage覆盖 YearPickerRoot 的nextPage,调用时传参优先级为"组件自身 props > 根组件 props"(useYearPicker.ts); disabled是"整组禁用 + 边界禁用"的合取:根组件disabled或maxValue越界任一成立,按钮即不可点,并同步输出aria-disabled、data-disabled与原生disabled;- 翻页不会丢失月份/日期上下文:
nextPage更新placeholder时保留原月份与日期,只替换年份(useYearPicker.ts); - 无障碍默认值已内置:组件自带
aria-label="Next page"与隐藏的role="heading"(由根组件输出完整fullCalendarLabel),并通过了vitest-axe的可访问性测试(YearPicker.test.ts)。
参考文件索引
- API 文档主体:docs/content/meta/YearPickerNext.md
- 组件文档(Anatomy / Keyboard Interactions):docs/content/docs/components/year-picker.md
- 组件源码:packages/core/src/YearPicker/YearPickerNext.vue
- 对称按钮实现:packages/core/src/YearPicker/YearPickerPrev.vue
- 根组件(上下文提供方):packages/core/src/YearPicker/YearPickerRoot.vue
- 翻页/禁用逻辑:packages/core/src/YearPicker/useYearPicker.ts
- 网格生成函数:packages/core/src/date/calendar.ts
- 集成测试:packages/core/src/YearPicker/YearPicker.test.ts
- 可参考的完整组合示例:packages/core/src/YearPicker/story/_YearPicker.vue
【免费下载链接】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),仅供参考