Files
MailUI4Agents/client/electron/src/components/NarrowStack.tsx
JianFeeeee 6702cc2f5e fix(webui): 窄屏日历/详情的圆角——圆角要给到「能裁剪的那一层」
用户(2026-09-14):「我这边看还是方角」。上一轮我只修了宽屏那条分支,
窄屏(手机/平板,<1024px)当时仍然是方角 —— 这次按像素量到了。

## 实测(真浏览器读渲染像素,不是读声明)

390x844 日历页左上角:
  改前 (255,255,255) 纯白 → 方角;面板 computed radius = 0px
  改后 (249,250,251) 页面底色 → 圆角;上边缘内缩 8px

## 根因:圆角落在**裁不住东西**的那层上

窄屏走 NarrowStack(`list`+`main` 的覆盖式容器,日历与收件箱详情都用它):

    .narrow-shell > *          ← 14px 加在这里(App.tsx 的 flex-1 min-h-0 flex)
      NarrowStack 根            ← overflow:hidden,但 radius 0px
        absolute inset-0 flex   ← 透明
          gridPane (bg-white)   ← 0px,直角原样露出来

`overflow: visible` 的透明 flex 容器拿到圆角 = 什么也没发生。
NarrowStack 的根节点是**唯一**同时具备「自己 overflow:hidden」与「包住有底色那一层」
的层,圆角必须加在它上面 —— 加给它,底层页与滑入的覆盖层就一起被裁到圆角里。

顺带修好一处同类问题:**收件箱详情在窄屏也是方角**(同一个容器),
以前没人报过,这次一起圆了(实测同上)。

## 判据

narrow-layout 新增 2 条,分开钉两件事(分开一条都不算修好):
  ① 那层自己带上面板圆角(类名与 CSS 规则接得上);
  ② 那一层同时具备裁剪能力(overflow:hidden)。
三组变异全部红在对的那条上(去掉类名 / 去掉 border-radius / 去掉 overflow-hidden)。

narrow-verify(手动探针)新增**读像素**的能力与两条判据 —— 这件事只有像素能回答:
computed radius 是 14px、角上 2px 处"谁在画"也指着面板本身,**但渲染出来仍是方的**
(圆角加在没底色的层上,被内层直角原地盖掉)。所以这里截 1x1 再解 PNG,
只用 zlib,不引图像库。方角=255,255,255 / 圆角=249,250,251,两个数字就能分辨。
2026-09-14 16:51:31 +08:00

89 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
/**
* 窄屏下的「页面覆盖」容器。
*
* 与分栏的区别:底层页面(列表)始终挂载,详情页从右侧滑入**盖在它上面**。
* 这样做的两个实际好处:
* - 列表的滚动位置与选中态天然保留 —— 它没被卸载
* - 退出动画有东西可播:如果直接卸载再渲染另一个组件,没有任何一帧
* 能让旧页面往右滑出去
*
* 因此这里必须区分「逻辑上是否打开」(open)与「是否还在 DOM 里」(mounted):
* 关闭时先播 200ms 滑出动画,动画结束才卸载。
*/
export default function NarrowStack({
base,
overlay,
open
}: {
base: React.ReactNode;
overlay: React.ReactNode;
open: boolean;
}) {
// mounted:是否在 DOM 里。entered:是否已滑到位(用于触发 transition)
const [mounted, setMounted] = useState(open);
const [entered, setEntered] = useState(open);
const timer = useRef<number | null>(null);
useEffect(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
if (open) {
setMounted(true);
// 必须等浏览器至少绘制一帧「在右侧之外」的状态,否则从挂载到
// translate-x-0 是同一帧内完成的,transition 不会触发。
// 两层 rAF 是跨浏览器最稳的写法(单层在 Safari 上偶尔仍被合帧)。
const raf = requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
return () => cancelAnimationFrame(raf);
}
setEntered(false);
// 与下面的 duration-200 保持一致;提前卸载会把动画切掉半截
timer.current = window.setTimeout(() => {
setMounted(false);
timer.current = null;
}, 200);
return () => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
};
}, [open]);
return (
/* `narrow-stack`:这一层才是「能裁剪 + 包住有底色的那一层」,
圆角加在这里底层与覆盖层才会一起被裁(详见 index.css)*/
<div className="narrow-stack flex-1 min-h-0 relative overflow-hidden">
{/* 底层:始终挂载。打开覆盖层时用 aria-hidden 把它从无障碍树里摘掉,
否则屏幕阅读器会读到两层内容。
`isolate`(isolation: isolate)是必需的:它让底层**自成一个层叠上下文**。
不加的后果在日历上实测到过:月视图的星期表头是 `sticky top-0 z-10`,
而覆盖层没有 z-index(= auto = 0)—— 两者在同一个层叠上下文里比,
`z-10` 赢过 `auto`,于是底层的表头穿透到二级页面之上,把日程内容遮住一条。
为何不只给覆盖层加 z-10 就完事:那只能治当下这一处。底层是任意业务组件,
下一个人在里面写个 `z-20` 就又复现,而这类 bug 只能肉眼看见。
isolate 把边界定在容器上,底层写多少 z-index 都出不来。 */}
<div className="absolute inset-0 flex isolate" aria-hidden={open ? 'true' : undefined}>
{base}
</div>
{mounted && (
<div
className={`absolute inset-0 z-10 flex bg-white shadow-3 transition-transform duration-200 ease-out motion-reduce:transition-none ${
entered ? 'translate-x-0' : 'translate-x-full'
}`}
>
{overlay}
</div>
)}
</div>
);
}