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(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 (
{/* 底层:始终挂载。打开覆盖层时用 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 都出不来。 */}
{base}
{mounted && (
{overlay}
)}
); }