Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions apps/docs/components/layout/mobile-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<TabId>(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<string | null>(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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -164,7 +202,11 @@ export function MobileSidebar({ navigationByTab }: MobileSidebarProps) {
id="mobile-sidebar-scroll-container"
>
<div className="px-2.5 pb-4">
<SidebarNav navigation={navigation} onNavigate={() => setIsOpen(false)} />
<SidebarNav
navigation={navigation}
onNavigate={handleNavigate}
activePath={pendingHref ?? undefined}
/>
</div>
</div>
)}
Expand Down
17 changes: 11 additions & 6 deletions apps/docs/components/layout/sidebar-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAnchorElement>(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) {
Expand Down
38 changes: 29 additions & 9 deletions apps/docs/components/layout/sidebar-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -142,10 +149,16 @@ export function SidebarNav({ navigation, onNavigate }: SidebarNavProps) {
pathname={pathname}
isOpen={openGroups.includes(groupKey(item))}
onItemClick={handleItemClick}
activePath={activePath}
/>
</li>
) : (
<SidebarItem key={iIdx} item={item} onItemClick={handleItemClick} />
<SidebarItem
key={iIdx}
item={item}
onItemClick={handleItemClick}
activePath={activePath}
/>
)
)}
</ul>
Expand All @@ -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);
Expand All @@ -191,7 +206,12 @@ function SidebarGroup({
<div className="ml-3 pl-4 border-l border-glass-divider mt-1 space-y-0.5">
<ul className="list-none space-y-0.5">
{item.children!.map((child, cIdx) => (
<SidebarItem key={cIdx} item={child} onItemClick={onItemClick} />
<SidebarItem
key={cIdx}
item={child}
onItemClick={onItemClick}
activePath={activePath}
/>
))}
</ul>
</div>
Expand All @@ -205,7 +225,7 @@ function SidebarGroup({
{!isOpen && activeChild && (
<div className="ml-3 pl-4 border-l border-glass-divider mt-1 space-y-0.5">
<ul className="list-none space-y-0.5">
<SidebarItem item={activeChild} onItemClick={onItemClick} />
<SidebarItem item={activeChild} onItemClick={onItemClick} activePath={activePath} />
</ul>
</div>
)}
Expand Down
13 changes: 12 additions & 1 deletion apps/docs/hooks/use-navigation-loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -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 };
}
Loading