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