diff --git a/src/app/(api)/api/[...slug]/page.tsx b/src/app/(api)/api/[...slug]/page.tsx index 66776885..ad1ff15c 100644 --- a/src/app/(api)/api/[...slug]/page.tsx +++ b/src/app/(api)/api/[...slug]/page.tsx @@ -7,7 +7,7 @@ import { ApiRequest, ApiResponses, Heading, Link, Note, Paragraph, Tag, TimeAgo import WithQuicknav from '@/components/WithQuickNav'; import { Icon } from '@/icons'; import { loadMdxInfo } from '@/lib/markdown/util'; -import { getActiveAncestors, getMenuItem } from '@/lib/menu/util'; +import { getCanonicalActiveAncestors, getMenuItem } from '@/lib/menu/util'; import { getLastUpdated, withDefaultMetadata, withMdxMetadata } from '@/lib/metadata/util'; import { parseSchemas, toOperations } from '@/lib/swagger/parse'; import { toRouteSegments, toSlug } from '@/lib/util'; @@ -78,7 +78,7 @@ const Page = async ({ params }: PageProps) => { const pathname = `${qualifiedSlug}`; const menuData = getMenuItem('api'); - const ancestors = getActiveAncestors(pathname, [menuData]); + const ancestors = getCanonicalActiveAncestors(pathname, [menuData]); const parentTitle = ancestors.length > 1 ? ancestors[ancestors.length - 2].title : null; if (mdxInfo) { diff --git a/src/app/(documentation)/[...slug]/page.tsx b/src/app/(documentation)/[...slug]/page.tsx index 348328f6..a010e99f 100644 --- a/src/app/(documentation)/[...slug]/page.tsx +++ b/src/app/(documentation)/[...slug]/page.tsx @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { withMdxMetadata, withDefaultMetadata, getLastUpdated } from '@/lib/metadata/util'; import { TimeAgo } from '@/components'; -import { getMenuItem, getActiveAncestors } from '@/lib/menu/util'; +import { getMenuItem, getCanonicalActiveAncestors } from '@/lib/menu/util'; import WithQuicknav from '@/components/WithQuickNav'; import { cx } from 'class-variance-authority'; @@ -51,7 +51,7 @@ const Page = async ({ params }: PageProps) => { // 2. Original logic: Get parentTitle from the menu system. const pathname = `/${qualifiedSlug}`; const menuData = getMenuItem('documentation'); - const ancestors = getActiveAncestors(pathname, [menuData]); + const ancestors = getCanonicalActiveAncestors(pathname, [menuData]); const menuParentTitle = ancestors.length > 1 ? ancestors[ancestors.length - 2].title : null; // 3. Prioritize the title from the MDX file, then fall back to the menu. diff --git a/src/app/(guides)/guides/[...slug]/page.tsx b/src/app/(guides)/guides/[...slug]/page.tsx index f43e87cf..3437d149 100644 --- a/src/app/(guides)/guides/[...slug]/page.tsx +++ b/src/app/(guides)/guides/[...slug]/page.tsx @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { withMdxMetadata, withDefaultMetadata, getLastUpdated } from '@/lib/metadata/util'; import { TimeAgo } from '@/components'; -import { getMenuItem, getActiveAncestors } from '@/lib/menu/util'; +import { getMenuItem, getCanonicalActiveAncestors } from '@/lib/menu/util'; import WithQuicknav from '@/components/WithQuickNav'; import { cx } from 'class-variance-authority'; @@ -50,7 +50,7 @@ const Page = async ({ params }: PageProps) => { const pathname = `/${qualifiedSlug}`; const menuData = getMenuItem('guides'); - const ancestors = getActiveAncestors(pathname, [menuData]); + const ancestors = getCanonicalActiveAncestors(pathname, [menuData]); const parentTitle = ancestors.length > 1 ? ancestors[ancestors.length - 2].title : null; return ( diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 5dce8480..897f3006 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createContext, useContext, useEffect, useRef, useState } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; import { usePathname } from 'next/navigation'; @@ -31,24 +31,11 @@ export const useNavigation = () => { export const NavigationEvents = () => { const pathname = usePathname(); const { setNavigationState } = useNavigation(); - const previousPathname = useRef(pathname); - // Close the navigation when the pathname changes + // Close the navigation when the pathname changes. + // Scroll-to-top and hash scrolling run in `useHashScroll` inside `WithQuicknav` only. useEffect(() => { setNavigationState('closed'); - - if (previousPathname.current !== pathname) { - // Disable smooth scroll so the top-reset is instant, then restore it - document.documentElement.style.scrollBehavior = 'auto'; - window.requestAnimationFrame(() => { - window.scrollTo(0, 0); - window.requestAnimationFrame(() => { - document.documentElement.style.scrollBehavior = ''; - }); - }); - } - - previousPathname.current = pathname; }, [pathname, setNavigationState]); return null; diff --git a/src/components/QuickNav/QuickNav.tsx b/src/components/QuickNav/QuickNav.tsx index d1702a55..3891b768 100644 --- a/src/components/QuickNav/QuickNav.tsx +++ b/src/components/QuickNav/QuickNav.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from 'react'; import { cx } from 'class-variance-authority'; -import { usePathname } from 'next/navigation'; import { quickNavContentSelector } from '@/lib/constants/quickNav'; @@ -12,26 +11,7 @@ import { useHeadingsObserver } from './useHeadingsObserver'; const headingsToObserve = ':scope > :is(h2, h3, h4, h5, h6):not([data-quick-nav-ignore])'; -export const scrollToHashTarget = (hash = window.location.hash) => { - const normalizedHash = hash.replace(/^#/, ''); - if (!normalizedHash) return false; - - let targetId = normalizedHash; - try { - targetId = decodeURIComponent(normalizedHash); - } catch { - targetId = normalizedHash; - } - - const target = document.getElementById(targetId); - if (!target) return false; - - target.scrollIntoView({ block: 'start' }); - return true; -}; - export const QuickNav = () => { - const pathname = usePathname(); const [headings, setHeadings] = useState>([]); const activeHeadline = useHeadingsObserver( quickNavContentSelector, @@ -40,19 +20,6 @@ export const QuickNav = () => { 1, ); - useEffect(() => { - if (!headings.length) return; - - const queueHashScroll = () => window.requestAnimationFrame(() => scrollToHashTarget()); - - queueHashScroll(); - window.addEventListener('hashchange', queueHashScroll); - - return () => { - window.removeEventListener('hashchange', queueHashScroll); - }; - }, [headings.length, pathname]); - useEffect(() => { const contentArea = document.querySelector(quickNavContentSelector); if (!(contentArea instanceof HTMLElement)) return; diff --git a/src/components/QuickNav/hashScroll.test.ts b/src/components/QuickNav/hashScroll.test.ts new file mode 100644 index 00000000..720a6d47 --- /dev/null +++ b/src/components/QuickNav/hashScroll.test.ts @@ -0,0 +1,80 @@ +/** @jest-environment jsdom */ + +import { scheduleDeferredScrollToTop, scheduleHashScroll, scheduleScrollToTop } from './hashScroll'; + +describe('scheduleHashScroll', () => { + beforeEach(() => { + jest.useFakeTimers(); + document.body.innerHTML = ''; + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 0; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + test('skips post-layout retry when the target is missing', () => { + const scrollIntoView = jest.fn(); + const target = document.createElement('h2'); + target.id = 'developer-tools'; + target.scrollIntoView = scrollIntoView; + document.body.appendChild(target); + + scheduleHashScroll('#missing'); + jest.runAllTicks(); + jest.advanceTimersByTime(350); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + test('scheduleScrollToTop scrolls to the document top with a post-layout retry', () => { + const scrollTo = jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); + + scheduleScrollToTop(); + jest.runAllTicks(); + expect(scrollTo).toHaveBeenCalled(); + + const callsAfterFrames = scrollTo.mock.calls.length; + jest.advanceTimersByTime(350); + expect(scrollTo.mock.calls.length).toBeGreaterThan(callsAfterFrames); + + scrollTo.mockRestore(); + }); + + test('scheduleDeferredScrollToTop respects hash presence after the frame', () => { + const scrollTo = jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); + + scheduleDeferredScrollToTop(() => true); + jest.runAllTicks(); + expect(scrollTo).not.toHaveBeenCalled(); + + scheduleDeferredScrollToTop(() => false); + jest.runAllTicks(); + expect(scrollTo).toHaveBeenCalled(); + + scrollTo.mockRestore(); + }); + + test('cancels a scheduled post-layout retry', () => { + const scrollIntoView = jest.fn(); + const target = document.createElement('h2'); + target.id = 'developer-tools'; + target.scrollIntoView = scrollIntoView; + document.body.appendChild(target); + + const cancel = scheduleHashScroll('#developer-tools'); + jest.runAllTicks(); + + const callsAfterFrames = scrollIntoView.mock.calls.length; + expect(callsAfterFrames).toBeGreaterThanOrEqual(1); + + cancel(); + jest.advanceTimersByTime(350); + + expect(scrollIntoView).toHaveBeenCalledTimes(callsAfterFrames); + }); +}); diff --git a/src/components/QuickNav/hashScroll.ts b/src/components/QuickNav/hashScroll.ts new file mode 100644 index 00000000..4e05aecb --- /dev/null +++ b/src/components/QuickNav/hashScroll.ts @@ -0,0 +1,169 @@ +// Copyright 2026 Cloudsmith Ltd + +const FRAME_RETRIES = 5; +/** Match Sidenav `openCloseTransition.duration` (0.35s). */ +const POST_LAYOUT_MS = 350; + +const withInstantScroll = (scroll: () => void) => { + const { documentElement } = document; + const previousScrollBehavior = documentElement.style.scrollBehavior; + documentElement.style.scrollBehavior = 'auto'; + + scroll(); + + documentElement.style.scrollBehavior = previousScrollBehavior; +}; + +export const scrollToDocumentTop = () => { + withInstantScroll(() => { + window.scrollTo(0, 0); + }); +}; + +export const scrollToHashTarget = (hash = window.location.hash) => { + const normalizedHash = hash.replace(/^#/, ''); + if (!normalizedHash) return false; + + let targetId = normalizedHash; + try { + targetId = decodeURIComponent(normalizedHash); + } catch { + targetId = normalizedHash; + } + + const target = document.getElementById(targetId); + if (!target) return false; + + withInstantScroll(() => { + target.scrollIntoView({ block: 'start' }); + }); + + return true; +}; + +export type CancelHashScroll = () => void; + +type PostLayoutRetry = 'always' | 'onAttemptSuccess'; + +/** + * Runs `attempt` across animation frames and optionally after sidenav layout animations. + * Returns a cancel function; call it when the hash or route changes before pending work runs. + */ +const scheduleScrollRetries = (attempt: () => boolean, postLayout: PostLayoutRetry): CancelHashScroll => { + let cancelled = false; + let rafId = 0; + let timeoutId = 0; + + const cancel = () => { + cancelled = true; + if (rafId) { + window.cancelAnimationFrame(rafId); + rafId = 0; + } + if (timeoutId) { + window.clearTimeout(timeoutId); + timeoutId = 0; + } + }; + + const runAttempt = () => { + if (cancelled) { + return false; + } + + return attempt(); + }; + + const schedulePostLayoutRetry = () => { + if (cancelled || timeoutId) { + return; + } + + timeoutId = window.setTimeout(() => { + timeoutId = 0; + runAttempt(); + }, POST_LAYOUT_MS); + }; + + queueMicrotask(() => { + if (cancelled) { + return; + } + + const retryFrames = (remaining: number) => { + if (cancelled) { + return; + } + + const succeeded = runAttempt(); + + if (postLayout === 'onAttemptSuccess' && succeeded) { + schedulePostLayoutRetry(); + return; + } + + if (remaining > 0) { + rafId = window.requestAnimationFrame(() => retryFrames(remaining - 1)); + return; + } + + if (postLayout === 'always') { + schedulePostLayoutRetry(); + } + }; + + retryFrames(FRAME_RETRIES); + }); + + return cancel; +}; + +/** + * Scrolls to the document top, retrying across frames and after sidenav layout animations. + * Use when the hash is cleared on the current page (e.g. switching hash-anchor menu sections). + */ +export const scheduleScrollToTop = (): CancelHashScroll => { + return scheduleScrollRetries(() => { + scrollToDocumentTop(); + return true; + }, 'always'); +}; + +/** + * Scrolls to the hash target, retrying across frames and after sidenav layout animations. + * Sidenav expand/collapse can reflow the page after the first scroll attempt. + * + * A post-layout retry runs only when a frame retry finds the target (to correct scroll + * position after the branch animation). If the target is never found, no delayed retry runs. + */ +export const scheduleHashScroll = (hash = window.location.hash): CancelHashScroll => { + if (!hash) { + return () => {}; + } + + return scheduleScrollRetries(() => scrollToHashTarget(hash), 'onAttemptSuccess'); +}; + +/** + * Defers scroll-to-top by one frame so a hash applied in the same navigation tick + * (e.g. `router.push('/page#section')`) is not overridden. + */ +export const scheduleDeferredScrollToTop = ( + hasWindowHash: () => boolean = () => Boolean(window.location.hash), +): CancelHashScroll => { + let cancelled = false; + let cancelScroll: CancelHashScroll | undefined; + const rafId = window.requestAnimationFrame(() => { + if (cancelled || hasWindowHash()) { + return; + } + + cancelScroll = scheduleScrollToTop(); + }); + + return () => { + cancelled = true; + window.cancelAnimationFrame(rafId); + cancelScroll?.(); + }; +}; diff --git a/src/components/QuickNav/resolveHashScrollEffect.test.ts b/src/components/QuickNav/resolveHashScrollEffect.test.ts new file mode 100644 index 00000000..d32c0317 --- /dev/null +++ b/src/components/QuickNav/resolveHashScrollEffect.test.ts @@ -0,0 +1,27 @@ +import { resolveHashScrollEffect } from './resolveHashScrollEffect'; + +describe('resolveHashScrollEffect', () => { + test('scrolls to hash when hash is present', () => { + expect( + resolveHashScrollEffect('/about-cloudsmith', '#developer-tools', '/other', ''), + ).toBe('hash'); + }); + + test('scrolls to top when hash is cleared on the same page', () => { + expect( + resolveHashScrollEffect('/about-cloudsmith', '', '/about-cloudsmith', '#developer-tools'), + ).toBe('top'); + }); + + test('does nothing when hash and pathname are unchanged', () => { + expect( + resolveHashScrollEffect('/about-cloudsmith', '', '/about-cloudsmith', ''), + ).toBe('none'); + }); + + test('defers top scroll when pathname changes without a hash', () => { + expect( + resolveHashScrollEffect('/about-cloudsmith', '', '/developer-tools/webhooks', ''), + ).toBe('deferred-top'); + }); +}); diff --git a/src/components/QuickNav/resolveHashScrollEffect.ts b/src/components/QuickNav/resolveHashScrollEffect.ts new file mode 100644 index 00000000..01951e1c --- /dev/null +++ b/src/components/QuickNav/resolveHashScrollEffect.ts @@ -0,0 +1,27 @@ +// Copyright 2026 Cloudsmith Ltd + +export type HashScrollEffect = 'hash' | 'top' | 'deferred-top' | 'none'; + +/** + * Decides which scroll behavior `useHashScroll` should run for the current route/hash transition. + */ +export const resolveHashScrollEffect = ( + pathname: string, + hash: string, + previousPathname: string, + previousHash: string, +): HashScrollEffect => { + if (hash) { + return 'hash'; + } + + if (previousHash) { + return 'top'; + } + + if (previousPathname === pathname) { + return 'none'; + } + + return 'deferred-top'; +}; diff --git a/src/components/QuickNav/useHashScroll.ts b/src/components/QuickNav/useHashScroll.ts new file mode 100644 index 00000000..f7c40a0d --- /dev/null +++ b/src/components/QuickNav/useHashScroll.ts @@ -0,0 +1,41 @@ +// Copyright 2026 Cloudsmith Ltd + +'use client'; + +import { useLayoutEffect, useRef } from 'react'; + +import { usePathname } from 'next/navigation'; + +import { useWindowHash } from '@/lib/windowLocationHash'; + +import { scheduleDeferredScrollToTop, scheduleHashScroll, scheduleScrollToTop } from './hashScroll'; +import { resolveHashScrollEffect } from './resolveHashScrollEffect'; + +/** + * Scrolls to the in-page heading for the current URL hash, or to the document top when + * the hash is cleared. Runs after React commit and retries after sidenav layout animations. + */ +export const useHashScroll = () => { + const pathname = usePathname(); + const hash = useWindowHash(); + const previousHash = useRef(hash); + const previousPathname = useRef(pathname); + + useLayoutEffect(() => { + const effect = resolveHashScrollEffect(pathname, hash, previousPathname.current, previousHash.current); + + previousPathname.current = pathname; + previousHash.current = hash; + + switch (effect) { + case 'hash': + return scheduleHashScroll(hash); + case 'top': + return scheduleScrollToTop(); + case 'deferred-top': + return scheduleDeferredScrollToTop(); + default: + return () => {}; + } + }, [pathname, hash]); +}; diff --git a/src/components/Sidenav/Sidenav.module.css b/src/components/Sidenav/Sidenav.module.css index a692dd63..f75bd4d1 100644 --- a/src/components/Sidenav/Sidenav.module.css +++ b/src/components/Sidenav/Sidenav.module.css @@ -42,11 +42,7 @@ /* Lists */ .listWrapper { - overflow-y: hidden; - - &:first-child { - padding-block: var(--space-l); - } + overflow: hidden; } /* Overwrite animation from JS to always display on mobile */ @@ -76,11 +72,23 @@ } } +.listRoot { + padding-block: var(--space-l); +} + .section + .listWrapper > .list { padding-inline-start: 0; } /* Links */ +.linkButton { + display: flex; + flex-shrink: 0; + align-items: center; + line-height: 0; + cursor: pointer; +} + .link { display: flex; justify-content: flex-start; @@ -112,6 +120,16 @@ --svg-path-fill: var(--brand-color-blue-7); } +.linkMain { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + line-height: 0; + text-decoration: none; + color: inherit; +} + .linkTitle { padding-inline-end: var(--space-xs); } diff --git a/src/components/Sidenav/Sidenav.tsx b/src/components/Sidenav/Sidenav.tsx index ba98ec7c..a0b9fc4e 100644 --- a/src/components/Sidenav/Sidenav.tsx +++ b/src/components/Sidenav/Sidenav.tsx @@ -1,22 +1,24 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { cx } from 'class-variance-authority'; import { Transition, Variants } from 'motion/react'; import * as motion from 'motion/react-client'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; import { useNavigation } from '@/app/navigation'; import { Icon } from '@/icons'; import { ChevronSmallIcon } from '@/icons/ChevronSmall'; import { MenuItem } from '@/lib/menu/types'; -import { getActiveAncestors } from '@/lib/menu/util'; import { last } from '@/lib/util'; import { Tag } from '../Tag'; import styles from './Sidenav.module.css'; +import { SidenavActiveMenuProvider, useSidenavActiveMenu } from './SidenavActiveMenuContext'; +import { SidenavHashProvider } from './SidenavHashContext'; +import { SidenavSiblingBranchProvider } from './SidenavSiblingBranches'; +import { useSidenavItemActions } from './useSidenavItemActions'; const togglerTransition: Transition = { duration: 0.2, ease: 'easeInOut' }; const openCloseTransition: Transition = { duration: 0.35, ease: [0.55, 0, 0, 1] }; @@ -25,11 +27,16 @@ const openCloseVariants: Variants = { collapsed: { opacity: 0, height: 0 }, }; -export const Sidenav = ({ items }: SidenavProps) => { +/** Stable React key for sidenav items (paths are not unique across siblings). */ +const buildMenuItemKey = (parentKey: string, item: MenuItem, index: number): string => { + const segment = `${index}:${item.path ?? item.title}`; + return parentKey ? `${parentKey}/${segment}` : segment; +}; + +const SidenavContent = ({ items }: SidenavProps) => { const { navigationState, toggleNavigation } = useNavigation(); - const pathname = usePathname(); - const activeMenuItems = getActiveAncestors(pathname, items); - const activeLabel = last(activeMenuItems)?.title ?? 'Select page'; + const { activeAncestors } = useSidenavActiveMenu(); + const activeLabel = last(activeAncestors)?.title ?? 'Select page'; useEffect(() => { document.body.classList.add('has-sidenav-toggle'); @@ -62,76 +69,69 @@ export const Sidenav = ({ items }: SidenavProps) => { opacity: isOpen ? 1 : 0, }} transition={togglerTransition}> -
{items ? : null}
+
+ + {items ? : null} + +
); }; -const List = ({ items, isExpanded }: ListProps) => { +export const Sidenav = ({ items }: SidenavProps) => { + return ( + + + + ); +}; + +const MenuItemRows = ({ items, parentKey = '', listClassName }: MenuItemRowsProps) => { + return ( + + + + ); +}; + +const List = ({ items, isExpanded, parentKey }: ListProps) => { return ( - + ); }; -const Item = ({ item }: ItemProps) => { - const pathname = usePathname(); - const { setNavigationState } = useNavigation(); - const isCurrentPageActive = item.path === pathname && !item.children; - const isExpandable = Boolean(item.children); - const isHashAnchorParent = Boolean(item.children && item.path?.includes('#')); - const shouldBeExpanded = isExpandedByDefault(item, pathname); - const [isExpanded, setIsExpanded] = useState(shouldBeExpanded); - - useEffect(() => { - setIsExpanded(shouldBeExpanded); - }, [shouldBeExpanded]); - - function toggleExpand(event: React.MouseEvent) { - const isMobileViewport = window.matchMedia('(max-width: 767px)').matches; - - // Hash-anchor parents should only expand/collapse in the sidenav. - if (isHashAnchorParent) { - event.preventDefault(); - setIsExpanded((expanded) => !expanded); - return; - } - - // Mobile will always link to the clicked item - if (isCurrentPageActive && isMobileViewport) { - event.preventDefault(); - } - - if (isMobileViewport && !isCurrentPageActive) { - event.currentTarget.blur(); - setNavigationState('closed'); - } - - if (isExpandable) { - setIsExpanded((expanded) => !expanded); - } - } +const Item = ({ item, itemKey, siblingItems }: ItemProps) => { + const { isExpanded, isActiveLeaf, handleLinkClick, handleChevronClick } = useSidenavItemActions({ + item, + itemKey, + siblingItems, + }); return (
  • {item.path ? ( - +
    {item.children ? ( -
  • ); }; -const isExpandedByDefault = (item: MenuItem, pathname: string) => { - if (!item.path) { - return true; - } - - return getActiveAncestors(pathname, [item]).length > 0; -}; - interface SidenavProps { items: MenuItem[]; } +interface MenuItemRowsProps { + items: MenuItem[]; + /** Omitted at the root; nested lists pass the parent item's key. */ + parentKey?: string; + listClassName: string; +} + interface ListProps { items: MenuItem[]; - isExpanded?: boolean; + isExpanded: boolean; + parentKey: string; } interface ItemProps { item: MenuItem; + itemKey: string; + siblingItems: MenuItem[]; } diff --git a/src/components/Sidenav/SidenavActiveMenuContext.tsx b/src/components/Sidenav/SidenavActiveMenuContext.tsx new file mode 100644 index 00000000..b40a43f9 --- /dev/null +++ b/src/components/Sidenav/SidenavActiveMenuContext.tsx @@ -0,0 +1,43 @@ +// Copyright 2026 Cloudsmith Ltd + +'use client'; + +import { createContext, useContext, useMemo } from 'react'; + +import type { MenuItem } from '@/lib/menu/types'; +import type { ReactNode } from 'react'; + +import { usePathname } from 'next/navigation'; + +import { getCanonicalActiveAncestors } from '@/lib/menu/util'; + +type SidenavActiveMenuContextValue = { + activeAncestors: MenuItem[]; +}; + +const SidenavActiveMenuContext = createContext(null); + +export const SidenavActiveMenuProvider = ({ + children, + items, +}: { + children: ReactNode; + items: MenuItem[]; +}) => { + const pathname = usePathname(); + const value = useMemo( + () => ({ activeAncestors: getCanonicalActiveAncestors(pathname, items) }), + [pathname, items], + ); + + return {children}; +}; + +export const useSidenavActiveMenu = () => { + const context = useContext(SidenavActiveMenuContext); + if (!context) { + throw new Error('useSidenavActiveMenu must be used within SidenavActiveMenuProvider'); + } + + return context; +}; diff --git a/src/components/Sidenav/SidenavHashContext.tsx b/src/components/Sidenav/SidenavHashContext.tsx new file mode 100644 index 00000000..43b206f8 --- /dev/null +++ b/src/components/Sidenav/SidenavHashContext.tsx @@ -0,0 +1,59 @@ +// Copyright 2026 Cloudsmith Ltd + +'use client'; + +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +import type { ReactNode } from 'react'; + +import { usePathname } from 'next/navigation'; + +import { useWindowHash } from '@/lib/windowLocationHash'; + +type SidenavHashContextValue = { + hash: string; + setHash: (hash: string) => void; +}; + +const SidenavHashContext = createContext(null); + +/** + * Tracks the URL hash for sidenav expand/collapse and click-resolution logic. + * Context `hash` is the source of truth for interaction resolvers; it is seeded from + * `window.location.hash` on route changes and updated imperatively when the user + * opens or clears hash-anchor sections (see `useSidenavItemActions`). + * Page scroll position follows `window.location.hash` via `useHashScroll`, not this override. + */ +export const SidenavHashProvider = ({ children }: { children: ReactNode }) => { + const pathname = usePathname(); + const windowHash = useWindowHash(); + const [hashOverride, setHashOverride] = useState(null); + const hash = hashOverride ?? windowHash; + + const setHash = useCallback((nextHash: string) => { + setHashOverride(nextHash); + }, []); + + useEffect(() => { + setHashOverride(null); + }, [pathname]); + + useEffect(() => { + if (hashOverride !== null && hashOverride === windowHash) { + setHashOverride(null); + } + }, [hashOverride, windowHash]); + + const value = useMemo(() => ({ hash, setHash }), [hash, setHash]); + + return {children}; +}; + +export const useSidenavHash = () => { + const context = useContext(SidenavHashContext); + if (!context) { + throw new Error('useSidenavHash must be used within SidenavHashProvider'); + } + + return context; +}; diff --git a/src/components/Sidenav/SidenavSiblingBranches.tsx b/src/components/Sidenav/SidenavSiblingBranches.tsx new file mode 100644 index 00000000..438dbfbf --- /dev/null +++ b/src/components/Sidenav/SidenavSiblingBranches.tsx @@ -0,0 +1,52 @@ +// Copyright 2026 Cloudsmith Ltd + +'use client'; + +import { createContext, useCallback, useContext, useMemo, useRef } from 'react'; + +import type { ReactNode } from 'react'; + +type SiblingBranchContextValue = { + collapseSiblings: (exceptKey: string) => void; + registerBranch: (key: string, setExpanded: (expanded: boolean) => void) => () => void; +}; + +const SiblingBranchContext = createContext(null); + +export const useSiblingBranchContext = () => { + const context = useContext(SiblingBranchContext); + if (!context) { + throw new Error('useSiblingBranchContext must be used within SidenavSiblingBranchProvider'); + } + + return context; +}; + +type SidenavSiblingBranchProviderProps = { + children: ReactNode; +}; + +/** Registers expand/collapse state for sibling branches at one list level. */ +export const SidenavSiblingBranchProvider = ({ children }: SidenavSiblingBranchProviderProps) => { + const branchSettersRef = useRef(new Map void>()); + + const registerBranch = useCallback((key: string, setExpanded: (expanded: boolean) => void) => { + branchSettersRef.current.set(key, setExpanded); + + return () => { + branchSettersRef.current.delete(key); + }; + }, []); + + const collapseSiblings = useCallback((exceptKey: string) => { + branchSettersRef.current.forEach((setExpanded, key) => { + if (key !== exceptKey) { + setExpanded(false); + } + }); + }, []); + + const value = useMemo(() => ({ collapseSiblings, registerBranch }), [collapseSiblings, registerBranch]); + + return {children}; +}; diff --git a/src/components/Sidenav/sidenavNavigation.ts b/src/components/Sidenav/sidenavNavigation.ts new file mode 100644 index 00000000..0ad249eb --- /dev/null +++ b/src/components/Sidenav/sidenavNavigation.ts @@ -0,0 +1,38 @@ +// Copyright 2026 Cloudsmith Ltd + +import { normalizePath } from '@/util/url'; + +type SidenavRouter = { + push: (href: string, options?: { scroll?: boolean }) => void; +}; + +/** `history.replaceState` does not emit `hashchange`; subscribers need a synthetic event. */ +const replaceUrlClearingHash = (path: string) => { + window.history.replaceState(null, '', path); + window.dispatchEvent(new HashChangeEvent('hashchange')); +}; + +/** + * Navigates to `path`, clearing any hash when already on that page. + * Scroll-to-top is handled by `useHashScroll` after the hash is removed from the URL. + */ +export const navigateToSidenavPath = (router: SidenavRouter, path: string) => { + const normalizedPath = normalizePath(path); + + if (normalizePath(window.location.pathname) === normalizedPath) { + if (window.location.hash) { + replaceUrlClearingHash(normalizedPath); + } + + return; + } + + router.push(normalizedPath, { scroll: false }); +}; + +/** Replaces the current URL with the base path when already on it, clearing any hash. */ +export const clearUrlHashOnPage = (router: SidenavRouter, pathname: string, basePath: string) => { + if (normalizePath(pathname) === normalizePath(basePath)) { + navigateToSidenavPath(router, basePath); + } +}; diff --git a/src/components/Sidenav/useSidenavItemActions.ts b/src/components/Sidenav/useSidenavItemActions.ts new file mode 100644 index 00000000..06b0b75d --- /dev/null +++ b/src/components/Sidenav/useSidenavItemActions.ts @@ -0,0 +1,190 @@ +// Copyright 2026 Cloudsmith Ltd + +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import type { SidenavChevronClickAction, SidenavLinkClickAction } from '@/lib/menu/sidenavInteractions'; +import type { MouseEvent } from 'react'; + +import { usePathname, useRouter } from 'next/navigation'; + +import { useNavigation } from '@/app/navigation'; +import { resolveSidenavChevronClick, resolveSidenavLinkClick } from '@/lib/menu/sidenavInteractions'; +import { MenuItem } from '@/lib/menu/types'; +import { + getMenuItemBasePath, + getMenuItemHash, + isActiveHashAnchorSibling, + isActiveMenuLeafForAncestors, + shouldExpandMenuItem, +} from '@/lib/menu/util'; + +import { useSidenavActiveMenu } from './SidenavActiveMenuContext'; +import { useSidenavHash } from './SidenavHashContext'; +import { clearUrlHashOnPage, navigateToSidenavPath } from './sidenavNavigation'; +import { useSiblingBranchContext } from './SidenavSiblingBranches'; + +const MOBILE_MEDIA_QUERY = '(max-width: 767px)'; + +type UseSidenavItemActionsParams = { + item: MenuItem; + itemKey: string; + siblingItems: MenuItem[]; +}; + +type SidenavItemAction = SidenavLinkClickAction | SidenavChevronClickAction; + +export const useSidenavItemActions = ({ item, itemKey, siblingItems }: UseSidenavItemActionsParams) => { + const pathname = usePathname(); + const router = useRouter(); + const { hash, setHash } = useSidenavHash(); + const { activeAncestors } = useSidenavActiveMenu(); + const siblingBranch = useSiblingBranchContext(); + const { setNavigationState } = useNavigation(); + const isActiveLeaf = isActiveMenuLeafForAncestors(item, activeAncestors); + const defaultExpanded = shouldExpandMenuItem(pathname, item, hash, siblingItems, activeAncestors); + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + useEffect(() => { + return siblingBranch.registerBranch(itemKey, setIsExpanded); + }, [itemKey, siblingBranch]); + + useEffect(() => { + setIsExpanded(defaultExpanded); + }, [defaultExpanded, pathname, hash]); + + const openHashAnchorSection = useCallback(() => { + if (!item.path) { + return; + } + + const itemHash = getMenuItemHash(item.path); + if (itemHash) { + // Optimistic hash for expand/collapse; scroll runs in `useHashScroll` from the URL after `router.push`. + setHash(`#${itemHash}`); + } + + siblingBranch.collapseSiblings(itemKey); + setIsExpanded(true); + router.push(item.path, { scroll: false }); + }, [item.path, itemKey, router, setHash, siblingBranch]); + + const openBranchSection = useCallback( + (clearHash: boolean) => { + if (clearHash && item.path) { + setHash(''); + clearUrlHashOnPage(router, pathname, getMenuItemBasePath(item.path)); + } + + siblingBranch.collapseSiblings(itemKey); + setIsExpanded(true); + }, + [item.path, itemKey, pathname, router, setHash, siblingBranch], + ); + + const collapseClearHash = useCallback( + (collapseSiblingsOnClose: boolean) => { + const shouldClearUrlHash = Boolean(item.path && isActiveHashAnchorSibling(pathname, item, hash)); + + setHash(''); + + if (shouldClearUrlHash && item.path) { + navigateToSidenavPath(router, getMenuItemBasePath(item.path)); + } + + if (collapseSiblingsOnClose) { + siblingBranch.collapseSiblings(itemKey); + } + + setIsExpanded(false); + }, + [hash, item, itemKey, pathname, router, setHash, siblingBranch], + ); + + const getInteractionContext = useCallback( + (isMobileViewport: boolean) => ({ + pathname, + hash, + item, + siblingItems, + activeAncestors, + isExpanded, + isMobileViewport, + }), + [activeAncestors, hash, isExpanded, item, pathname, siblingItems], + ); + + const applyAction = useCallback( + (action: SidenavItemAction) => { + switch (action.kind) { + case 'collapse': + setIsExpanded(false); + break; + case 'collapse-clear-hash': + collapseClearHash(action.collapseSiblings); + break; + case 'open-hash-anchor': + openHashAnchorSection(); + break; + case 'open-sibling-branch': + openBranchSection(action.clearHash); + break; + case 'expand-branch': + setIsExpanded(true); + break; + case 'toggle': + setIsExpanded((expanded) => !expanded); + break; + case 'navigate': + break; + } + }, + [collapseClearHash, openBranchSection, openHashAnchorSection], + ); + + const handleLinkClick = useCallback( + (event: MouseEvent) => { + const isMobileViewport = window.matchMedia(MOBILE_MEDIA_QUERY).matches; + const { action, preventDefault, closeMobileNav, blurLink } = resolveSidenavLinkClick( + getInteractionContext(isMobileViewport), + ); + + if (preventDefault) { + event.preventDefault(); + } + + if (blurLink) { + event.currentTarget.blur(); + } + + if (closeMobileNav) { + setNavigationState('closed'); + } + + applyAction(action); + }, + [applyAction, getInteractionContext, setNavigationState], + ); + + const handleChevronClick = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const action = resolveSidenavChevronClick( + getInteractionContext(window.matchMedia(MOBILE_MEDIA_QUERY).matches), + ); + + applyAction(action); + }, + [applyAction, getInteractionContext], + ); + + return { + isExpanded, + isActiveLeaf, + handleLinkClick, + handleChevronClick, + }; +}; diff --git a/src/components/WithQuickNav/WithQuicknav.tsx b/src/components/WithQuickNav/WithQuicknav.tsx index 39fbc07c..10e8c13c 100644 --- a/src/components/WithQuickNav/WithQuicknav.tsx +++ b/src/components/WithQuickNav/WithQuicknav.tsx @@ -1,5 +1,8 @@ +'use client'; + import { Icon } from '@/icons'; +import { useHashScroll } from '../QuickNav/useHashScroll'; import { PageInfo } from '../PageInfo'; import { QuickNav } from '../QuickNav'; import styles from './WithQuicknav.module.css'; @@ -10,6 +13,8 @@ import styles from './WithQuicknav.module.css'; */ const WithQuicknav = ({ children, showPageInfo = false, path = '', lastUpdated }: WithQuicknavProps) => { + useHashScroll(); + return (
    diff --git a/src/lib/menu/sidenavInteractions.test.ts b/src/lib/menu/sidenavInteractions.test.ts new file mode 100644 index 00000000..45633a78 --- /dev/null +++ b/src/lib/menu/sidenavInteractions.test.ts @@ -0,0 +1,205 @@ +import type { MenuItem } from './types'; +import type { SidenavItemInteractionContext, SidenavLinkClickResult } from './sidenavInteractions'; +import { resolveSidenavChevronClick, resolveSidenavLinkClick } from './sidenavInteractions'; +import { getCanonicalActiveAncestors } from './util'; + +const withActiveAncestors = ( + context: Omit, + rootItems: MenuItem[], +): SidenavItemInteractionContext => ({ + ...context, + activeAncestors: getCanonicalActiveAncestors(context.pathname, rootItems), +}); + +const aboutCloudsmith: MenuItem = { + title: 'About Cloudsmith', + path: '/about-cloudsmith', + children: [{ title: 'Key Concepts', path: '/about-cloudsmith/key-concepts' }], +}; + +const developerTools: MenuItem = { + title: 'Developer Tools', + path: '/about-cloudsmith#developer-tools', + children: [{ title: 'Cloudsmith CLI', path: '/developer-tools/cli' }], +}; + +const hashAnchorSiblings = [aboutCloudsmith, developerTools]; + +describe('resolveSidenavLinkClick', () => { + test.each<[string, SidenavItemInteractionContext, SidenavLinkClickResult]>([ + [ + 'collapse active hash-anchor', + withActiveAncestors( + { + pathname: '/about-cloudsmith', + hash: '#developer-tools', + item: developerTools, + siblingItems: hashAnchorSiblings, + isExpanded: true, + isMobileViewport: false, + }, + hashAnchorSiblings, + ), + { + action: { kind: 'collapse-clear-hash', collapseSiblings: false }, + preventDefault: true, + closeMobileNav: false, + blurLink: false, + }, + ], + [ + 'open hash-anchor', + withActiveAncestors( + { + pathname: '/about-cloudsmith', + hash: '', + item: developerTools, + siblingItems: hashAnchorSiblings, + isExpanded: false, + isMobileViewport: false, + }, + hashAnchorSiblings, + ), + { + action: { kind: 'open-hash-anchor' }, + preventDefault: true, + closeMobileNav: false, + blurLink: false, + }, + ], + [ + 'open hash-anchor from child route', + withActiveAncestors( + { + pathname: '/developer-tools/terraform-provider', + hash: '', + item: developerTools, + siblingItems: hashAnchorSiblings, + isExpanded: true, + isMobileViewport: false, + }, + hashAnchorSiblings, + ), + { + action: { kind: 'open-hash-anchor' }, + preventDefault: true, + closeMobileNav: false, + blurLink: false, + }, + ], + [ + 'collapse non-hash sibling', + withActiveAncestors( + { + pathname: '/about-cloudsmith', + hash: '', + item: aboutCloudsmith, + siblingItems: hashAnchorSiblings, + isExpanded: true, + isMobileViewport: false, + }, + hashAnchorSiblings, + ), + { + action: { kind: 'collapse' }, + preventDefault: true, + closeMobileNav: false, + blurLink: false, + }, + ], + [ + 'open sibling and clear hash', + withActiveAncestors( + { + pathname: '/about-cloudsmith', + hash: '#developer-tools', + item: aboutCloudsmith, + siblingItems: hashAnchorSiblings, + isExpanded: false, + isMobileViewport: false, + }, + hashAnchorSiblings, + ), + { + action: { kind: 'open-sibling-branch', clearHash: true }, + preventDefault: true, + closeMobileNav: false, + blurLink: false, + }, + ], + [ + 'open sibling without clearing hash', + withActiveAncestors( + { + pathname: '/about-cloudsmith', + hash: '', + item: aboutCloudsmith, + siblingItems: hashAnchorSiblings, + isExpanded: false, + isMobileViewport: false, + }, + hashAnchorSiblings, + ), + { + action: { kind: 'open-sibling-branch', clearHash: false }, + preventDefault: true, + closeMobileNav: false, + blurLink: false, + }, + ], + ])('%s', (_name, context, expected) => { + expect(resolveSidenavLinkClick(context)).toEqual(expected); + }); +}); + +describe('resolveSidenavChevronClick', () => { + test.each([ + [ + 'open hash-anchor', + withActiveAncestors( + { + pathname: '/about-cloudsmith', + hash: '', + item: developerTools, + siblingItems: hashAnchorSiblings, + isExpanded: false, + isMobileViewport: false, + }, + hashAnchorSiblings, + ), + { kind: 'open-hash-anchor' }, + ], + [ + 'collapse hash-anchor and siblings', + withActiveAncestors( + { + pathname: '/developer-tools/cli', + hash: '', + item: developerTools, + siblingItems: hashAnchorSiblings, + isExpanded: true, + isMobileViewport: false, + }, + hashAnchorSiblings, + ), + { kind: 'collapse-clear-hash', collapseSiblings: true }, + ], + [ + 'open sibling and clear hash', + withActiveAncestors( + { + pathname: '/about-cloudsmith', + hash: '#developer-tools', + item: aboutCloudsmith, + siblingItems: hashAnchorSiblings, + isExpanded: false, + isMobileViewport: false, + }, + hashAnchorSiblings, + ), + { kind: 'open-sibling-branch', clearHash: true }, + ], + ] as const)('%s', (_name, context, expected) => { + expect(resolveSidenavChevronClick(context)).toEqual(expected); + }); +}); diff --git a/src/lib/menu/sidenavInteractions.ts b/src/lib/menu/sidenavInteractions.ts new file mode 100644 index 00000000..b35d5738 --- /dev/null +++ b/src/lib/menu/sidenavInteractions.ts @@ -0,0 +1,136 @@ +// Copyright 2026 Cloudsmith Ltd + +import type { MenuItem } from './types'; + +import { + hasHashAnchorSiblingOnSameBase, + isActiveHashAnchorSibling, + isActiveMenuLeafForAncestors, + isAnyHashAnchorSiblingActive, + isExactMenuPath, + isHashAnchorParent, + isItemInActiveAncestors, +} from './util'; + +export type SidenavItemInteractionContext = { + pathname: string; + hash: string; + item: MenuItem; + siblingItems: MenuItem[]; + activeAncestors: MenuItem[]; + isExpanded: boolean; + isMobileViewport: boolean; +}; + +export type SidenavLinkClickAction = + | { kind: 'navigate' } + | { kind: 'collapse' } + | { kind: 'collapse-clear-hash'; collapseSiblings: boolean } + | { kind: 'open-hash-anchor' } + | { kind: 'open-sibling-branch'; clearHash: boolean } + | { kind: 'expand-branch' }; + +export type SidenavLinkClickResult = { + action: SidenavLinkClickAction; + preventDefault: boolean; + closeMobileNav: boolean; + blurLink: boolean; +}; + +export type SidenavChevronClickAction = + | { kind: 'open-hash-anchor' } + | { kind: 'collapse-clear-hash'; collapseSiblings: boolean } + | { kind: 'open-sibling-branch'; clearHash: boolean } + | { kind: 'toggle' }; + +/** + * Resolves how a sidenav item link click should behave. + */ +export const resolveSidenavLinkClick = (ctx: SidenavItemInteractionContext): SidenavLinkClickResult => { + const { pathname, hash, item, siblingItems, activeAncestors, isExpanded, isMobileViewport } = ctx; + const isActiveLeafMatch = isActiveMenuLeafForAncestors(item, activeAncestors); + const isExactPathMatch = isExactMenuPath(pathname, item); + const isHashAnchor = isHashAnchorParent(item); + const isOnActiveBranchMatch = isItemInActiveAncestors(item, activeAncestors); + const sharesBaseWithHashAnchorSibling = hasHashAnchorSiblingOnSameBase(item, siblingItems); + const hashSiblingIsActive = isAnyHashAnchorSiblingActive(pathname, hash, siblingItems); + + const idle = (action: SidenavLinkClickAction): SidenavLinkClickResult => ({ + action, + preventDefault: false, + closeMobileNav: false, + blurLink: false, + }); + + if (isHashAnchor) { + if (isExpanded && isActiveHashAnchorSibling(pathname, item, hash)) { + return { + ...idle({ kind: 'collapse-clear-hash', collapseSiblings: false }), + preventDefault: true, + }; + } + + return { + action: { kind: 'open-hash-anchor' }, + preventDefault: true, + closeMobileNav: isMobileViewport && !isOnActiveBranchMatch, + blurLink: isMobileViewport && !isOnActiveBranchMatch, + }; + } + + if (sharesBaseWithHashAnchorSibling && item.children) { + if (isExactPathMatch && isExpanded && !hashSiblingIsActive) { + return { ...idle({ kind: 'collapse' }), preventDefault: true }; + } + + if (hashSiblingIsActive || !isExpanded) { + return { + action: { kind: 'open-sibling-branch', clearHash: hashSiblingIsActive }, + preventDefault: hashSiblingIsActive || isExactPathMatch, + closeMobileNav: isMobileViewport && !isActiveLeafMatch, + blurLink: isMobileViewport && !isActiveLeafMatch, + }; + } + } + + if (item.children && isExactPathMatch && isExpanded) { + return { ...idle({ kind: 'collapse' }), preventDefault: true }; + } + + if (isActiveLeafMatch && isMobileViewport) { + return { ...idle({ kind: 'navigate' }), preventDefault: true }; + } + + return { + action: item.children && !isExpanded ? { kind: 'expand-branch' } : { kind: 'navigate' }, + preventDefault: false, + closeMobileNav: isMobileViewport && !isActiveLeafMatch, + blurLink: isMobileViewport && !isActiveLeafMatch, + }; +}; + +/** + * Resolves how a sidenav item chevron click should behave. + * Chevron collapse-clear-hash collapses same-level siblings; link clicks do not. + */ +export const resolveSidenavChevronClick = (ctx: SidenavItemInteractionContext): SidenavChevronClickAction => { + const { pathname, hash, item, siblingItems, activeAncestors, isExpanded } = ctx; + const isOnActiveBranchMatch = isItemInActiveAncestors(item, activeAncestors); + const isHashAnchor = isHashAnchorParent(item); + const sharesBaseWithHashAnchorSibling = hasHashAnchorSiblingOnSameBase(item, siblingItems); + const hashSiblingIsActive = isAnyHashAnchorSiblingActive(pathname, hash, siblingItems); + + if (isHashAnchor && item.path) { + if (!isOnActiveBranchMatch || !isExpanded) { + return { kind: 'open-hash-anchor' }; + } + + return { kind: 'collapse-clear-hash', collapseSiblings: true }; + } + + if (sharesBaseWithHashAnchorSibling && !isExpanded) { + return { kind: 'open-sibling-branch', clearHash: hashSiblingIsActive }; + } + + return { kind: 'toggle' }; +}; diff --git a/src/lib/menu/util.test.ts b/src/lib/menu/util.test.ts index 77a72a2d..24a76ce7 100644 --- a/src/lib/menu/util.test.ts +++ b/src/lib/menu/util.test.ts @@ -1,9 +1,34 @@ -import { getActiveAncestors, getActiveMenuItem, getMenuItem } from './util'; +import type { MenuItem } from './types'; +import { + getActiveAncestors, + getActiveMenuItem, + getCanonicalActiveAncestors, + getMenuItem, + isActiveMenuLeaf, + isActiveMenuLeafForAncestors, + isExactMenuPath, + isItemInActiveAncestors, + shouldExpandMenuItem, +} from './util'; + +const aboutCloudsmith: MenuItem = { + title: 'About Cloudsmith', + path: '/about-cloudsmith', + children: [{ title: 'Key Concepts', path: '/about-cloudsmith/key-concepts' }], +}; + +const developerTools: MenuItem = { + title: 'Developer Tools', + path: '/about-cloudsmith#developer-tools', + children: [{ title: 'Cloudsmith CLI', path: '/developer-tools/cli' }], +}; + +const hashAnchorSiblings = [aboutCloudsmith, developerTools]; describe('lib', () => { describe('menu', () => { describe('getActiveMenuItem()', () => { - test('finds active top-level item', async () => { + test('finds active top-level item', () => { const active1 = getActiveMenuItem('/api/something'); expect(active1.title).toEqual('API Reference'); const active2 = getActiveMenuItem('/guides/something'); @@ -14,8 +39,9 @@ describe('lib', () => { expect(active4.title).toEqual('Documentation'); }); }); + describe('getActiveAncestors()', () => { - test('finds active menu item with ancestors', async () => { + test('finds active menu item with ancestors', () => { const documentationItem = getMenuItem('documentation'); const ancestors = getActiveAncestors('/migrating-to-cloudsmith/export-from-nexus-sonatype', [ documentationItem, @@ -25,6 +51,168 @@ describe('lib', () => { expect(ancestors[1].title).toEqual('Migrating to Cloudsmith'); expect(ancestors[2].title).toEqual('Migrating from Nexus Sonatype'); }); + + test('matches paths regardless of trailing slash', () => { + const workspacesMenu: MenuItem = { + title: 'Workspaces', + path: '/workspaces', + children: [ + { + title: 'Authentication', + path: '/authentication/', + children: [{ title: 'SAML', path: '/authentication/single-sign-on' }], + }, + ], + }; + + const ancestors = getActiveAncestors('/authentication', [workspacesMenu]); + expect(ancestors.map((item) => item.title)).toEqual(['Workspaces', 'Authentication']); + expect(isItemInActiveAncestors(workspacesMenu, ancestors)).toBe(true); + }); + }); + + describe('isExactMenuPath()', () => { + test('matches paths regardless of trailing slash', () => { + const item: MenuItem = { title: 'Authentication', path: '/authentication/' }; + expect(isExactMenuPath('/authentication', item)).toBe(true); + }); + + test('does not match bare pathname for hash-anchor menu paths', () => { + const item: MenuItem = { + title: 'Developer Tools', + path: '/about-cloudsmith#developer-tools', + children: [], + }; + expect(isExactMenuPath('/about-cloudsmith', item)).toBe(false); + }); + }); + + describe('isActiveMenuLeaf()', () => { + test('is true only for a matching leaf item', () => { + const leaf: MenuItem = { title: 'SAML', path: '/authentication/single-sign-on' }; + const branch: MenuItem = { + title: 'Authentication', + path: '/authentication', + children: [leaf], + }; + + expect(isActiveMenuLeaf('/authentication/single-sign-on', leaf)).toBe(true); + expect(isActiveMenuLeaf('/authentication/single-sign-on', branch)).toBe(false); + }); }); + + describe('getCanonicalActiveAncestors()', () => { + test('picks one branch when multiple menu items share the same path', () => { + const cursorIde: MenuItem = { title: 'Cursor IDE', path: '/developer-tools/vscode' }; + const theiaIde: MenuItem = { title: 'Theia IDE', path: '/developer-tools/vscode' }; + const vscodeInIntegrations: MenuItem = { + title: 'VS Code Extension', + path: '/developer-tools/vscode', + }; + const vscodeInDeveloperTools: MenuItem = { + title: 'VS Code Extension', + path: '/developer-tools/vscode', + }; + + const integrations: MenuItem = { + title: 'Integrations', + path: '/integrations', + children: [ + { title: 'Ansible', path: '/integrations/integrating-with-ansible' }, + cursorIde, + theiaIde, + vscodeInIntegrations, + ], + }; + + const developerToolsSection: MenuItem = { + title: 'Developer Tools', + path: '/about-cloudsmith#developer-tools', + children: [ + { title: 'Cloudsmith CLI', path: '/developer-tools/cli' }, + vscodeInDeveloperTools, + { title: 'Webhooks', path: '/developer-tools/webhooks' }, + ], + }; + + const menuItems = [integrations, developerToolsSection]; + const path = '/developer-tools/vscode'; + const ancestors = getCanonicalActiveAncestors(path, menuItems); + + expect(ancestors).toEqual([developerToolsSection, vscodeInDeveloperTools]); + expect(isActiveMenuLeafForAncestors(vscodeInDeveloperTools, ancestors)).toBe(true); + expect(isActiveMenuLeafForAncestors(cursorIde, ancestors)).toBe(false); + expect(isItemInActiveAncestors(integrations, ancestors)).toBe(false); + expect(isItemInActiveAncestors(developerToolsSection, ancestors)).toBe(true); + expect(shouldExpandMenuItem(path, integrations, '', [], ancestors)).toBe(false); + expect(shouldExpandMenuItem(path, developerToolsSection, '', [], ancestors)).toBe(true); + }); + + test('breaks affinity ties by keeping the earlier menu match', () => { + const firstMatch: MenuItem = { title: 'First', path: '/docs/page' }; + const secondMatch: MenuItem = { title: 'Second', path: '/docs/page' }; + + const sectionA: MenuItem = { + title: 'Section A', + path: '/docs', + children: [firstMatch, { title: 'Other A', path: '/docs/other-a' }], + }; + + const sectionB: MenuItem = { + title: 'Section B', + path: '/docs', + children: [secondMatch, { title: 'Other B', path: '/docs/other-b' }], + }; + + const ancestors = getCanonicalActiveAncestors('/docs/page', [sectionA, sectionB]); + + expect(ancestors).toEqual([sectionA, firstMatch]); + }); + }); + + describe('shouldExpandMenuItem()', () => { + test('expands section headers without a path', () => { + const section: MenuItem = { title: 'Overview' }; + expect(shouldExpandMenuItem('/any-page', section)).toBe(true); + }); + + test('expands branches on the active path', () => { + const workspacesMenu: MenuItem = { + title: 'Workspaces', + path: '/workspaces', + children: [{ title: 'Authentication', path: '/authentication' }], + }; + const ancestors = getActiveAncestors('/authentication', [workspacesMenu]); + + expect(shouldExpandMenuItem('/authentication', workspacesMenu, '', [], ancestors)).toBe(true); + }); + + test('expands hash-anchor and sibling branches for the active hash', () => { + const ancestors = getCanonicalActiveAncestors('/about-cloudsmith', hashAnchorSiblings); + + expect( + shouldExpandMenuItem( + '/about-cloudsmith', + developerTools, + '#developer-tools', + hashAnchorSiblings, + ancestors, + ), + ).toBe(true); + expect( + shouldExpandMenuItem( + '/about-cloudsmith', + aboutCloudsmith, + '#developer-tools', + hashAnchorSiblings, + ancestors, + ), + ).toBe(false); + expect( + shouldExpandMenuItem('/about-cloudsmith', aboutCloudsmith, '', hashAnchorSiblings, ancestors), + ).toBe(true); + }); + }); + }); }); diff --git a/src/lib/menu/util.ts b/src/lib/menu/util.ts index 704705ac..8edfcc2e 100644 --- a/src/lib/menu/util.ts +++ b/src/lib/menu/util.ts @@ -1,5 +1,7 @@ import type { Menu, MenuItem } from './types'; +import { normalizePath } from '@/util/url'; + import json from '../../content/menu.json'; const menu: Menu = json as Menu; @@ -26,9 +28,11 @@ export const getMenuItems = (keys: string[]): MenuItem[] => { * Finds the active top-level menu item based on the pathname */ export const getActiveMenuItem = (pathname: string): MenuItem => { + const normalizedPathname = normalizePath(pathname); + for (const key in menu) { const item = menu[key]; - if (item.path && pathname.startsWith(item.path)) { + if (item.path && normalizedPathname.startsWith(normalizePath(item.path))) { return item; } } @@ -47,9 +51,13 @@ export const getActiveAncestors = ( items: MenuItem[], ancestors: MenuItem[] = [], ): MenuItem[] => { + const normalizedPathname = normalizePath(pathname); + for (const item of items) { + const normalizedItemPath = item.path ? normalizePath(item.path) : undefined; + // If this item has the exact pathname - if (item.path === pathname) { + if (normalizedItemPath === normalizedPathname) { return ancestors.concat([item]); } @@ -64,3 +72,293 @@ export const getActiveAncestors = ( return []; }; + +/** Collects every menu item whose path exactly matches the pathname. */ +const findAllExactPathMatches = (pathname: string, items: MenuItem[]): MenuItem[] => { + const normalizedPathname = normalizePath(pathname); + const matches: MenuItem[] = []; + + const visit = (menuItems: MenuItem[]) => { + for (const item of menuItems) { + if (item.path && normalizePath(item.path) === normalizedPathname) { + matches.push(item); + } + + if (item.children) { + visit(item.children); + } + } + }; + + visit(items); + return matches; +}; + +/** Returns the ancestor chain from `items` down to `target`, or null when not found. */ +const findMenuItemAncestors = ( + items: MenuItem[], + target: MenuItem, + ancestors: MenuItem[] = [], +): MenuItem[] | null => { + for (const item of items) { + const nextAncestors = ancestors.concat(item); + + if (item === target) { + return nextAncestors; + } + + if (item.children) { + const found = findMenuItemAncestors(item.children, target, nextAncestors); + if (found) { + return found; + } + } + } + + return null; +}; + +/** + * Scores how well a menu item's siblings align with the current pathname. + * Used to pick one entry when multiple menu items share the same path. + */ +const scoreMenuSiblingAffinity = ( + pathname: string, + item: MenuItem, + rootItems: MenuItem[], +): { ratioScore: number; matchCount: number } => { + const ancestors = findMenuItemAncestors(rootItems, item); + if (!ancestors || ancestors.length < 2) { + return { ratioScore: 0, matchCount: 0 }; + } + + const parent = ancestors[ancestors.length - 2]; + const siblings = parent.children ?? []; + const pathSegments = normalizePath(pathname).split('/').filter(Boolean); + const primarySegment = pathSegments[0]; + + if (!primarySegment) { + return { ratioScore: 0, matchCount: 0 }; + } + + const segmentPrefix = `/${primarySegment}`; + const siblingsWithPath = siblings.filter((sibling) => sibling.path); + + if (siblingsWithPath.length === 0) { + return { ratioScore: 0, matchCount: 0 }; + } + + const matchingSiblings = siblingsWithPath.filter((sibling) => { + const siblingPath = getMenuItemBasePath(sibling.path!); + + return siblingPath === segmentPrefix || siblingPath.startsWith(`${segmentPrefix}/`); + }); + + const matchCount = matchingSiblings.length; + const ratioScore = Math.round((matchCount / siblingsWithPath.length) * 10000); + + return { ratioScore, matchCount }; +}; + +/** + * Picks the best leaf when several share a pathname. + * Tie-break order: sibling-path ratio, then absolute match count, then stable DFS order. + */ +const isSiblingAffinityBetter = ( + candidate: { ratioScore: number; matchCount: number }, + best: { ratioScore: number; matchCount: number }, +): boolean => { + if (candidate.ratioScore !== best.ratioScore) { + return candidate.ratioScore > best.ratioScore; + } + + if (candidate.matchCount !== best.matchCount) { + return candidate.matchCount > best.matchCount; + } + + return false; +}; + +const pickCanonicalLeaf = (pathname: string, leafMatches: MenuItem[], rootItems: MenuItem[]): MenuItem => { + return leafMatches.reduce((best, candidate) => { + const candidateMetrics = scoreMenuSiblingAffinity(pathname, candidate, rootItems); + const bestMetrics = scoreMenuSiblingAffinity(pathname, best, rootItems); + + return isSiblingAffinityBetter(candidateMetrics, bestMetrics) ? candidate : best; + }); +}; + +/** + * Resolves the single active menu branch for a pathname. + * When multiple menu entries intentionally share one path (e.g. Integrations aliases and + * Developer Tools both linking to `/developer-tools/vscode`), prefers the branch whose + * siblings best match the pathname prefix. + */ +export const getCanonicalActiveAncestors = ( + pathname: string, + items: MenuItem[], +): MenuItem[] => { + const exactMatches = findAllExactPathMatches(pathname, items); + const leafMatches = exactMatches.filter((item) => !item.children); + + if (leafMatches.length > 1) { + const canonicalLeaf = pickCanonicalLeaf(pathname, leafMatches, items); + + return findMenuItemAncestors(items, canonicalLeaf) ?? []; + } + + if (leafMatches.length === 1) { + return findMenuItemAncestors(items, leafMatches[0]) ?? []; + } + + return getActiveAncestors(pathname, items); +}; + +/** + * Whether the pathname exactly matches this menu item's path. + * Compares full paths as stored in the menu; hash fragments in `item.path` are not + * stripped, so hash-anchor parents (e.g. `/page#section`) never match a bare pathname. + * Use `getMenuItemBasePath` and `isActiveHashAnchorSibling` for hash-anchor behavior. + */ +export const isExactMenuPath = (pathname: string, item: MenuItem): boolean => { + if (!item.path) { + return false; + } + + return normalizePath(item.path) === normalizePath(pathname); +}; + +/** Whether `item` appears in the resolved active ancestor chain. */ +export const isItemInActiveAncestors = (item: MenuItem, activeAncestors: MenuItem[]): boolean => { + return activeAncestors.includes(item); +}; + +/** Whether `item` is the active leaf for the resolved ancestor chain. */ +export const isActiveMenuLeafForAncestors = (item: MenuItem, activeAncestors: MenuItem[]): boolean => { + return !item.children && activeAncestors[activeAncestors.length - 1] === item; +}; + +/** + * Whether this leaf menu item is the current page. + * Only valid when the menu tree has no duplicate paths for `pathname`. + */ +export const isActiveMenuLeaf = (pathname: string, item: MenuItem): boolean => { + return !item.children && isExactMenuPath(pathname, item); +}; + +/** + * Parent menu item whose path is a same-page hash anchor (e.g. `/page#section`). + * Matches on `#` in the menu path only; not suitable if paths gain query strings. + */ +export const isHashAnchorParent = (item: MenuItem): boolean => { + return Boolean(item.children && item.path?.includes('#')); +}; + +/** Path portion of a menu path, without a hash fragment. */ +export const getMenuItemBasePath = (path: string): string => { + return normalizePath(path.split('#')[0] ?? path); +}; + +/** Hash fragment of a menu path, without the leading `#`. */ +export const getMenuItemHash = (path: string): string | undefined => { + const hashIndex = path.indexOf('#'); + if (hashIndex === -1) { + return undefined; + } + + return path.slice(hashIndex + 1); +}; + +/** Normalizes a hash value to a fragment without a leading `#`. */ +export const normalizeHashFragment = (hash: string): string => hash.replace(/^#/, ''); + +/** Whether this branch shares a base path with a hash-anchor sibling. */ +export const hasHashAnchorSiblingOnSameBase = ( + item: MenuItem, + siblingItems: MenuItem[], +): boolean => { + if (!item.path || !item.children || isHashAnchorParent(item)) { + return false; + } + + const basePath = getMenuItemBasePath(item.path); + + return siblingItems.some( + (sibling) => + isHashAnchorParent(sibling) && + sibling.path && + getMenuItemBasePath(sibling.path) === basePath, + ); +}; + +/** Whether any hash-anchor sibling is active for the current location. */ +export const isAnyHashAnchorSiblingActive = ( + pathname: string, + hash: string, + siblingItems: MenuItem[], +): boolean => { + return siblingItems.some((sibling) => isActiveHashAnchorSibling(pathname, sibling, hash)); +}; + +/** Whether a hash-anchor sibling is the active section for the current location. */ +export const isActiveHashAnchorSibling = ( + pathname: string, + item: MenuItem, + hash: string, +): boolean => { + if (!isHashAnchorParent(item) || !item.path) { + return false; + } + + const itemHash = getMenuItemHash(item.path); + const normalizedHash = normalizeHashFragment(hash); + + return Boolean( + itemHash && + normalizedHash === itemHash && + normalizePath(pathname) === getMenuItemBasePath(item.path), + ); +}; + +/** + * Default expanded state for a sidenav branch (active path or section without a path). + */ +export const shouldExpandMenuItem = ( + pathname: string, + item: MenuItem, + hash = '', + siblingItems: MenuItem[] = [], + activeAncestors?: MenuItem[], +): boolean => { + if (!item.path) { + return true; + } + + const normalizedHash = normalizeHashFragment(hash); + const activeHashSibling = siblingItems.find((sibling) => + isActiveHashAnchorSibling(pathname, sibling, normalizedHash), + ); + // Sidenav always passes `activeAncestors` (required when paths repeat). Subtree fallback is for unit tests. + const onActiveBranch = activeAncestors + ? isItemInActiveAncestors(item, activeAncestors) + : getActiveAncestors(pathname, [item]).length > 0; + + if (isHashAnchorParent(item)) { + if (onActiveBranch) { + return true; + } + + return isActiveHashAnchorSibling(pathname, item, normalizedHash); + } + + const activeHashSiblingPath = activeHashSibling?.path; + if ( + activeHashSiblingPath && + normalizePath(pathname) === getMenuItemBasePath(item.path) && + normalizePath(pathname) === getMenuItemBasePath(activeHashSiblingPath) + ) { + return false; + } + + return onActiveBranch; +}; diff --git a/src/lib/windowLocationHash.ts b/src/lib/windowLocationHash.ts new file mode 100644 index 00000000..4f862c6f --- /dev/null +++ b/src/lib/windowLocationHash.ts @@ -0,0 +1,21 @@ +// Copyright 2026 Cloudsmith Ltd + +'use client'; + +import { useSyncExternalStore } from 'react'; + +/** Subscribes to URL hash changes (address bar, history, in-app navigation). */ +export const subscribeToWindowHash = (onStoreChange: () => void) => { + window.addEventListener('hashchange', onStoreChange); + window.addEventListener('popstate', onStoreChange); + + return () => { + window.removeEventListener('hashchange', onStoreChange); + window.removeEventListener('popstate', onStoreChange); + }; +}; + +export const getWindowHash = () => window.location.hash; + +/** Subscribes to `window.location.hash` (SSR snapshot is empty). */ +export const useWindowHash = () => useSyncExternalStore(subscribeToWindowHash, getWindowHash, () => ''); diff --git a/src/util/url.js b/src/util/url.js index fecb6589..6f406655 100644 --- a/src/util/url.js +++ b/src/util/url.js @@ -1,3 +1,15 @@ +/** + * Normalizes a URL path for comparison by stripping trailing slashes. + * Root path `/` is returned unchanged. + */ +export const normalizePath = (path) => { + if (path === '/') { + return path; + } + + return path.replace(/\/+$/, ''); +}; + /** * Joins fragments of a path together, ensuring there is a single slash between each fragment. * Optionally also ensures there is a trailing slash. diff --git a/src/util/url.test.ts b/src/util/url.test.ts new file mode 100644 index 00000000..6475b9da --- /dev/null +++ b/src/util/url.test.ts @@ -0,0 +1,12 @@ +import { normalizePath } from './url'; + +describe('normalizePath', () => { + test('strips trailing slashes', () => { + expect(normalizePath('/authentication/')).toEqual('/authentication'); + expect(normalizePath('/workspaces')).toEqual('/workspaces'); + }); + + test('leaves root path unchanged', () => { + expect(normalizePath('/')).toEqual('/'); + }); +});