Files
ARTEX/web/src/lib/utils.ts
T
Autumn-27andClaude Opus 4.8 aab6b9c948 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]>
2026-08-09 03:17:49 -04:00

83 lines
2.8 KiB
TypeScript

import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
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 "?";
return (
str
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) => word[0])
.join("")
.toUpperCase() || "?"
);
};
export function formatCurrency(
amount: number,
opts?: {
currency?: string;
locale?: string;
minimumFractionDigits?: number;
maximumFractionDigits?: number;
noDecimals?: boolean;
},
) {
const { currency = "USD", locale = "en-US", minimumFractionDigits, maximumFractionDigits, noDecimals } = opts ?? {};
const formatOptions: Intl.NumberFormatOptions = {
style: "currency",
currency,
minimumFractionDigits: noDecimals ? 0 : minimumFractionDigits,
maximumFractionDigits: noDecimals ? 0 : maximumFractionDigits,
};
return new Intl.NumberFormat(locale, formatOptions).format(amount);
}