返回 DeepSeek-Reasonix
useMountTransition.ts
根目录 / desktop / frontend / src / lib / useMountTransition.ts
1 import { useEffect, useRef, useState } from "react";
2 import { prefersReducedMotion } from "./motion";
3
4 // useMountTransition keeps a conditionally-rendered overlay mounted long enough
5 // to play an exit animation. Callers flip `open`; the hook returns whether the
6 // node should still be in the DOM (`mounted`) and its transition `status`,
7 // which the component maps to a `data-state` attribute so CSS can drive both
8 // the enter and the exit keyframes.
9 //
10 // open true → mounted=true, status "open" (enter animation runs)
11 // open false → mounted=true, status "closing" (exit animation runs)
12 // …after `duration` ms → mounted=false (node unmounts)
13 //
14 // The exit timer is cancelled if `open` flips back to true mid-exit, so a
15 // rapid close→open reuses the live node instead of unmounting it. Honors
16 // prefers-reduced-motion by collapsing the exit delay to ~0, matching the
17 // global reduced-motion rule that zeroes every animation.
18 export type MountStatus = "open" | "closing";
19
20 export function useMountTransition(
21 open: boolean,
22 duration: number,
23 ): { mounted: boolean; status: MountStatus } {
24 const [mounted, setMounted] = useState(open);
25 const [status, setStatus] = useState<MountStatus>(open ? "open" : "closing");
26 const timerRef = useRef<number | null>(null);
27
28 const clearTimer = () => {
29 if (timerRef.current !== null) {
30 window.clearTimeout(timerRef.current);
31 timerRef.current = null;
32 }
33 };
34
35 useEffect(() => {
36 if (open) {
37 clearTimer();
38 setMounted(true);
39 // Defer the "open" status by a frame so the node mounts in its
40 // enter-from state before the enter animation is applied.
41 const raf = requestAnimationFrame(() => setStatus("open"));
42 return () => cancelAnimationFrame(raf);
43 }
44 if (!mounted) return;
45 setStatus("closing");
46 const wait = prefersReducedMotion() ? 0 : duration;
47 clearTimer();
48 timerRef.current = window.setTimeout(() => {
49 timerRef.current = null;
50 setMounted(false);
51 }, wait);
52 return undefined;
53 }, [open, mounted, duration]);
54
55 useEffect(() => () => clearTimer(), []);
56
57 return { mounted, status };
58 }
59
60 // useDeferredClose suits overlays whose mount is owned by a parent (rendered as
61 // `{cond && <Panel onClose={...} />}`). The panel can't keep itself mounted, so
62 // instead it defers the parent's unmount: `requestClose` flips `closing` true
63 // (CSS plays the exit animation), then fires the real `onClose` after
64 // `duration`. `status` maps to data-state exactly like useMountTransition, so
65 // the same enter/exit CSS applies. Reduced-motion collapses the delay to ~0.
66 export function useDeferredClose(
67 onClose: () => void,
68 duration: number,
69 ): { status: MountStatus; requestClose: () => void } {
70 const [closing, setClosing] = useState(false);
71 const timerRef = useRef<number | null>(null);
72 const onCloseRef = useRef(onClose);
73 onCloseRef.current = onClose;
74
75 const requestClose = () => {
76 if (timerRef.current !== null) return; // already closing
77 setClosing(true);
78 const wait = prefersReducedMotion() ? 0 : duration;
79 timerRef.current = window.setTimeout(() => {
80 timerRef.current = null;
81 onCloseRef.current();
82 }, wait);
83 };
84
85 useEffect(
86 () => () => {
87 if (timerRef.current !== null) window.clearTimeout(timerRef.current);
88 },
89 [],
90 );
91
92 return { status: closing ? "closing" : "open", requestClose };
93 }
94
94 lines TYPESCRIPT