diff --git a/src/app/router.tsx b/src/app/router.tsx index 2ec5b70..5eaced4 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -4,7 +4,6 @@ import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query import { routeTree } from "./routeTree.gen"; import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary"; import RoutePending from "../components/route-pending"; -import { headerChange, navDirection } from "@/features/core/utils/nav-direction"; import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config"; export function getRouter() { @@ -34,12 +33,6 @@ export function getRouter() { defaultPreloadStaleTime: 60_000, defaultPendingMs: 100, defaultPendingMinMs: 0, - defaultViewTransition: { - types: ({ fromLocation, toLocation }) => { - const direction = navDirection(fromLocation, toLocation); - return direction === "none" ? false : [direction]; - }, - }, defaultPendingComponent: RoutePending, defaultErrorComponent: DefaultCatchBoundary, }); @@ -49,13 +42,6 @@ export function getRouter() { queryClient }) - if (typeof document !== "undefined") { - router.subscribe("onBeforeNavigate", ({ fromLocation, toLocation }) => { - document.documentElement.dataset.navDirection = navDirection(fromLocation, toLocation); - document.documentElement.dataset.headerVt = headerChange(fromLocation, toLocation); - }); - } - return router; } diff --git a/src/app/styles.css b/src/app/styles.css index f67483e..b032053 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -50,120 +50,13 @@ textarea, transform: scale(0.90) translateY(-4px); } -/* keep the page tappable mid-transition (rapid tab switching) */ -::view-transition { - pointer-events: none; -} - -/* theme overlays must be this element, not body::before, or they blink off - during transitions */ +/* themes paint their full-page texture through this element */ .flxn-theme-overlay { position: fixed; pointer-events: none; - view-transition-name: theme-overlay; -} - -/* pinned states, not 0ms durations: Safari skips zero-duration VT animations - and blends both snapshots */ -::view-transition-old(root), -::view-transition-old(app-header), -::view-transition-old(theme-overlay) { - animation: none; - opacity: 0; -} -::view-transition-new(root), -::view-transition-new(app-header), -::view-transition-new(theme-overlay) { - animation: none; - opacity: 1; - mix-blend-mode: normal; -} -::view-transition-image-pair(root), -::view-transition-image-pair(app-header), -::view-transition-image-pair(theme-overlay), -::view-transition-image-pair(page) { - isolation: auto; -} - -::view-transition-old(page), -::view-transition-new(page) { - animation-duration: 180ms; - animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); - mix-blend-mode: normal; -} - -html[data-nav-direction="forward"]::view-transition-old(page) { - animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-left; -} -html[data-nav-direction="forward"]::view-transition-new(page) { - animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-right; -} -html[data-nav-direction="back"]::view-transition-old(page) { - animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-right; -} -html[data-nav-direction="back"]::view-transition-new(page) { - animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left; -} - -html[data-nav-direction="forward"][data-header-vt="exit"]::view-transition-old(app-header) { - animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-left; - opacity: 1; -} -html[data-nav-direction="back"][data-header-vt="exit"]::view-transition-old(app-header) { - animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-right; - opacity: 1; -} -html[data-nav-direction="forward"][data-header-vt="enter"]::view-transition-new(app-header) { - animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-right; -} -html[data-nav-direction="back"][data-header-vt="enter"]::view-transition-new(app-header) { - animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left; -} - -@keyframes flxn-page-out-left { - to { transform: translateX(-100%); } -} -@keyframes flxn-page-in-right { - from { transform: translateX(100%); } -} -@keyframes flxn-page-out-right { - to { transform: translateX(100%); } -} -@keyframes flxn-page-in-left { - from { transform: translateX(-100%); } -} - -@keyframes flxn-vt-fade-out { - to { opacity: 0; } -} -@keyframes flxn-vt-fade-in { - from { opacity: 0; } } @media (prefers-reduced-motion: reduce) { - ::view-transition-group(*), - ::view-transition-image-pair(*) { - animation: none !important; - } - - ::view-transition-old(*) { - animation: 120ms ease both flxn-vt-fade-out !important; - z-index: auto !important; - } - - ::view-transition-new(*) { - animation: 120ms ease both flxn-vt-fade-in !important; - } - - ::view-transition-old(root), - ::view-transition-old(app-header), - ::view-transition-old(theme-overlay), - ::view-transition-new(root), - ::view-transition-new(app-header), - ::view-transition-new(theme-overlay) { - animation: none !important; - } - .app, [data-drawer-level] { transition: none; diff --git a/src/features/core/components/header.tsx b/src/features/core/components/header.tsx index e5c3e47..5a62822 100644 --- a/src/features/core/components/header.tsx +++ b/src/features/core/components/header.tsx @@ -60,7 +60,6 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) alignItems: 'center', justifyContent: 'center', paddingTop: 'env(safe-area-inset-top, 0px)', - viewTransitionName: 'app-header', }} > { withBackButton && } diff --git a/src/features/core/components/layout.tsx b/src/features/core/components/layout.tsx index b752d18..478e3fb 100644 --- a/src/features/core/components/layout.tsx +++ b/src/features/core/components/layout.tsx @@ -1,4 +1,4 @@ -import { AppShell, Box } from '@mantine/core'; +import { AppShell } from '@mantine/core'; import { PropsWithChildren } from 'react'; import Header from './header'; import Navbar from './navbar'; @@ -15,7 +15,7 @@ const Layout: React.FC = ({ children }) => { = ({ children }) => { maw='100dvw' style={{ overflow: 'hidden' }} > - {/* pane box must be route-invariant or slides drift */} - - - - {children} - - - + + + {children} + + diff --git a/src/features/core/components/nav-indicator/use-nav-indicator.ts b/src/features/core/components/nav-indicator/use-nav-indicator.ts new file mode 100644 index 0000000..b4a8e0b --- /dev/null +++ b/src/features/core/components/nav-indicator/use-nav-indicator.ts @@ -0,0 +1,68 @@ +import { useLayoutEffect, useRef } from "react"; +import { useRouterState } from "@tanstack/react-router"; + +const TRAVEL_MS = 333; + +export const useNavIndicator = (isMobile: boolean) => { + const containerRef = useRef(null); + const indicatorRef = useRef(null); + const shapeRef = useRef(null); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + useLayoutEffect(() => { + const container = containerRef.current; + const indicator = indicatorRef.current; + const shape = shapeRef.current; + if (!container || !indicator || !shape) return; + + let last: number | null = null; + + const place = () => { + const active = container.querySelector("[data-active]"); + if (!active) { + delete indicator.dataset.placed; + last = null; + return; + } + + const x = active.offsetLeft; + const y = active.offsetTop; + const travelled = isMobile ? x : y; + const first = indicator.dataset.placed !== "1"; + + if (first) indicator.style.transition = "none"; + indicator.style.setProperty("--nav-x", `${x}px`); + indicator.style.setProperty("--nav-y", `${y}px`); + indicator.style.setProperty("--nav-w", `${active.offsetWidth}px`); + indicator.style.setProperty("--nav-h", `${active.offsetHeight}px`); + indicator.dataset.placed = "1"; + + if (first) { + indicator.getBoundingClientRect(); + indicator.style.transition = ""; + } else if ( + last !== null && + Math.abs(travelled - last) > 1 && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + const axis = isMobile ? "scaleX" : "scaleY"; + shape.animate( + [ + { transform: `${axis}(1)`, offset: 0 }, + { transform: `${axis}(0.88)`, offset: 0.4 }, + { transform: `${axis}(1)`, offset: 1 }, + ], + { duration: TRAVEL_MS, easing: "ease-in-out" } + ); + } + last = travelled; + }; + + place(); + const observer = new ResizeObserver(place); + observer.observe(container); + return () => observer.disconnect(); + }, [pathname, isMobile]); + + return { containerRef, indicatorRef, shapeRef }; +}; diff --git a/src/features/core/components/nav-link/nav-link.tsx b/src/features/core/components/nav-link/nav-link.tsx index 2a0c38b..f706cbb 100644 --- a/src/features/core/components/nav-link/nav-link.tsx +++ b/src/features/core/components/nav-link/nav-link.tsx @@ -32,30 +32,19 @@ export const NavLink = ({ - - + + {label} diff --git a/src/features/core/components/navbar.tsx b/src/features/core/components/navbar.tsx index 21f297d..e47ae1a 100644 --- a/src/features/core/components/navbar.tsx +++ b/src/features/core/components/navbar.tsx @@ -5,11 +5,13 @@ import { useAuth } from "@/contexts/auth-context"; import { useLinks } from "../hooks/use-links"; import { memo } from "react"; import { useIsPWA } from "@/hooks/use-is-pwa"; +import { useNavIndicator } from "./nav-indicator/use-nav-indicator"; const Navbar = () => { const { user, roles } = useAuth() const isMobile = useIsMobile(); const isPWA = useIsPWA(); + const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile); const links = useLinks(user?.id, roles); @@ -17,9 +19,16 @@ const Navbar = () => { ? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)' : 'env(safe-area-inset-bottom, 0px)'; + const indicator = ( +