radix-vue(Reka UI)TooltipContent 组件深度解析:16 个 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
TooltipContent 是 radix-vue(Reka UI)工具提示(Tooltip)组件族中的核心弹出部件,负责在触发器聚焦或悬停时展示信息浮层。本文以 TooltipContent 官方元数据文档 为骨架,逐项解读其全部 Props 与事件,并结合packages/core/src/Tooltip与packages/core/src/Popper的源码,剖析其基于 Floating UI 的定位、碰撞检测与动画机制,最终给出可复制运行的完整实战代码。读完本文,你将能够精确控制 TooltipContent 的方位、偏移、碰撞行为、挂载时机与无障碍语义。
TooltipContent 在 Tooltip 组件族中的角色
Tooltip 是一组协同工作的部件(Parts),官方推荐的最小组合结构如下(完整用法见 tooltip.md):
<script setup lang="ts"> import { TooltipArrow, TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui' </script> <template> <TooltipProvider> <TooltipRoot> <TooltipTrigger /> <TooltipPortal> <TooltipContent> <TooltipArrow /> </TooltipContent> </TooltipPortal> </TooltipRoot> </TooltipProvider> </template>其中TooltipContent是"弹出内容"本体。从源码结构看(TooltipContent.vue),它的渲染链路为:
TooltipContent(Presence 包裹) └─ TooltipContentHoverable(可悬停内容)/ TooltipContentImpl(默认) └─ DismissableLayer(点击外部 / Esc 关闭) └─ PopperContent(Floating UI 定位) └─ 具名插槽内容 + VisuallyHidden(无障碍朗读文本)选择哪条渲染路径由TooltipRoot的disableHoverableContent决定:启用时使用 TooltipContentImpl.vue,禁用悬停内容时才切换到 TooltipContentHoverable.vue(后者借助useGraceArea维护"指针在触发器和内容之间过渡"的宽限区域,避免悬停中断导致闪烁关闭)。
Props 全解析:16 个配置项逐项拆解
以下表格完整继承自 TooltipContent.md,字段、类型与必填性均以文档为准:
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
align | The preferred alignment against the trigger. May change when collisions occur. | "start" \| "center" \| "end" | No | - |
alignOffset | An offset in pixels from the start or end alignment options. | number | No | - |
ariaLabel | By default, screenreaders will announce the content inside the component. If this is not descriptive enough, or you have content that cannot be announced, use aria-label as a more descriptive label. | string | No | - |
arrowPadding | The padding between the arrow and the edges of the content. If your content has border-radius, this will prevent it from overflowing the corners. | number | No | - |
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 | - |
avoidCollisions | When true, overrides the side and align preferences to prevent collisions with boundary edges. | boolean | No | - |
collisionBoundary | The element used as the collision boundary. By default this is the viewport, though you can provide additional element(s) to be included in this check. | Element \| (Element \| null)[] \| null | No | - |
collisionPadding | The distance in pixels from the boundary edges where collision detection should occur. Accepts a number (same for all sides), or a partial padding object, for example: { top: 20, left: 20 }. | number \| Partial<Record<"top" \| "right" \| "bottom" \| "left", number>> | No | - |
forceMount | Used to force mounting when more control is needed. Useful when controlling animation with Vue animation libraries. | boolean | No | - |
hideWhenDetached | Whether to hide the content when the trigger becomes fully occluded. | boolean | No | - |
positionStrategy | The type of CSS position property to use. | "fixed" \| "absolute" | No | - |
side | The preferred side of the trigger to render against when open. Will be reversed when collisions occur and avoidCollisions is enabled. | "top" \| "right" \| "bottom" \| "left" | No | - |
sideOffset | The distance in pixels from the trigger. | number | No | - |
sticky | The sticky behavior on the align axis. partial will keep the content in the boundary as long as the trigger is at least partially in the boundary whilst "always" will keep the content in the boundary regardless. | "partial" \| "always" | No | - |
updatePositionStrategy | Strategy to update the position of the floating element on every animation frame. | "always" \| "optimized" | No | - |
定位偏好类:side / sideOffset / align / alignOffset
side定义内容相对触发器的首选方位(上/右/下/左);sideOffset定义与触发器的像素间距。align定义在主轴上的对齐方式(start/center/end);alignOffset在start/end对齐基础上再追加像素偏移,用于微调。
一个直观组合示例:side="top"、side-offset="5"、align="start"、align-offset="8",表示内容出现在触发器上方 5px 处,向起点侧对齐并再偏移 8px。
碰撞处理类:avoidCollisions / collisionBoundary / collisionPadding / sticky / hideWhenDetached
这组 Props 控制浮层在视口或自定义容器边缘"放不下"时的行为,是 TooltipContent 在复杂页面布局中不越界的关键:
avoidCollisions:为true时,会覆盖side/align的偏好值,主动翻转方向以避让边界(默认开启)。collisionBoundary:碰撞检测的边界元素。默认是视口(viewport),也可以传入一个或多个元素数组,将其纳入检测范围。collisionPadding:边界四周保留的安全距离(像素)。传数字表示四边一致,也支持部分对象如{ top: 20, left: 20 }。sticky:对齐轴上的"粘性"策略。partial表示只要触发器还在边界内(哪怕只有一部分),内容就尽量留在边界内;always则无论触发器位置如何,都强制内容留在边界内。hideWhenDetached:当触发器被完全遮挡(如滚出视口)时隐藏内容。
渲染行为类:positionStrategy / updatePositionStrategy / forceMount
positionStrategy:定位使用的 CSSposition类型,fixed或absolute。updatePositionStrategy:浮层位置更新策略。optimized(默认)按需更新,always则在每个动画帧都重新计算——适用于浮层内容本身带有高频动画的场景。forceMount:强制挂载内容(不受开关状态控制)。在 TooltipContent.vue 中,Presence的present属性为forceMount || rootContext.open.value,因此它常与 Vue 动画库配合,实现离场动画控制。
无障碍与组合类:ariaLabel / as / asChild / arrowPadding
ariaLabel:默认情况下读屏软件会朗读内容区内的文本;若内容本身不可读或不够描述性,可用该属性提供更完整的无障碍标签(源码实现在下文详述)。as:组件实际渲染的标签,默认div;asChild则完全复用子元素的标签并合并 Props 与行为(即"Composition"组合模式,官方文档建议配合 Composition 指南 使用)。arrowPadding:箭头与内容边缘之间的内边距。当内容带有border-radius时,该值可防止箭头溢出圆角边缘。
默认值速查
文档表格中Default列多为-,实际默认值定义在 TooltipContentImpl.vue 的defu合并逻辑中:
| 属性 | 默认值 |
|---|---|
side | 'top' |
sideOffset | 0 |
align | 'center' |
avoidCollisions | true |
collisionBoundary | [](即仅视口) |
collisionPadding | 0 |
arrowPadding | 0 |
sticky | 'partial' |
hideWhenDetached | false |
positionStrategy | 'fixed' |
updatePositionStrategy | 'optimized' |
Events:两个可阻止的关闭事件
TooltipContent 仅暴露两个事件,均用于"关闭时机"的精细化拦截(可调用event.preventDefault()阻止默认行为):
| Name | Description | Type |
|---|---|---|
escapeKeyDown | Event handler called when focus moves to the destructive action after opening. It can be prevented by calling event.preventDefault | [event: KeyboardEvent] |
pointerDownOutside | Event handler called when a pointer event occurs outside the bounds of the component. It can be prevented by calling event.preventDefault. | [event: Event] |
它们在 TooltipContentImpl.vue 中由DismissableLayer逐层转发:@escape-key-down与@pointer-down-outside直接透传,而@dismiss(关闭确认)则触发rootContext.onClose()。例如,若希望点击内容区外部时不关闭提示,可在pointerDownOutside中event.preventDefault()。
<TooltipContent @pointer-down-outside="(event) => event.preventDefault()" > 点击外部也不关闭 </TooltipContent>源码级原理:TooltipContent 的四大内部机制
1. Presence 与 forceMount
TooltipContent.vue 外层包裹Presence组件,present值由forceMount || rootContext.open.value计算。这意味着:
- 常规情况下,内容随
open状态挂载/卸载; - 设置
forceMount后内容常驻 DOM,配合 CSS 动画库可实现完整的"进入 + 离开"双向动画控制。
2. 无障碍朗读:ariaLabel 与 VisuallyHidden
在 TooltipContentImpl.vue,ariaLabel的默认值并非空串,而是:
const ariaLabel = computed(() => props.ariaLabel || currentElement.value?.textContent)即:未显式传入ariaLabel时,自动取内容区文本作为朗读内容。该文本通过VisuallyHidden渲染为role="tooltip"、id为contentId的隐藏节点(第 121-126 行);而 TooltipTrigger.vue 在打开状态下为触发器设置aria-describedby指向该contentId,从而建立完整的无障碍关联关系。
3. 自动关闭:滚动监听与互斥机制
TooltipContentImpl 挂载后注册了两个全局监听(第 82-91 行):
- 监听
window的scroll(capture 阶段):若滚动目标是触发器或其祖先,则关闭当前提示; - 监听自定义事件
TOOLTIP_OPEN(见 TooltipRoot.vue 中打开时派发):保证同一时刻只有一个 Tooltip 处于打开状态。
4. 定位引擎:Floating UI 中间件链
TooltipContent 的位置计算完全复用 Popper 体系。在 PopperContent.vue 中,computedMiddleware组装了一条中间件链,与 TooltipContent 各 Props 一一对应:
offset:由sideOffset + arrowHeight(主轴)与alignOffset(交叉轴)驱动;flip:由avoidCollisions与sideFlip/alignFlip控制,负责碰撞时翻转方位与对齐;shift:由avoidCollisions驱动,sticky === 'partial'时附加limitShift()限制器;size:将availableWidth/availableHeight、锚点宽高等写入 CSS 变量(见下节);arrow:以arrowPadding为内边距计算箭头位置;transformOrigin:计算内容与箭头相对位置得出transform-origin;hide:仅当hideWhenDetached为true时启用referenceHidden策略。
同时,autoUpdate的animationFrame参数由updatePositionStrategy === 'always'控制(第 347-352 行),strategy则由positionStrategy传入。此外,Provider 层还可通过content属性提供全局默认配置,TooltipContentImpl.vue 使用defu将 Props > Provider 默认值 > 组件内置默认值三级合并。
CSS 变量与 data 属性:动画与尺寸约束的钥匙
data 属性
TooltipContent 暴露三个运行时可变的 data 属性(官方文档 tooltip.md):
| 属性 | 取值 |
|---|---|
[data-state] | closed/delayed-open/instant-open |
[data-side] | left/right/bottom/top |
[data-align] | start/end/center |
其中data-state的三态由 TooltipRoot.vue 计算:未打开为closed,打开时若经历过延迟定时器则为delayed-open,否则为instant-open。data-side/data-align会随碰撞翻转实时更新(placedSide/placedAlign,见 PopperContent.vue),因此可用来编写"方向感知"动画。
CSS 变量
TooltipContentImpl 将 Popper 的计算结果映射为五个专属变量(TooltipContentImpl.vue):
| CSS 变量 | 含义 |
|---|---|
--reka-tooltip-content-transform-origin | 由内容与箭头位置/偏移计算出的transform-origin |
--reka-tooltip-content-available-width | 触发器与边界之间剩余的宽度 |
--reka-tooltip-content-available-height | 触发器与边界之间剩余的高度 |
--reka-tooltip-trigger-width | 触发器的宽度 |
--reka-tooltip-trigger-height | 触发器的高度 |
实战示例
基础用法(带箭头)
<script setup> import { TooltipArrow, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui' </script> <template> <TooltipProvider> <TooltipRoot> <TooltipTrigger>悬停我</TooltipTrigger> <TooltipContent :side-offset="5" class="TooltipContent" > 提示内容 <TooltipArrow :width="11" :height="5" /> </TooltipContent> </TooltipRoot> </TooltipProvider> </template>全局统一延迟
利用 TooltipProvider 的delayDuration(默认 700ms)与skipDelayDuration(默认 300ms)统一控制所有提示的打开节奏:
<TooltipProvider :delay-duration="800" :skip-delay-duration="500"> <TooltipRoot>…</TooltipRoot> <TooltipRoot>…</TooltipRoot> </TooltipProvider>若某个提示需要立即弹出,可在 Root 上覆写:<TooltipRoot :delay-duration="0">。
约束内容尺寸
使用 CSS 变量让内容宽度贴合触发器、高度不超视口:
<TooltipContent class="TooltipContent" :side-offset="5">…</TooltipContent>.TooltipContent { width: var(--reka-tooltip-trigger-width); max-height: var(--reka-tooltip-content-available-height); }从计算原点展开的动画
.TooltipContent { transform-origin: var(--reka-tooltip-content-transform-origin); animation: scaleIn 0.5s ease-out; } @keyframes scaleIn { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }碰撞方向感知动画
.TooltipContent { animation-duration: 0.6s; animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); } .TooltipContent[data-side="top"] { animation-name: slideUp; } .TooltipContent[data-side="bottom"] { animation-name: slideDown; } @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } @keyframes slideUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }为禁用按钮显示提示
禁用按钮不触发任何事件,需将 Trigger 渲染为span并让按钮忽略指针事件(官方文档 tooltip.md):
<TooltipRoot> <TooltipTrigger as-child> <span tabindex="0"> <button disabled style="{ pointerEvents: 'none' }">…</button> </span> </TooltipTrigger> <TooltipContent>…</TooltipContent> </TooltipRoot>无障碍与键盘交互
Tooltip 遵循 WAI-ARIA Tooltip 设计模式(官方文档 tooltip.md)。键盘交互如下:
| 按键 | 行为 |
|---|---|
Tab | 无延迟地打开/关闭提示 |
Space | 若已打开,无延迟关闭 |
Enter | 若已打开,无延迟关闭 |
Escape | 若已打开,无延迟关闭 |
再加上ariaLabel+VisuallyHidden+aria-describedby的自动关联,以及 TooltipRoot 提供的ignoreNonKeyboardFocus(仅键盘焦点打开提示)等无障碍增强选项,TooltipContent 在默认配置下即可获得完整的读屏支持。
自定义 API 封装
Tooltip 全部部件都支持asChild组合模式,可将多个部件抽象成自己的组件并暴露更简洁的 Props。官方文档给出了把内容抽成contentprop 的示例(tooltip.md):
<!-- your-tooltip.vue --> <script setup lang="ts"> import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui' import { TooltipArrow, TooltipContent, TooltipRoot, TooltipTrigger, useForwardPropsEmits } from 'reka-ui' const props = defineProps<TooltipRootProps & { content?: string }>() const emits = defineEmits<TooltipRootEmits>() const forward = useForwardPropsEmits(props, emits) </script> <template> <TooltipRoot v-bind="forward"> <TooltipTrigger as-child> <slot /> </TooltipTrigger> <TooltipContent side="top" align="center"> {{ content }} <TooltipArrow :width="11" :height="5" /> </TooltipContent> </TooltipRoot> </template>使用时即可获得极简 API:<Tooltip content="提示内容"><button>触发器</button></Tooltip>。这种封装方式充分体现了 TooltipContent 各 Props 的可组合性与默认值设计的合理性——即使不显式传入任何定位参数,内容也会以side="top"、align="center"的默认姿态,配合avoidCollisions自动避让边界,稳定呈现在用户视野内。
【免费下载链接】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),仅供参考