From fd1e995e1e0df092c426ebe7abd1d3220a135ba7 Mon Sep 17 00:00:00 2001 From: MMarhoon Date: Tue, 20 Oct 2026 16:39:12 +0300 Subject: [PATCH 1/4] fix: improve mobile navigation dock --- resources/js/components/app-nav.tsx | 399 +++++++++++++++++++++------- 1 file changed, 298 insertions(+), 101 deletions(-) diff --git a/resources/js/components/app-nav.tsx b/resources/js/components/app-nav.tsx index acc5ca964..29a504b73 100644 --- a/resources/js/components/app-nav.tsx +++ b/resources/js/components/app-nav.tsx @@ -66,6 +66,9 @@ import type { Log, NavItem, Profile } from '@/types'; const navLabelClassName = 'max-w-0 overflow-hidden opacity-0 transition-[max-width,opacity] duration-300 ease-[cubic-bezier(0.33,1,0.68,1)] nav-wide:max-w-40 nav-wide:opacity-100 motion-reduce:transition-none'; +const mobileNavMotionClassName = + 'transition-transform duration-350 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none'; + /** * Guests see the same items, each taking them to sign in. */ @@ -525,19 +528,23 @@ export function MobileCommitButton({ return ( ); } @@ -566,12 +573,77 @@ export function MobileNav({ children }: { children?: ReactNode }) { item.title !== 'Starred' && !menuItems.some((menuItem) => menuItem.title === item.title), ); + const isMenuActive = menuItems.some((item) => item.isActive); + const itemCount = items.length + 1 + (children ? 1 : 0); const nav = useRef(null); + const dock = useRef(null); + const [isCompact, setIsCompact] = useState(false); + + useEffect(() => { + const mobile = window.matchMedia('(max-width: 767px)'); + const root = document.documentElement; + let compact = false; + let frame = 0; + let previousY = 0; + let direction = 0; + let directionStart = 0; + + const update = () => { + frame = 0; + + if (root.hasAttribute('data-keyboard-open')) { + return; + } + + const y = Math.max( + 0, + Math.min(window.scrollY, root.scrollHeight - window.innerHeight), + ); + const nextDirection = Math.sign(y - previousY); + + if (nextDirection !== 0 && nextDirection !== direction) { + directionStart = previousY; + direction = nextDirection; + } + + previousY = y; + let next = compact; + + if (!mobile.matches || y <= 24) { + next = false; + } else if (direction > 0 && y > 80 && y - directionStart >= 32) { + next = true; + } else if (direction < 0 && directionStart - y >= 24) { + next = false; + } + + if (next !== compact) { + compact = next; + setIsCompact(next); + } + }; + const onScroll = () => { + if (frame === 0) { + frame = window.requestAnimationFrame(update); + } + }; + + update(); + window.addEventListener('scroll', onScroll, { passive: true }); + mobile.addEventListener('change', onScroll); + + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener('scroll', onScroll); + mobile.removeEventListener('change', onScroll); + }; + }, []); useLayoutEffect(() => { const element = nav.current; + const surface = dock.current; - if (element === null) { + if (element === null || surface === null) { return; } @@ -582,6 +654,25 @@ export function MobileNav({ children }: { children?: ReactNode }) { if (height > 0) { root.style.setProperty('--mobile-nav-height', `${height}px`); + const width = surface.getBoundingClientRect().width; + + if (width > 0) { + const compactWidth = Math.min( + width, + Math.max( + itemCount * 44 + 18, + Math.min(width - 24, 296), + ), + ); + surface.style.setProperty( + '--dock-scale', + `${compactWidth / width}`, + ); + surface.style.setProperty( + '--dock-inset', + `${(width - compactWidth) / 2}px`, + ); + } } }; const observer = new ResizeObserver(updateHeight); @@ -593,127 +684,233 @@ export function MobileNav({ children }: { children?: ReactNode }) { observer.disconnect(); root.style.removeProperty('--mobile-nav-height'); }; - }, []); + }, [itemCount]); + + const itemPosition = (index: number, offset = 0) => ({ + transform: isCompact + ? `translate(calc(var(--dock-inset, 0px) * ${(itemCount - 1 - 2 * index) / itemCount} + ${offset}px), 2px)` + : `translate(${offset}px, 0px)`, + }); // The lite mobile app draws the tabs itself. The nav keeps the height of its tab bar, which the app adds to the safe // area, so the commit button floats above the bar and full-screen pages end right above it. if (isLiteMobileApp) { - return ( - - ); - } + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener('scroll', onScroll); + mobile.removeEventListener('change', onScroll); + }; + }, []); + + useLayoutEffect(() => { + const element = nav.current; + const surface = dock.current; + + if (element === null || surface === null) { + return; + } + + const root = document.documentElement; + // Hidden while the keyboard is open, it keeps its height, so toasts above it do not drop onto it on the way back. + const observer = new ResizeObserver(() => { + const { height } = element.getBoundingClientRect(); + + if (height > 0) { + root.style.setProperty('--mobile-nav-height', `${height}px`); + + const width = surface.getBoundingClientRect().width; + + if (width > 0) { + // Leave room for 44px targets, including compose, on narrow screens. + const compactWidth = Math.min( + width, + Math.max( + itemCount * 44 + 18, + Math.min(width - 24, 296), + ), + ); + surface.style.setProperty( + '--dock-scale', + `${compactWidth / width}`, + ); + surface.style.setProperty( + '--dock-inset', + `${(width - compactWidth) / 2}px`, + ); + } + } else if (window.matchMedia('(min-width: 48rem)').matches) { + root.style.removeProperty('--mobile-nav-height'); + } + }); + + observer.observe(element, { box: 'border-box' }); + + return () => { + observer.disconnect(); + root.style.removeProperty('--mobile-nav-height'); + }; + }, [itemCount]); + + // Only the surface scales. Fixed-size controls translate between evenly spaced slots. + const itemPosition = (index: number, offset = 0) => ({ + transform: isCompact + ? `translate(calc(var(--dock-inset, 0px) * ${(itemCount - 1 - 2 * index) / itemCount} + ${offset}px), 2px)` + : `translate(${offset}px, 0px)`, + }); return (