86 lines
3.0 KiB
TypeScript
86 lines
3.0 KiB
TypeScript
/**
|
|
* 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<HTMLElement>("aside.v5-side");
|
|
const oldTopnav = document.querySelector<HTMLElement>(".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<HTMLElement>("*").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);
|
|
}
|