/** * Nav orientation transition — ported from INVYONE Design System * (`ui_kits/app/app.jsx` → animatedNavOrientationChange). * * Clones the outgoing nav (sidebar OR topnav) as a ghost fixed over its * original rect, slides it along its own axis + fades, then applies the * state change. The new nav fades in via its own CSS enter animation. * * No 3D, no fold — just clean motion matching the axis change. */ export type NavOrientation = "vertical" | "horizontal"; export function animatedNavOrientationChange( next: NavOrientation, apply: (next: NavOrientation) => void, ): void { const root = document.documentElement; root.setAttribute("data-nav-anim", next); // Grab the current outgoing nav. invyone uses .v5-side / .v5-topnav. const oldAside = document.querySelector("aside.v5-side"); const oldTopnav = document.querySelector(".v5-topnav"); const oldNav = oldAside || oldTopnav; if (oldNav) { const rect = oldNav.getBoundingClientRect(); const ghost = oldNav.cloneNode(true) as HTMLElement; ghost.classList.add("v5-nav-exit-ghost"); const fromOrient: NavOrientation = next === "horizontal" ? "vertical" : "horizontal"; Object.assign(ghost.style, { position: "fixed", left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px`, margin: "0", zIndex: "60", pointerEvents: "none", willChange: "transform, opacity", }); // Kill child animations on the ghost so it stays visually static while sliding. ghost.querySelectorAll("*").forEach((el) => { el.style.animation = "none"; el.style.transition = "none"; }); document.body.appendChild(ghost); // Drive via setInterval because React.flushSync in the same tick can swallow rAF. const DURATION = 320; const startTs = performance.now(); // sidebar (vertical) slides left; topnav (horizontal) slides up. const deltaAxis: "X" | "Y" = fromOrient === "vertical" ? "X" : "Y"; const deltaMax = fromOrient === "vertical" ? -28 : -14; const ease = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(1 - t, 2.2)); const iv = setInterval(() => { if (!ghost.isConnected) { clearInterval(iv); return; } const t = Math.min(1, (performance.now() - startTs) / DURATION); const e = ease(t); ghost.style.transform = `translate${deltaAxis}(${deltaMax * e}px)`; ghost.style.opacity = String(1 - e); if (t >= 1) { clearInterval(iv); ghost.remove(); } }, 16); // Safety net — if the ghost hangs around, nuke it. setTimeout(() => { clearInterval(iv); if (ghost.isConnected) ghost.remove(); }, 900); } // Apply the state change. New nav mounts and plays its own enter animation. apply(next); // Clean up the root attribute after the "enter" window. setTimeout(() => { if (root.getAttribute("data-nav-anim") === next) { root.removeAttribute("data-nav-anim"); } }, 520); }