Vue TagsInputItem 组件深度解析:radix-vue 标签项的 Props、选中态与可访问性实现
【免费下载链接】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 的 Vue 实现)官方 API 参考文档docs/content/meta/TagsInputItem.md为骨架,结合 TagsInputItem 源码 与配套的 TagsInputRoot 源码、测试用例及官方组件文档,系统讲解 TagsInputItem 在标签输入(Tags Input)组件中的定位、全部 Props、渲染与选中态机制、与 ItemText/ItemDelete 的协作方式,以及键盘导航与无障碍实现。读完本文,你将能理解每个标签项背后的数据流,并能够熟练定制单个标签的样式、禁用与删除行为。
一、TagsInputItem 在组件家族中的定位
Tags Input 是一类"在输入框内渲染标签、并在其后跟随真实文本输入框"的复合组件。在 radix-vue 中,它由五个可组合部件构成(见官方文档 tags-input.md 的 Anatomy 一节,部件从reka-ui包导入):
TagsInputRoot:根容器,管理标签数组modelValue与全部交互逻辑;TagsInputItem:单个标签的容器,即本文的主角;TagsInputItemText:标签的文本部分,对无障碍至关重要;TagsInputItemDelete:删除当前标签的按钮;TagsInputInput:文本输入框;TagsInputClear:一键清空所有标签的按钮。
其中 TagsInputItem 处于中间枢纽位置:它向上从 Root 注入上下文(injectTagsInputRootContext),向下通过provideTagsInputItemContext把当前标签的value、displayValue、isSelected、disabled等状态提供给 ItemText 与 ItemDelete 使用。源码位于 packages/core/src/TagsInput/TagsInputItem.vue,并在 index.ts 中对外导出TagsInputItem及类型TagsInputItemProps。
二、Props 完整参考:继承自官方 API 文档
官方 API 参考(docs/content/meta/TagsInputItem.md)为 TagsInputItem 定义了 4 个 Props:
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
as | The element or component this component should render as. Can be overwritten by asChild. | AsTag \| Component | No | "div" |
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 | - |
disabled | When true, prevents the user from interacting with the tags input. | boolean | No | - |
value | Value associated with the tags | string \| number \| bigint \| Record<string, any> | Yes | - |
对应到源码中,TagsInputItemProps接口继承了PrimitiveProps,并声明了唯一必填的value与可选的disabled(TagsInputItem.vue):
export interface TagsInputItemProps extends PrimitiveProps { /** Value associated with the tags */ value: AcceptableInputValue /** When `true`, prevents the user from interacting with the tags input. */ disabled?: boolean }1.value:必填的标签值
value是每个标签项唯一必填的属性,其类型AcceptableInputValue定义在 TagsInputRoot.vue 中:
export type AcceptableInputValue = string | number | bigint | Record<string, any>也就是说,标签既可以承载普通字符串/数字,也可以承载任意对象。使用对象值时需要特别注意两点(详见下文"对象值"示例):
- Root 上必须提供
convertValue函数,把输入框文本转换为对象;否则运行时会抛出You must provide a 'convertValue' function when using objects as values.(该行为在 TagsInput.test.ts 中有专门用例验证); - 可以配合 Root 的
displayValue函数,把对象格式化为要展示的文本,例如Person: {name}。
每个 Item 的value会被登记到 Root 的 Collection(useCollection)中,Root 正是通过该集合遍历、定位、删除标签的。
2.disabled:禁用单个标签
disabled为true时阻止用户与该标签交互。值得注意的实现细节是:Item 的禁用态是自身禁用与整体禁用取并集得到的(TagsInputItem.vue):
const disabled = computed(() => props.disabled || context.disabled.value)即只要 Item 自身声明disabled,或者 Root 整体处于禁用态,该标签即视为禁用。禁用后的标签:
- 在根元素上渲染
data-disabled属性; - 会被 Root 的键盘导航集合过滤掉(Root 中
onInputKeydown使用filter(i => i.dataset.disabled !== '')构建可导航集合),因此方向键导航与 Backspace/Delete 删除都会跳过禁用标签——这一点在测试用例"given TagsInput with a disabled item before a removable one"中得到了验证:连续按 Backspace 只会删除可移除的标签,禁用标签永远无法被键盘删除(见 TagsInput.test.ts); - 子组件
TagsInputItemDelete同样会读取该禁用态,点击删除按钮时直接返回(TagsInputItemDelete.vue)。
3.as与asChild:Primitive 组合能力
与 radix-vue 其他组件一致,TagsInputItem 继承PrimitiveProps,默认渲染为div。as允许把标签改成任意元素或组件(如span、li、自定义组件);asChild则让标签直接合并到传入子元素的 props 与行为上(Radix 风格的 Slot 组合模式)。这一点使标签可以无缝融入既有的列表结构或设计系统。渲染由Primitive组件完成(TagsInputItem.vue):
<template> <CollectionItem :value="value"> <Primitive :ref="forwardRef" :as="as" :as-child="asChild" :aria-labelledby="itemContext.textId" :aria-current="isSelected" :data-disabled="disabled ? '' : undefined" :data-state="isSelected ? 'active' : 'inactive'" > <slot /> </Primitive> </CollectionItem> </template>三、选中态(data-state)与可访问性属性
官方组件文档为 Item 定义了如下 Data Attributes 表(见 tags-input.md 的### Item小节):
| 属性 | 值 |
|---|---|
[data-state] | active|inactive |
[data-disabled] | Present when disabled |
结合源码可以精确还原它们的来源:
data-state:由isSelected计算属性驱动。isSelected的判断逻辑是context.selectedElement.value === currentElement.value(TagsInputItem.vue),即当前标签的 DOM 元素是否正是 Root 记录的"当前选中标签"。选中时输出active,否则为inactive。这也是官方样式示例中data-[state=active]、aria-[selected=true]类选择器能够生效的根本原因。data-disabled:仅在disabled为真时输出空字符串(''),否则为undefined(即不渲染该属性)。aria-current:选中标签会输出aria-current="true",配合视觉高亮,向屏幕阅读器标明当前聚焦的标签。aria-labelledby:指向itemContext.textId,该 id 由TagsInputItemText生成(见下文)。
此外 Root 根元素还带有data-invalid、data-disabled、data-focused等状态属性(TagsInputRoot.vue),可与 Item 的属性配合做整体样式控制。
四、与子组件的上下文协作
Item 通过provideTagsInputItemContext向内部子组件提供上下文(TagsInputItem.vue):
export interface TagsInputItemContext { value: Ref<AcceptableInputValue> displayValue: ComputedRef<string> isSelected: Ref<boolean> disabled?: Ref<boolean> textId: string }TagsInputItemText:文本渲染与 id 生成
TagsInputItemText默认渲染为span(as默认值),从 Item 上下文读取displayValue作为默认插槽内容,并负责生成textId(TagsInputItemText.vue):
itemContext.textId ||= useId(undefined, 'reka-tags-input-item-text')<template> <Primitive v-bind="props" :id="itemContext.textId" > <slot>{{ itemContext.displayValue.value }}</slot> </Primitive> </template>因此"先有 ItemText、再有 Item"的顺序是安全且推荐的:ItemText 一旦挂载,就会把生成的 id 回填到 Item 上下文的textId,Item 据此渲染aria-labelledby,保证每个标签的可访问名称与其可见文本一一对应。官方 Anatomy 中TagsInputItemText被标注为 "Important for accessibility"。
TagsInputItemDelete:删除按钮
TagsInputItemDelete默认渲染为button,并强制设置tabindex="-1"(避免键盘 Tab 进入标签内层按钮,删除统一交给键盘导航),同时继承aria-labelledby、aria-current、data-state、data-disabled(TagsInputItemDelete.vue)。点击删除时的调用链为:
function handleDelete() { if (disabled.value) return const index = context.modelValue.value.findIndex(i => isEqual(i, itemContext.value.value)) context.onRemoveValue(index) }它使用ohash的isEqual做深度相等比较(因此对象值也能正确匹配),找到当前标签在modelValue数组中的下标后,调用 Root 提供的onRemoveValue。Root 的handleRemoveTag会从数组中过滤掉该值并触发removeTag事件(TagsInputRoot.vue)。
五、键盘交互:选中、删除与导航
虽然键盘逻辑主要由 Root 的onInputKeydown承担,但它操作的对象正是 Item 的选中态(selectedElement)。官方文档的 Keyboard Table 定义了如下交互(tags-input.md 的### Keyboard Interactions):
| Keys | Description |
|---|---|
Delete | When tag is active, remove it and set the tag on right active. |
Backspace | When tag is active, remove it and set the tag on left active. If there are no tags to the left, either the next tag gets focus, or the input. |
ArrowRight | Set the next tag active. |
ArrowLeft | Set the previous tag active. |
Home | Set the first tag active. |
End | Set the last tag active. |
源码层面的关键细节(TagsInputRoot.vue):
- 当输入框光标位于首字符(
selectionStart === 0)时,按ArrowLeft会先选中最后一个标签;连续按方向键通过useArrowNavigation在标签集合中移动,selectedElement随之更新,Item 的data-state同步切换; - 在末位标签上按
ArrowRight会取消选中、回到输入框; - 标签处于选中态时按
Backspace/Delete会先执行handleRemoveTag,再把选中态迁移到相邻标签(删除方向遵循 LTR/RTL 语义); - 使用 IME 输入法组合(composition)期间,所有导航与删除逻辑都会提前返回,避免干扰候选词选择——测试中专门验证了"输入法组合期间输入分隔符不应创建标签"的场景(TagsInput.test.ts)。
六、完整实战示例
基础用法:字符串标签 + 删除按钮
参考仓库 story 组件 _TagsInput.vue:
<script setup lang="ts"> import { ref } from 'vue' import { TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot } from 'reka-ui' const modelValue = ref(['Test']) </script> <template> <TagsInputRoot v-model="modelValue" class="flex flex-wrap items-center gap-2 ..."> <TagsInputItem v-for="item in modelValue" :key="item" :value="item" class="...><TagsInputRoot v-model="people" :display-value="(item) => `Person: ${item.name}`" :convert-value="(text) => ({ name: text, id: Math.random() })" > <TagsInputItem v-for="(item, i) in people" :key="i" :value="item"> <TagsInputItemText class="text-sm" /> <TagsInputItemDelete><XIcon /></TagsInputItemDelete> </TagsInputItem> <TagsInputInput placeholder="Add a person..." /> </TagsInputRoot>禁用单个标签
参考 _TagsInputDisabled.vue:通过给特定 Item 传disabled即可锁定该标签,同时可配合v-if隐藏其删除按钮;Root 的键盘导航会自动跳过它,且测试保证它永远不会被 Backspace 删除。
七、测试如何验证这些行为
TagsInput.test.ts 覆盖了与 TagsInputItem 直接相关的三类关键行为,可作为功能契约参考:
- 默认渲染与新增:初始标签正确渲染;回车新增后
addTag事件按序触发、输入框被清空并保持焦点; - 键盘导航与删除:
ArrowLeft选中末位标签(data-state="active")、Home/End跳到首/末位、ArrowRight取消选中、Backspace触发removeTag并选中新末位标签; - 对象值与禁用:对象标签正确显示格式化文本;缺少
convertValue时抛出指定错误信息;禁用标签前的可移除标签能被删除而禁用标签始终保留。
整套测试还通过axe做了无障碍断言,从侧面印证了aria-labelledby、aria-current、data-state等属性设计满足无违规(accessibility violations)要求。
八、总结
TagsInputItem 是整个 Tags Input 组件中负责"单个标签"渲染与状态的核心部件:value决定了标签的身份(支持字符串、数字与对象),disabled提供了细粒度的单项禁用能力,as/asChild保留了 Primitive 的组合自由度,而data-state(active/inactive)与aria-*属性则把选中态无缝暴露给样式层与无障碍层。理解它向上注入 Root 上下文、向下供给 ItemText/ItemDelete 的协作链路,就能在此基础上轻松实现带自动补全、富文本样式与复杂对象数据的标签输入交互。
【免费下载链接】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),仅供参考