如何让HeadlessUI Popover在React Portal中正常工作?
问题描述
我有一个用作筛选选择菜单的组件,布局如下:
由于上方存在黑色虚线容器div,必须使用Portal将面板提升到该容器上方,试过其他方案后最终选择Portal。
以下是用HeadlessUI组件实现的React组件抽象结构(未展示用于定位两个面板的PopperJS):
<Popover> <Popover.Button /> {ReactDOM.createPortal( <Popover.Panel> {/* 主面板 */} <Combobox> {/* 大量可滚动选项,包含一些Dialog... */} {/* 选中选项后打开子面板 */} </Combobox> {submenuIsOpen && ( {/* submenuIsOpen是状态变量 */} <div> {/* 用div替代嵌套的Popover.Panel */} <Combobox> {/* 选择值的选项 */} {/* 多选,点击内部不应关闭 */} </Combobox> </div>) } </Popover.Panel>, document.body) {/* 尽可能提升到DOM层级顶部 */} } </Popover>
核心问题
- 点击按钮打开主菜单
- 点击主菜单内部任意位置
- 主菜单会像点击外部失焦一样关闭
推测是Portal导致Popover误将面板判定为“外部”触发关闭,但也可能存在其他原因,该问题仅在引入Portal后出现。
已尝试的无效方案
- 仅对子菜单使用Portal:主菜单内部点击正常,但点击子菜单内任意位置会立即关闭Popover
- 使用HeadlessUI的
<Portal>替代:效果与上述情况一致 - 尝试不同的
Popover.Group组合:父级Popover.Group搭配作为实际Popover.Panel的各个菜单,无任何效果
解决方案
1. 自定义外部点击关闭判断逻辑
HeadlessUI的Popover默认会检查点击事件是否在自身DOM范围内,Portal将面板移到Popover组件外后,默认判断失效。可以通过onClose事件手动校验点击区域:
import { useRef } from 'react'; // 给面板添加ref const mainPanelRef = useRef(null); const subPanelRef = useRef(null); <Popover onClose={(event) => { // 检查点击目标是否在主面板或子面板内 const isInnerClick = mainPanelRef.current?.contains(event.target) || subPanelRef.current?.contains(event.target); // 内部点击则阻止关闭 if (isInnerClick) { event.preventDefault(); } }}> <Popover.Button /> {ReactDOM.createPortal( <Popover.Panel ref={mainPanelRef}> <Combobox> {/* ...选项内容 */} </Combobox> {submenuIsOpen && ( <div ref={subPanelRef}> <Combobox> {/* ...子选项内容 */} </Combobox> </div> )} </Popover.Panel>, document.body )} </Popover>
2. 用HeadlessUI Portal配合静态面板+手动控制
给Popover.Panel添加static属性脱离默认关闭逻辑,手动控制菜单显隐并阻止内部点击冒泡:
import { useState } from 'react'; import { Portal } from '@headlessui/react'; const [isOpen, setIsOpen] = useState(false); <Popover open={isOpen} onOpenChange={setIsOpen}> <Popover.Button onClick={() => setIsOpen(!isOpen)} /> <Portal> <Popover.Panel static> {/* 阻止内部点击事件冒泡到外部关闭逻辑 */} <Combobox onClick={(e) => e.stopPropagation()}> {/* ...主选项内容 */} </Combobox> {submenuIsOpen && ( <div onClick={(e) => e.stopPropagation()}> <Combobox> {/* ...子选项内容 */} </Combobox> </div> )} {/* 手动添加全屏遮罩处理外部点击关闭 */} <div className="fixed inset-0 z-[999]" onClick={() => setIsOpen(false)} style={{ pointerEvents: isOpen ? 'auto' : 'none' }} /> </Popover.Panel> </Portal> </Popover>
3. 调整Portal挂载目标
不一定非要挂载到document.body,可以选择层级足够高且仍在Popover上下文范围内的父元素(比如黑色虚线容器的同级父元素),让HeadlessUI默认事件判断正常工作:
import { useRef, useEffect } from 'react'; const portalTargetRef = useRef(null); useEffect(() => { // 提前获取目标容器 portalTargetRef.current = document.getElementById('high-level-container'); }, []); {ReactDOM.createPortal( <Popover.Panel> {/* ...面板内容 */} </Popover.Panel>, portalTargetRef.current || document.body )}
内容的提问来源于stack exchange,提问作者Evyn
相关产品推荐
相关产品推荐

