fix(ui): 抽屉/对话框内 Select 等弹层不再误关抽屉

抽屉内 Radix 弹层(Select 下拉等)portal 到抽屉外,开着弹层时点遮罩,同一次
pointerdown 被 Select 与 Sheet 两个 DismissableLayer 处理:Select 先关闭且是
discrete 事件、React 同步 flush,轮到 Sheet 判断时弹层 data-state 已翻成 closed,
"当下检测弹层是否打开"不可靠,导致抽屉被连带关闭。

改为在 pointerdown 的 capture 阶段(早于 Radix 冒泡监听)先记录"此刻有无弹层开着",
onInteractOutside 据此:开着弹层时点遮罩→只收弹层不关抽屉;无弹层时点遮罩→正常关闭。
下沉到 sheet.tsx/dialog.tsx 基础组件,一次覆盖所有含下拉的抽屉/对话框;移除 agents
页此前重复的 per-callsite 处理。已用无头浏览器验证三种场景(点选项/点遮罩收下拉/
无弹层点遮罩关闭)均正确。

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-08-09 03:17:49 -04:00
1 parent f86885ec3d
commit aab6b9c948
4 files changed
+68 -11

No files matched your search

@@ -258,15 +258,6 @@ export default function AgentsPage() {
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw]"
// 抽屉内的下拉/弹出层(Select 等)会 portal 到抽屉外;交互源自这些弹出层时
// 不要把它当成「点击外部」而关闭抽屉。注意:Radix 的 onInteractOutside 里
// e.target 是层节点本身,真正被点的元素在 e.detail.originalEvent.target。
onInteractOutside={(e) => {
const t = e.detail.originalEvent.target as Element | null;
if (t?.closest?.("[data-radix-popper-content-wrapper],[data-radix-select-content],[role='listbox']")) {
e.preventDefault();
}
}}
>
{editing && (
<>
+14 -1
View File
@@ -3,7 +3,7 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { cn, radixOverlayWasOpenAtPointerDown } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
@@ -51,6 +51,7 @@ function DialogContent({
className,
children,
showCloseButton = true,
onInteractOutside,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
@@ -60,6 +61,18 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
// 关闭对话框的唯一条件:点击的是遮罩(灰色背景)本身,且此刻没有任何 Radix 弹层
// (Select 下拉等)开着。其余"外部交互"一律挡掉(Esc、右上角 ✕ 仍可关):
// · 点弹层里的选项 → target 不是遮罩 → 挡;
// · 弹层开着时点对话框外/遮罩想收起它 → 有弹层开着 → 挡(只收弹层,不关对话框);
// · 弹层收起时焦点移动被 Radix 误判为焦点移出 → target 不是遮罩 → 挡。
// (onInteractOutside 在指针/焦点两条路径都会触发。)调用方仍可追加逻辑。
onInteractOutside={(e) => {
const target = e.detail.originalEvent.target as Element | null
const onOverlay = !!target?.closest?.("[data-slot='dialog-overlay']")
if (!onOverlay || radixOverlayWasOpenAtPointerDown()) e.preventDefault()
onInteractOutside?.(e)
}}
className={cn(
"fixed top-1/2 left-1/2 z-50 grid max-h-[90dvh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
+14 -1
View File
@@ -3,7 +3,7 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { cn, radixOverlayWasOpenAtPointerDown } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
@@ -50,6 +50,7 @@ function SheetContent({
children,
side = "right",
showCloseButton = true,
onInteractOutside,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
@@ -61,6 +62,18 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
// 关闭抽屉的唯一条件:点击的是遮罩(灰色背景)本身,且此刻没有任何 Radix 弹层
// (Select 下拉等)开着。其余"外部交互"一律挡掉(Esc、右上角 ✕ 仍可关):
// · 点弹层里的选项 → target 不是遮罩 → 挡;
// · 弹层开着时点抽屉外/遮罩想收起它 → 有弹层开着 → 挡(只收弹层,不关抽屉);
// · 弹层收起时焦点移动被 Radix 误判为焦点移出 → target 不是遮罩 → 挡。
// (onInteractOutside 在指针/焦点两条路径都会触发。)调用方仍可追加逻辑。
onInteractOutside={(e) => {
const target = e.detail.originalEvent.target as Element | null
const onOverlay = !!target?.closest?.("[data-slot='sheet-overlay']")
if (!onOverlay || radixOverlayWasOpenAtPointerDown()) e.preventDefault()
onInteractOutside?.(e)
}}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
+40
View File
@@ -5,6 +5,46 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// 抽屉/对话框(Sheet/Dialog)的 onInteractOutside 关闭判定辅助。
//
// 背景:抽屉内的 Radix 弹层(Select 下拉、DropdownMenu、Popover 等)会 portal 到抽屉
// 之外。开着弹层时点遮罩/抽屉外想收起它,这一次 pointerdown 会被 Select 和 Sheet 两个
// DismissableLayer 同时处理;Select 先关闭且是 discrete 事件、React 会同步 flush,于是
// 轮到 Sheet 的处理器时弹层的 data-state 早已翻成 closed —— 在"当下"检测弹层是否打开
// 天然不可靠(实测已验证)。
//
// 正确做法:Radix 的 pointerdown 监听在冒泡阶段;我们在 capture 阶段(早于它)先把
// "此刻有没有弹层开着"记录下来,onInteractOutside 再读这个记录值来决定是否放行关闭。
function isRadixOverlayOpenNow(): boolean {
if (typeof document === "undefined") return false;
return !!document.querySelector(
[
"[data-slot='select-trigger'][data-state='open']",
"[data-slot='select-content'][data-state='open']",
"[role='listbox'][data-state='open']",
"[data-radix-popper-content-wrapper]",
"[aria-expanded='true'][data-state='open']",
].join(","),
);
}
let overlayOpenAtLastPointerDown = false;
if (typeof document !== "undefined") {
document.addEventListener(
"pointerdown",
() => {
overlayOpenAtLastPointerDown = isRadixOverlayOpenNow();
},
true, // capture:抢在 Radix 冒泡阶段的 pointerdown 处理器之前记录
);
}
// radixOverlayWasOpenAtPointerDown 返回"最近一次 pointerdown 发生时是否有 Radix 弹层
// 开着"。抽屉/对话框据此:开着弹层时点遮罩 → 只收弹层、不关自身。
export function radixOverlayWasOpenAtPointerDown(): boolean {
return overlayOpenAtLastPointerDown;
}
export const getInitials = (str: string): string => {
if (typeof str !== "string" || !str.trim()) return "?";