Files
invyone/frontend/lib/navOrientationTransition.ts
T
gbpark 5153386fce
Build & Deploy to K8s / build-and-deploy (push) Successful in 3m59s
디자인 수정
2026-04-21 22:59:51 +09:00

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);
}