用户(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,两个数字就能分辨。
89 lines
3.5 KiB
TypeScript
89 lines
3.5 KiB
TypeScript
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>
|
||
);
|
||
}
|