From aab6b9c948b97f97b5962e025f8288bfef71ba23 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:17:49 -0400 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E6=8A=BD=E5=B1=89/=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=A1=86=E5=86=85=20Select=20=E7=AD=89=E5=BC=B9?= =?UTF-8?q?=E5=B1=82=E4=B8=8D=E5=86=8D=E8=AF=AF=E5=85=B3=E6=8A=BD=E5=B1=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽屉内 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 --- web/src/app/(main)/system/agents/page.tsx | 9 ----- web/src/components/ui/dialog.tsx | 15 ++++++++- web/src/components/ui/sheet.tsx | 15 ++++++++- web/src/lib/utils.ts | 40 +++++++++++++++++++++++ 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/web/src/app/(main)/system/agents/page.tsx b/web/src/app/(main)/system/agents/page.tsx index 7add813..683c6ca 100644 --- a/web/src/app/(main)/system/agents/page.tsx +++ b/web/src/app/(main)/system/agents/page.tsx @@ -258,15 +258,6 @@ export default function AgentsPage() { { - 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 && ( <> diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 230ffd5..af8e78d 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -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 & { showCloseButton?: boolean @@ -60,6 +61,18 @@ function DialogContent({ { + 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 diff --git a/web/src/components/ui/sheet.tsx b/web/src/components/ui/sheet.tsx index 8d0e1d3..ce2b56a 100644 --- a/web/src/components/ui/sheet.tsx +++ b/web/src/components/ui/sheet.tsx @@ -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 & { side?: "top" | "right" | "bottom" | "left" @@ -61,6 +62,18 @@ function SheetContent({ { + 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 diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index dda1f92..eb6d2c9 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -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 "?";