
Radix Vue FocusScope 组件深度解析聚焦范围、焦点陷阱与自动聚焦控制【免费下载链接】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 Vueradix-vue 仓库的FocusScope组件 API 文档为骨架完整覆盖其 5 个 Propsas、asChild、loop、present、trapped与 2 个可阻止的自动聚焦事件mountAutoFocus、unmountAutoFocus并结合 FocusScope.vue、stack.ts、utils.ts 等源码与 FocusScope.test.ts 测试用例讲清它是如何管理 Tab 循环、焦点捕获trap、嵌套焦点栈以及“隐藏但保持挂载”场景下的自动聚焦行为。读完本文你将能够正确使用FocusScope包裹表单/弹层内容并理解 Dialog、Select、Combobox 等高层组件依赖它的底层机制。1. FocusScope 在 Radix Vue 中的角色FocusScope是一个轻量的“聚焦范围”原语它渲染一个普通的语义容器默认div通过as/asChild可替换并在该容器内建立三条键盘焦点规则——自动聚焦挂载时自动把焦点移入容器内第一个可聚焦元素卸载时把焦点还给挂载前的元素循环looploop为true时从最后一个可聚焦元素按 Tab 会回到第一个ShiftTab 则反向捕获trappedtrapped为true时焦点无法通过键盘、指针或编程方式离开该范围。从源码结构看FocusScope不只是独立组件它是 Radix Vue 弹层类组件的核心基础设施。仓库中 DialogContentImpl.vue、SelectContentImpl.vue、PopoverContentImpl.vue、MenuContentImpl.vue、ComboboxContentImpl.vue、DrawerContentImpl.vue、ToastViewport.vue 等均在内部使用FocusScope实现各自的焦点管理——Dialog 的模态焦点陷阱、Select 弹出内容“夺走”焦点等体验最终都落在FocusScope的实现上。2. Props 全量说明FocusScope的 Props 定义于 FocusScope.vue 的FocusScopeProps接口继承PrimitiveProps即as/asChild默认值在withDefaults中声明FocusScope.vue#L61-L71。完整对照表如下Name说明类型必填默认值as指定组件渲染的元素或组件可被asChild覆盖AsTag \| Component否divasChild将默认渲染元素替换为子元素并合并其 props 与行为组合写法boolean否-loop为true时从最后一个可聚焦元素 Tab 会聚焦到第一个ShiftTab 从第一个到最后一个boolean否falsepresent表示范围当前是否可见。允许消费者让范围保持挂载但隐藏如display: none并获得正确的自动聚焦不可见时跳过挂载自动聚焦重新可见时再次执行。默认true因此“仅在可见时才挂载”的用法不受影响boolean否truetrapped为true时焦点无法通过键盘、指针或编程式 focus 逃出该范围boolean否false几个值得注意的默认值语义loop与trapped默认均为false即默认情况下FocusScope只负责“挂载时聚焦进入、卸载时焦点归还”不干预 Tab 流出as默认渲染div但模板中始终会给容器绑定tabindex-1保证容器自身可作为兜底聚焦目标见 FocusScope.vue#L306-L316present默认必须为true——源码注释专门解释了原因由于它是boolean类型若默认值是undefinedVue 的布尔 prop 强制转换会把“未传”变成false导致不显式传present的消费者如 Combobox/Select 的内容组件不会把自己注册进焦点栈、无法暂停祖先陷阱Dialog 内的 Combobox 输入框将永远无法获得焦点对应 issue #2749。这段注释在 FocusScope.vue#L64-L70。3. 事件mountAutoFocus 与 unmountAutoFocus两个事件均由组件通过容器元素上的CustomEvent分发cancelable: true消费者可以通过event.preventDefault()阻止默认聚焦行为常量定义于 utils.ts#L3-L5Name说明类型mountAutoFocus挂载自动聚焦时调用的事件处理器。可阻止[event: Event]unmountAutoFocus卸载自动聚焦时调用的事件处理器。可阻止[event: Event]从源码可以精确还原其触发时机挂载路径FocusScope.vue#L187-L236组件在nextTick后检查当前激活元素是否已在容器内若不在且present ! false则调用dispatchMountAutoFocusFocusScope.vue#L173-L185——先向容器派发focusScope.autoFocusOnMount事件并把它桥接为组件的mountAutoFocusemit只有事件未被preventDefault时才会把焦点移到第一个可聚焦候选focusFirst若焦点仍停留在原元素则兜底聚焦容器本身。卸载路径cleanup 回调FocusScope.vue#L213-L235派发focusScope.autoFocusOnUnmount事件桥接为unmountAutoFocusemit随后给容器打上data-focus-scope-unmounting属性——源码注释说明这是为了标记“下面的 blur 事件是系统焦点陷阱清理触发的不是用户触发的”消费者可据此跳过表单校验等逻辑再在setTimeout(0)中若事件未被阻止把焦点还原到挂载前记录的元素否则回到document.body最后从焦点栈中移除自身并清除属性。典型用法示例阻止默认挂载聚焦、交给自定义逻辑script setup import { FocusScope } from radix-vue import { ref } from vue const formRef refHTMLElement() function onMountAutoFocus(event: Event) { // 跳过默认聚焦聚焦到我想要的元素 event.preventDefault() document.querySelector(#custom-target)?.focus() } /script template FocusScope refformRef loop mount-auto-focusonMountAutoFocus buttonSave/button input aria-labelname / button typesubmitSubmit/button /FocusScope /template高层组件正是利用“可阻止”这一特性做定制的例如 DialogContentModal.vue 拦截了内容层的关闭自动聚焦事件改为把焦点精确还给 Dialog 的触发器DialogContentModal.vue#L43-L50。4. loopTab 循环的实现原理loop的行为由容器上的keydown处理器实现FocusScope.vue#L270-L303只有loop或trapped至少一个为true、且当前焦点未被暂停时才响应仅处理不带 Alt/Ctrl/Meta 修饰键的 Tab 键用getTabbableEdges求出容器内第一个/最后一个可聚焦元素在“焦点位于最后一个 Tab”或“焦点位于第一个 ShiftTab”时preventDefault若loop为true才真正把焦点focus()到另一端trapped为true但loop为false时只是把 Tab“吃掉”焦点留在边缘不流出。“可聚焦元素”的识别在 utils.ts 中getTabbableCandidates使用document.createTreeWalker遍历容器内元素跳过disabled、hidden属性、typehidden的 input并以运行时node.tabIndex 0判断可聚焦性utils.ts#L42-L61——注释特别说明它不理会正tabindex的排序因为 Tab 顺序与视觉顺序不一致会损害可访问性getTabbableEdges在此基础上用findVisible过滤掉display: none/visibility: hidden的不可见元素utils.ts#L25-L30、utils.ts#L67-L87。FocusScope.test.ts 用testing-library/user-event的 Tab 模拟验证了这些规则FocusScope.test.ts#L56-L117默认作用域内 Tab 移到下一个元素、最后一个元素 Tab 回到第一个、第一个元素 ShiftTab 到最后一个loop生效tabindex-1的元素在 Tab 循环中会被跳过循环回来之前最后一个可聚焦元素会正确收到一次blurFocusScope.test.ts#L142-L148保证依赖 blur 的逻辑如表单校验仍能触发。focus()辅助函数还有一个细节utils.ts#L95-L113聚焦时使用preventScroll: true以避免突兀的滚动且仅当目标元素发生变化、是可选择的input、并显式要求select时才调用element.select()让文本输入框在被自动聚焦时选中文本。5. trapped焦点陷阱的三重防线trapped通过一个watchEffect建立三条相互独立的防线FocusScope.vue#L88-L167全部以文档级focusin/focusout监听 MutationObserver实现并在focusScope.paused为真时整体旁路防线一指针/跨元素 focusin。handleFocusIn记录容器内最后被聚焦的元素一旦聚焦目标跑到容器外立即把焦点拉回并select文本。防线二focusout 逃逸。handleFocusOut检查relatedTarget若焦点确实移到了容器外的真实元素则把焦点移回容器内最后一个有效聚焦元素。源码注释解释了两种relatedTarget null的边界情况并选择放行FocusScope.vue#L109-L120一是用户切换 App/标签页/浏览器失焦浏览器自己记忆焦点状态二是 Chrome 中聚焦元素被移出 DOM 时会触发该事件此时强行聚焦已删除节点会把 CPU 打满所以不做任何事。防线三DOM 变更。当聚焦元素被移除时浏览器会把焦点甩回document.bodyhandleMutations用MutationObserver监听容器子树确认有节点被移除、且最后聚焦的元素已不在容器内时把焦点落到容器本身tabindex-1使其可聚焦。这对应 FocusScope.test.ts#L74-L79 的测试“focused element is removed from the DOM → container 获得焦点”。一个典型的loop trapped表单包裹示例与测试用例结构一致template FocusScope as-child loop trapped form labelName input typetext namename //label labelEmail input typeemail nameemail //label button typesubmitSubmit/button /form /FocusScope !-- 范围外的元素将永远不会被 Tab 到 -- buttonouter button/button /template6. present隐藏但保持挂载的自动聚焦present是为unmountOnHide: false强制挂载这类“关闭后仍留在 DOM”的场景设计的。它把原本绑定在物理挂载/卸载上的两个行为改为跟随“可见性”重新计时栈成员资格。物理挂载效果里只有present ! false时才会把当前 scope 加入焦点栈FocusScope.vue#L193-L198——注释说明一个以隐藏状态挂载的 scope如unmountOnHide: false的已关闭 Dialog若入栈会暂停当前激活 scope 的陷阱并破坏其焦点捕获挂载自动聚焦。present: false时跳过dispatchMountAutoFocus避免向隐藏范围偷焦点FocusScope.vue#L202-L211源码注释特别提醒props.present必须在await nextTick()之后读取以免该 effect 追踪present、导致每次开关都重复触发卸载自动聚焦的清理逻辑present 监听器FocusScope.vue#L246-L268present从true变false时把 scope 移出栈从false变true时重新入栈await nextTick()等消费者的可见性变更如v-show生效后再执行一次与挂载时完全相同的自动聚焦。注释指出对不传present的消费者默认恒为true这个 watcher 永远不会触发栈成员资格由主 effect 独占。对应的使用模式script setup import { FocusScope } from radix-vue import { ref } from vue const visible ref(false) /script template !-- 保持挂载、用 v-show 隐藏present 驱动自动聚焦的“重挂” -- FocusScope v-showvisible :presentvisible trapped loop input aria-labelfirst / buttonClose/button /FocusScope button clickvisible trueOpen scope/button /templateDialog 侧的配合逻辑可作为参照unmountOnHide: false时FocusScope永不卸载、关闭自动聚焦不会触发DialogContentModal.vue#L30-L33 通过监听present由true变false手动把焦点还给触发器。7. 焦点栈嵌套 scope 的暂停/恢复机制多弹层嵌套Dialog 内再开 Select/Combobox/Popover时不能同时存在两个“活动”的焦点陷阱。FocusScope通过一个全局共享栈协调stack.ts基于vueuse/core的createGlobalStatestack.ts#L10-L36add(scope)把当前栈顶活动 scopepause()然后把新 scope 去重后unshift到栈顶成为新的活动 scoperemove(scope)移除该 scope并resume()新的栈顶恢复被暂停的祖先陷阱。每个 scope 实例是{ paused, pause(), resume() }的响应式对象FocusScope.vue#L78-L86被paused的 scope 会在handleKeyDown、handleFocusIn、handleFocusOut中全部短路见 FocusScope.vue#L96-L107 与 FocusScope.vue#L273-L274。这个机制解决了一个真实的回归问题issue #2749测试用例 FocusScope.test.ts#L219-L241Dialog 内打开 Combobox 时Combobox 内容自带的FocusScope需要暂停Dialog 的trapped陷阱否则 Dialog 会不断把焦点抢回自己输入框永远无法聚焦。由于 Combobox/Select 的内容组件注册 scope 时都不传presentpresent默认必须是true该暂停逻辑才成立——这正是第 2 节默认值注释的由来。同一契约的另一个实例是“Dialog 内 portal 出 Dialog 的 Select 内容”FocusScope.test.ts#L249-L295焦点应落在 Select 内容内而不是被 Dialog 陷阱拉回。8. 小结与使用建议需要“进入即聚焦、离开即归还”的容器表单、内联面板直接用FocusScope必要时加loop需要模态级焦点隔离弹层、抽屉、Toast 视口加trapped并注意它同时拦截键盘、指针和编程式 focusunmountOnHide: false/v-show这类“常驻 DOM”的容器务必用present同步可见性否则会出现隐藏时抢焦点或陷阱误暂停想接管自动聚焦时机监听mount-auto-focus/unmount-auto-focus并preventDefault()与 Dialog/Select 等组件定制自身聚焦策略的方式一致编写组件时若要在已有 trap 的祖先中弹出内容参照 Select/Combobox 的做法——在内容层包一层FocusScope即可借助焦点栈自动暂停祖先陷阱。组件导出入口为 packages/core/src/FocusScope/index.ts导出FocusScope及FocusScopeProps、FocusScopeEmits类型多实例场景可参考 FocusScopeMultiple.story.vue 的示例API 速查以文档 docs/content/meta/FocusScope.md 为准。【免费下载链接】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),仅供参考