chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
86
client/electron/src/components/NarrowStack.tsx
Normal file
86
client/electron/src/components/NarrowStack.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
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 (
|
||||
<div className="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 border-l border-gray-200 shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
|
||||
entered ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{overlay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user