Skip to content
Source
Sign inSign up

panelAnimations.ts

69 lines2.9 KB

1import { createContext, useContext, useEffect, useState } from "react";
2import { type PanelAnimationDurationMs } from "@t3tools/contracts/settings";
3​
4import { useMediaQuery } from "./hooks/useMediaQuery";
5import { useClientSettings } from "./hooks/useSettings";
6​
7const PanelAnimationSuppressionContext = createContext(false);
8​
9export const PanelAnimationSuppressionProvider = PanelAnimationSuppressionContext.Provider;
10​
11export function usePanelNavigationSuppression(navigationKey: string): boolean {
12 const [paintedNavigationKey, setPaintedNavigationKey] = useState<string | null>(null);
13 const suppressed = paintedNavigationKey !== navigationKey;
14​
15 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Wait two browser frames so the new route paints before panel animations are re-enabled; cancel the frames on change or unmount.
16 useEffect(() => {
17 if (!suppressed) return;
18 let releaseFrame = 0;
19 const paintFrame = window.requestAnimationFrame(() => {
20 releaseFrame = window.requestAnimationFrame(() => setPaintedNavigationKey(navigationKey));
21 });
22 return () => {
23 window.cancelAnimationFrame(paintFrame);
24 window.cancelAnimationFrame(releaseFrame);
25 };
26 }, [navigationKey, suppressed]);
27​
28 return suppressed;
30​
31export function usePanelAnimationSettings(): {
32 active: boolean;
33 durationMs: PanelAnimationDurationMs;
34} {
35 const durationMs = useClientSettings((settings) => settings.panelAnimationDurationMs);
36 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
37 const suppressed = useContext(PanelAnimationSuppressionContext);
38 return { active: durationMs > 0 && !prefersReducedMotion && !suppressed, durationMs };
40​
41export function usePanelPresence<T>(
42 open: boolean,
43 value: T | null,
44 animated: boolean,
45 scopeKey: string | null,
46 durationMs: PanelAnimationDurationMs,
47): { present: boolean; value: T | null } {
48 const [present, setPresent] = useState(open);
49 const [retained, setRetained] = useState<{ scopeKey: string | null; value: T | null } | null>(
50 open ? { scopeKey, value } : null,
51 );
52 if (open && (retained?.scopeKey !== scopeKey || retained.value !== value)) {
53 setRetained({ scopeKey, value });
54 }
55​
56 if (open && !present) setPresent(true);
57 if (!open && !animated && present) setPresent(false);
58​
59 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Keep the closing panel mounted for its CSS exit transition, then unmount it; the timer is cleared on reopen or unmount.
60 useEffect(() => {
61 if (open || !animated) return;
62 const timeout = window.setTimeout(() => setPresent(false), durationMs);
63 return () => window.clearTimeout(timeout);
64 }, [animated, durationMs, open]);
65​
66 const retainedValue = retained?.scopeKey === scopeKey ? retained.value : null;
67 const visible = open || (animated && present && retained?.scopeKey === scopeKey);
68 return { present: visible, value: open ? value : visible ? retainedValue : null };