diff --git a/resources/js/components/app-nav.tsx b/resources/js/components/app-nav.tsx index acc5ca964..360c0df2a 100644 --- a/resources/js/components/app-nav.tsx +++ b/resources/js/components/app-nav.tsx @@ -14,6 +14,7 @@ import { SquarePen, UserRound, } from 'lucide-react'; +import { Children, isValidElement } from 'react'; import type { ReactElement, ReactNode } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import ConversationController from '@/actions/App/Http/Controllers/ConversationController'; @@ -66,6 +67,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. */ @@ -236,6 +240,9 @@ function NavTooltip({ ); } +/** + * Renders the GitHub sign-in action for the sidebar. + */ function SignInButton({ className }: { className?: string }) { return ( ); } @@ -566,10 +593,87 @@ export function MobileNav({ children }: { children?: ReactNode }) { item.title !== 'Starred' && !menuItems.some((menuItem) => menuItem.title === item.title), ); + const renderedChildren = Children.toArray(children); + const dockChild = renderedChildren.find( + (child) => isValidElement(child) && child.type === MobileCommitButton, + ); + const auxiliaryChildren = renderedChildren.filter( + (child) => child !== dockChild, + ); + const hasDockChild = dockChild !== undefined; + const isMenuActive = menuItems.some((item) => item.isActive); + const itemCount = items.length + 1 + (hasDockChild ? 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; + } + + // Clamp elastic overscroll so iOS bounce does not reverse the dock. + 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) { return; @@ -577,23 +681,59 @@ export function MobileNav({ children }: { children?: ReactNode }) { 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 updateHeight = () => { + const observer = new ResizeObserver(() => { const { height } = element.getBoundingClientRect(); if (height > 0) { root.style.setProperty('--mobile-nav-height', `${height}px`); + + if (surface !== null) { + const width = surface.getBoundingClientRect().width; + + if (width <= 0) { + return; + } + + // 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'); } - }; - const observer = new ResizeObserver(updateHeight); + }); - updateHeight(); 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. + /** + * Positions a dock control before or after the dock contracts. + */ + 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. @@ -604,116 +744,169 @@ export function MobileNav({ children }: { children?: ReactNode }) { aria-label="Main" className="pointer-events-none fixed inset-x-0 bottom-0 z-20 pb-[env(safe-area-inset-bottom)] *:pointer-events-auto md:hidden [:root[data-keyboard-open]_&]:hidden" > - {children} + {renderedChildren} ); } - return (