radix-vue(Reka UI)TagsInputItemText 组件源码解析:标签文本渲染与无障碍设计的核心
【免费下载链接】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
导读
TagsInputItemText是 radix-vue(现 Reka UI)标签输入组件(TagsInput)家族中负责渲染单个标签文本的部分,官方文档将其定位为"标签的文本部分,对无障碍(accessibility)至关重要"。本文以 TagsInputItemText 组件元数据文档 为骨架,结合 TagsInput 源码 与官方组件文档 tags-input.md,深入讲解它的两个 Props(as、asChild)、默认插槽与displayValue渲染机制、textId无障碍关联原理,并给出可直接复制运行的实战示例。读完你将掌握:如何自定义标签文本的渲染元素、如何基于对象值优雅展示标签、以及为什么 ItemText 是标签输入组件可访问性的关键一环。
一、组件定位:TagsInput 家族中的"文本输出单元"
在深入了解TagsInputItemText之前,先明确它在整个组件中的位置。根据 官方 Tags Input 文档 中的 Anatomy(结构解剖),一个完整的标签输入组件由以下部分组成:
<script setup> import { TagsInputClear, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot } from 'reka-ui' </script> <template> <TagsInputRoot> <TagsInputItem> <TagsInputItemText /> <TagsInputItemDelete /> </TagsInputItem> <TagsInputInput /> <TagsInputClear /> </TagsInputRoot> </template>各部分职责如下:
| 部件 | 职责 |
|---|---|
TagsInputRoot | 根容器,承载值状态、增删逻辑与键盘导航 |
TagsInputItem | 单个标签容器,注入标签的 value、选中态与禁用态 |
TagsInputItemText | 标签的文本展示部分(本文主角) |
TagsInputItemDelete | 删除当前标签的按钮 |
TagsInputInput | 实际文本输入框 |
TagsInputClear | 一键清空所有标签的按钮 |
从源码目录 packages/core/src/TagsInput/ 可以看到,这六个部件一一对应六个.vue文件,且TagsInputItem.vue、TagsInputItemText.vue、TagsInputItemDelete.vue之间存在清晰的上下文(Context)注入关系,下文会具体拆解。
二、Props 详解:as 与 asChild
TagsInputItemText的 API 极为精简,依据 组件元数据文档 的 Props 表,它只暴露两个 Props:
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
as | 该组件应渲染成的元素或组件,可被asChild覆盖。 | AsTag \| Component | No | "span" |
asChild | 将默认渲染元素替换为传入的子元素,并合并其 Props 与行为。 | boolean | No | - |
2.1as:默认渲染为<span>
as的类型AsTag | Component定义于 Primitive.ts,涵盖a、button、div、input、li、span、ul等常见 HTML 标签,也可以传入任意 Vue 组件。
在源码 TagsInputItemText.vue 中,默认值被显式指定为'span':
const props = withDefaults(defineProps<TagsInputItemTextProps>(), { as: 'span', })之所以默认是span,是因为标签文本在语义上属于行内内容;而根组件Primitive自身的默认值是div(见 Primitive.ts),TagsInputItemText覆写了这一默认值,保证默认行为符合语义预期。
2.2asChild:把渲染权交给子元素
asChild是 radix-vue / Reka UI 全库通用的组合(Composition)机制。当设置为true时,组件不再渲染自身标签,而是将 Props、属性与行为全部合并到传入的唯一子元素上,实现"样式与语义分离"。
在Primitive的实现中,asChild会令asTag变为'template',最终通过内部Slot组件渲染子元素并合并属性(见 Primitive.ts):
const asTag = props.asChild ? 'template' : props.as // ... return () => h(Slot, attrs, { default: slots.default })这意味着你可以把标签文本放进自定义的span、div甚至RouterLink等组件中,同时保留id等由 ItemText 注入的关键属性(无障碍关联依赖这个id,见第四节)。
三、源码级解析:默认插槽与 displayValue 渲染机制
TagsInputItemText的模板极其简洁(TagsInputItemText.vue):
<template> <Primitive v-bind="props" :id="itemContext.textId" > <slot>{{ itemContext.displayValue.value }}</slot> </Primitive> </template>这短短几行揭示了三个关键机制:
3.1 默认插槽内容来自displayValue
当你不传任何插槽内容时,组件默认显示itemContext.displayValue.value。这个值由TagsInputItem通过上下文提供(TagsInputItem.vue):
const itemContext = provideTagsInputItemContext({ value, isSelected, disabled, textId: '', displayValue: computed(() => context.displayValue(value.value)), })而displayValue函数本身定义在TagsInputRoot上,默认实现为(value: T) => value.toString()(TagsInputRoot.vue)。调用链可以概括为:
TagsInputRoot.displayValue(value) → TagsInputItem 注入 displayValue 计算属性 → TagsInputItemText 默认插槽渲染3.2 插槽可覆盖:完全掌控展示内容
由于使用了具名默认插槽,你可以直接覆盖渲染内容,而不必依赖displayValue。在源码示例 story/_TagsInput.vue 中可以看到这种写法:
<TagsInputItemText class="text-sm"> {{ item }} </TagsInputItemText>当然,更推荐的做法是让插槽留空、通过 Root 的displayValue统一控制展示逻辑,这样所有标签(包括选中态、删除按钮的无障碍标签)的展示口径保持一致。
3.3textId的懒生成与共享
模板中的:id="itemContext.textId"是无障碍的关键。在 setup 阶段,如果 Item 上下文中的textId尚未生成,组件会通过useId补一个稳定 ID(TagsInputItemText.vue):
const itemContext = injectTagsInputItemContext() useForwardExpose() itemContext.textId ||= useId(undefined, 'reka-tags-input-item-text')注意itemContext.textId是在TagsInputItem的上下文中被共享的同一个引用——由于TagsInputItemText通常是第一个被渲染的 Item 子组件,它负责"抢占"生成textId,而同一 Item 下的其他部件会复用这个 ID(||=保证了只生成一次)。从源码结构看,这种设计让"一个 Item 只有一个文本 ID"成为不变式。
四、无障碍设计:为什么 ItemText "Important for accessibility"
官方文档对 ItemText 的定位是"标签的文本部分,对无障碍至关重要"(tags-input.md)。这句话在源码中有非常具体的体现:
4.1aria-labelledby双向引用同一个textId
在 TagsInputItem.vue 中,Item 根元素以textId作为自身的无障碍标签:
<Primitive :aria-labelledby="itemContext.textId" :aria-current="isSelected" :data-disabled="disabled ? '' : undefined" :data-state="isSelected ? 'active' : 'inactive'" >而在 TagsInputItemDelete.vue 中,删除按钮同样引用了它:
<Primitive tabindex="-1" v-bind="props" :aria-labelledby="itemContext.textId" :aria-current="itemContext.isSelected.value" :data-state="itemContext.isSelected.value ? 'active' : 'inactive'"这样形成的语义是:标签容器和删除按钮都以 ItemText 的文本内容作为自己的可访问名称(accessible name)。屏幕阅读器朗读到某个标签或它的删除按钮时,读出的正是 ItemText 渲染的文字(例如"JavaScript"或"Person: Alice")。这正是该组件被单独抽象出来、并强调其无障碍价值的根本原因——它不只是显示文本,更是整个标签可访问名称的"唯一真相源"。
4.2 与对象值配合:displayValue让无障碍与展示一致
当标签值是对象时(如{ name: 'Alice' }),默认的value.toString()只会输出[object Object],既不美观也无障碍价值。此时应通过 Root 的displayValue提供可读文本。在 TagsInput.test.ts 中可以找到官方测试用例中的用法:
displayValue: (item: any) => `Person: ${item.name}`,结合TagsInputRoot的convertValue(把输入字符串转换为对象)与displayValue(把对象还原为可读文本)两个 Props(见 TagsInputRoot.vue),即可实现"输入文本 → 对象存储 → 文本展示"的完整闭环,且屏幕阅读器听到的标签名与视觉呈现完全一致。
五、实战示例
5.1 基础用法:字符串标签
最简单的用法(完整组件结构可对照 官方文档示例 与 story/_TagsInput.vue):
<script setup lang="ts"> import { ref } from 'vue' import { TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot } from 'reka-ui' const modelValue = ref(['Vue', 'Reka UI']) </script> <template> <TagsInputRoot v-model="modelValue"> <TagsInputItem v-for="item in modelValue" :key="item" :value="item" class="tag-item" > <TagsInputItemText class="tag-text" /> <TagsInputItemDelete aria-label="remove" /> </TagsInputItem> <TagsInputInput placeholder="Add a tag..." /> </TagsInputRoot> </template>这里<TagsInputItemText class="tag-text" />不写插槽内容,文本由 Root 的默认displayValue(value.toString())渲染,渲染出的元素是带tag-text类的<span>。
5.2 对象值 + displayValue:标签显示可读文本
<script setup lang="ts"> import { ref } from 'vue' import { TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot } from 'reka-ui' interface Person { name: string } const modelValue = ref<Person[]>([]) const convertValue = (v: string): Person => ({ name: v }) const displayValue = (v: Person) => `Person: ${v.name}` </script> <template> <TagsInputRoot v-model="modelValue" :convert-value="convertValue" :display-value="displayValue" > <TagsInputItem v-for="item in modelValue" :key="item.name" :value="item" > <!-- 插槽留空:文本由 displayValue 统一渲染 --> <TagsInputItemText /> <TagsInputItemDelete /> </TagsInputItem> <TagsInputInput placeholder="Type a name..." /> </TagsInputRoot> </template>注意:当标签值为对象时,必须为 Root 提供convertValue,否则源码会在添加标签时直接抛出You must provide a convertValue function when using objects as values.(见 TagsInputRoot.vue)。
5.3 asChild 组合:复用现有组件承载文本
当需要把标签文本渲染进自定义组件(例如带图标的胶囊、或路由链接)时,使用as-child:
<TagsInputItem :value="item"> <TagsInputItemText as-child> <span class="tag-custom"> <TagIcon /> <slot>{{ displayValue(item) }}</slot> </span> </TagsInputItemText> <TagsInputItemDelete /> </TagsInputItem>此时 ItemText 不会渲染自身的<span>,而是把id等属性合并到子元素上,同时保持无障碍关联不失效。
六、配套属性与键盘交互(上下文速览)
作为标签输入体系的一部分,理解以下配套信息有助于用好 ItemText:
- Item 的数据属性:
[data-state](active/inactive,由选中态决定)、[data-disabled](禁用时存在),见 TagsInputItem.vue; - 键盘交互(官方文档):标签激活时,
Delete删除当前标签并将右侧标签置为激活;Backspace删除当前标签并激活左侧标签;ArrowRight/ArrowLeft在标签间移动激活;Home/End跳到首尾标签。这些逻辑全部由 Root 的onInputKeydown处理(TagsInputRoot.vue),ItemText 无需参与键盘逻辑,其文本仅作为可访问名称被朗读。
结语
TagsInputItemText虽然只有两个 Props、一段不到十行的模板,却是标签输入组件中"看得见"与"被听见"之间的桥梁:它以默认<span>渲染由displayValue计算出的标签文本,通过textId同时成为 Item 容器与删除按钮的可访问名称来源,并以插槽和asChild保留了完全的自定义空间。从 元数据文档 到 源码实现,再到 官方组件文档 与 story 示例,一条"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),仅供参考