diff --git a/apps/docs/components/layout/mobile-sidebar.tsx b/apps/docs/components/layout/mobile-sidebar.tsx index e0a4f031..ea308aa9 100644 --- a/apps/docs/components/layout/mobile-sidebar.tsx +++ b/apps/docs/components/layout/mobile-sidebar.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { PanelLeftClose, ChevronDown } from 'lucide-react'; -import { usePathname } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { SidebarNav } from './sidebar-nav'; import type { NavigationSection } from '@/lib/openapi/types'; @@ -19,8 +19,16 @@ export function MobileSidebar({ navigationByTab }: MobileSidebarProps) { const [hasOpened, setHasOpened] = useState(false); const [mounted, setMounted] = useState(false); const pathname = usePathname(); + const router = useRouter(); const activeTab = useActiveTab(); const [selectedTab, setSelectedTab] = useState(activeTab); + // Landing page of a section the user just picked, while its navigation is + // still in flight — highlighted right away so the item doesn't light up + // late, once the route commits. + const [pendingHref, setPendingHref] = useState(null); + // Set when the panel itself triggers navigation (tab switch), so the + // route change doesn't close the panel like an outside navigation would. + const keepOpenRef = useRef(false); // Radix DropdownMenu generates random ids via useId, which differ between // SSR and the first client render and trigger a hydration warning. Render @@ -53,16 +61,46 @@ export function MobileSidebar({ navigationByTab }: MobileSidebarProps) { return () => window.removeEventListener('toggle-mobile-menu', handler); }, [activeTab]); - // Close on route change + // Close on route change — except when the panel navigated on its own useEffect(() => { + setPendingHref(null); + if (keepOpenRef.current) { + keepOpenRef.current = false; + return; + } setIsOpen(false); }, [pathname]); // Lock body scroll when open (shared with the search modal) useBodyScrollLock(isOpen); + // Picking a section opens its landing page right away (same as clicking a + // header tab on desktop) while the panel stays on screen, so the sidebar + // can be used to keep browsing the section. const handleTabChange = (tabId: TabId) => { setSelectedTab(tabId); + + const target = TABS.find((t) => t.id === tabId)?.defaultHref; + if (!target || target === pathname) return; + + // Section changed — show its nav from the top, not the previous scroll offset. + const container = document.getElementById('mobile-sidebar-scroll-container'); + if (container) container.scrollTop = 0; + + setPendingHref(target); + keepOpenRef.current = true; + router.push(target); + }; + + // Picking a page keeps the panel up until the new page is actually there — + // the item shows its loader meanwhile, instead of the menu vanishing into a + // few seconds of nothing. The route change itself closes the panel. + const handleNavigate = (href: string) => { + if (href === pathname) { + setIsOpen(false); + return; + } + setPendingHref(href); }; const selectedTabConfig = TABS.find((t) => t.id === selectedTab); @@ -164,7 +202,11 @@ export function MobileSidebar({ navigationByTab }: MobileSidebarProps) { id="mobile-sidebar-scroll-container" >
- setIsOpen(false)} /> +
)} diff --git a/apps/docs/components/layout/sidebar-item.tsx b/apps/docs/components/layout/sidebar-item.tsx index aa6bb4ac..5ecdf8f8 100644 --- a/apps/docs/components/layout/sidebar-item.tsx +++ b/apps/docs/components/layout/sidebar-item.tsx @@ -32,21 +32,26 @@ const METHOD_COLORS = { interface SidebarItemProps { item: NavigationItem; - onItemClick?: () => void; + onItemClick?: (href: string) => void; + /** Overrides the current pathname while a sidebar navigation is in flight */ + activePath?: string; } -export function SidebarItem({ item, onItemClick }: SidebarItemProps) { - const pathname = usePathname(); - const isActive = pathname === item.href; +export function SidebarItem({ item, onItemClick, activePath }: SidebarItemProps) { + const currentPathname = usePathname(); + const isActive = (activePath ?? currentPathname) === item.href; + // Navigation to this item was started outside the sidebar — show it as + // active and loading, exactly as if it had been clicked here. + const isPending = activePath === item.href && currentPathname !== item.href; const itemRef = useRef(null); - const { isLoading, handleClick } = useNavigationLoading(item.href, 200); + const { isLoading, handleClick } = useNavigationLoading(item.href, 200, isPending); // Check if item has new badge (set by server in navigation.ts) const hasNewBadge = item.badge === 'new'; const handleLinkClick = () => { handleClick(); - onItemClick?.(); + onItemClick?.(item.href); }; if (item.external) { diff --git a/apps/docs/components/layout/sidebar-nav.tsx b/apps/docs/components/layout/sidebar-nav.tsx index 7ddc6edc..f87300c1 100644 --- a/apps/docs/components/layout/sidebar-nav.tsx +++ b/apps/docs/components/layout/sidebar-nav.tsx @@ -9,7 +9,13 @@ import { useState, useEffect, useRef, useCallback } from 'react'; interface SidebarNavProps { navigation: NavigationSection[]; - onNavigate?: () => void; + onNavigate?: (href: string) => void; + /** + * Highlight this href instead of the current pathname. Used while a + * navigation started from the sidebar is still in flight, so the target + * item looks selected right away instead of lighting up after the commit. + */ + activePath?: string; } /** @@ -29,8 +35,9 @@ function isGroupActive(item: NavigationItem, pathname: string): boolean { return item.children?.some((child) => child.href === pathname) ?? false; } -export function SidebarNav({ navigation, onNavigate }: SidebarNavProps) { - const pathname = usePathname(); +export function SidebarNav({ navigation, onNavigate, activePath }: SidebarNavProps) { + const currentPathname = usePathname(); + const pathname = activePath ?? currentPathname; const isInternalNav = useRef(false); // Collect all groups (items with children) across all sections @@ -41,9 +48,9 @@ export function SidebarNav({ navigation, onNavigate }: SidebarNavProps) { return allGroups.filter((g) => isGroupActive(g, pathname)).map(groupKey); }); - const handleItemClick = () => { + const handleItemClick = (href: string) => { isInternalNav.current = true; - onNavigate?.(); + onNavigate?.(href); }; // On external navigation: expand group containing active item @@ -142,10 +149,16 @@ export function SidebarNav({ navigation, onNavigate }: SidebarNavProps) { pathname={pathname} isOpen={openGroups.includes(groupKey(item))} onItemClick={handleItemClick} + activePath={activePath} /> ) : ( - + ) )} @@ -163,11 +176,13 @@ function SidebarGroup({ pathname, isOpen, onItemClick, + activePath, }: { item: NavigationItem; pathname: string; isOpen: boolean; - onItemClick: () => void; + onItemClick: (href: string) => void; + activePath?: string; }) { const activeChild = item.children?.find((c) => c.href === pathname); const key = groupKey(item); @@ -191,7 +206,12 @@ function SidebarGroup({
    {item.children!.map((child, cIdx) => ( - + ))}
@@ -205,7 +225,7 @@ function SidebarGroup({ {!isOpen && activeChild && (
    - +
)} diff --git a/apps/docs/hooks/use-navigation-loading.ts b/apps/docs/hooks/use-navigation-loading.ts index 4002a395..0d4baca0 100644 --- a/apps/docs/hooks/use-navigation-loading.ts +++ b/apps/docs/hooks/use-navigation-loading.ts @@ -3,7 +3,14 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import { usePathname } from 'next/navigation'; -export function useNavigationLoading(href: string, delay: number = 200) { +/** + * Loader state for a link that starts a navigation. + * + * `pending` starts the same delayed loader without a click — for navigations + * kicked off elsewhere (e.g. picking a section in the mobile menu jumps to its + * landing page, and that page's sidebar item should look like it was clicked). + */ +export function useNavigationLoading(href: string, delay: number = 200, pending: boolean = false) { const [showLoader, setShowLoader] = useState(false); const pathname = usePathname(); const targetHrefRef = useRef(null); @@ -40,5 +47,9 @@ export function useNavigationLoading(href: string, delay: number = 200) { }, delay); }, [href, pathname, delay]); + useEffect(() => { + if (pending) handleClick(); + }, [pending, handleClick]); + return { isLoading: showLoader, handleClick }; }