From c145f3e046f99171bc0fb8381a35844f23cb9b15 Mon Sep 17 00:00:00 2001 From: Anthony Lio Date: Thu, 10 Sep 2026 15:03:55 +0300 Subject: [PATCH] fix(docs): guide nav collapsible parity (#49945) ## What kind of change does this PR introduce? visual parity fix and refresh + component extraction (stacked on #49942) ## What is the current behavior? guide and reference sidebars each hand-roll their own collapsible section visuals ## What is the new behavior? - adds `NavSection` composition components (`NavSectionCaret`, `NavSectionContent`, `NavSectionList`) shared by both navs via radix `asChild`, so the rail, caret, and motion have a single source of truth - fixes ui drift between both so navs get the same left rail beside expanded children, the same caret and animation - enhances link click area so space between rows is part of the click target | state | preview | | -------|------| | before | image | | after | image | ## How to test? 1. run `pnpm dev:docs` 2. open [guide page](http://localhost:3001/docs/guides/integrations/build-a-supabase-oauth-integration) 3. open [reference page](http://localhost:3001/docs/reference/dart/introduction) ## Summary by CodeRabbit * **New Features** * Added animated expand/collapse behavior and rotating caret indicators to documentation navigation sections. * Added active-child indicators for clearer navigation context. * **Improvements** * Standardized spacing, borders, and animation styles across guide and reference navigation. * Improved collapsible animations to support varying content sizes more reliably. * Navigation items without links or child content, including disabled nested items, are no longer displayed. --- .../docs/components/Navigation/NavSection.tsx | 25 +++++ .../NavigationMenuGuideListItems.tsx | 98 +++++++++++++------ .../docs/Reference.navigation.client.tsx | 44 +++++---- packages/config/css/animations.css | 4 +- 4 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 apps/docs/components/Navigation/NavSection.tsx diff --git a/apps/docs/components/Navigation/NavSection.tsx b/apps/docs/components/Navigation/NavSection.tsx new file mode 100644 index 00000000000..7ff5c7bbd12 --- /dev/null +++ b/apps/docs/components/Navigation/NavSection.tsx @@ -0,0 +1,25 @@ +import { ChevronDown } from 'lucide-react' +import type { HTMLAttributes } from 'react' +import { cn } from 'ui' + +export const NavSectionCaret = ({ className }: { className?: string }) => ( + +) + +export const NavSectionList = ({ className, ...props }: HTMLAttributes) => ( +
    +) + +export const NavSectionContent = ({ className, ...props }: HTMLAttributes) => ( +
    +) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx index 89b64e78b9d..c859d3a31ee 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx @@ -1,4 +1,8 @@ -import { ChevronDown } from 'lucide-react' +import { + NavSectionCaret, + NavSectionContent, + NavSectionList, +} from '~/components/Navigation/NavSection' import { useTheme } from 'next-themes' import Image from 'next/legacy/image' import Link from 'next/link' @@ -10,14 +14,23 @@ import MenuIconPicker from './MenuIconPicker' type NavAccordionItem = { url?: string + enabled?: boolean items?: NavAccordionItem[] } function hasActiveDescendant(item: NavAccordionItem, pathname: string): boolean { + if (item.enabled === false) return false if (item.url === pathname) return true return item.items?.some((child) => hasActiveDescendant(child, pathname)) ?? false } +function isRenderable(item: NavAccordionItem): boolean { + if (item.enabled === false) return false + if (item.url) return true + + return item.items?.some(isRenderable) ?? false +} + const HeaderLink = React.memo(function HeaderLink(props: { title: string id: string @@ -41,13 +54,19 @@ const HeaderLink = React.memo(function HeaderLink(props: { const ContentAccordionLink = React.memo(function ContentAccordionLink(props: any) { const pathname = usePathname() const { resolvedTheme } = useTheme() - const activeItem = props.subItem.url === pathname + const childItems = props.subItem.items ?? [] + const activeItem = + props.subItem.url === pathname && + !childItems.some((child: NavAccordionItem) => child.url === pathname) const activeItemRef = useRef(null) - const hasChildren = props.subItem.items && props.subItem.items.length > 0 + const enabledChildren = childItems.filter(isRenderable) + const hasChildren = enabledChildren.length > 0 - const isChildActive = - hasChildren && - props.subItem.items.some((child: NavAccordionItem) => hasActiveDescendant(child, pathname)) + const isChildActive = enabledChildren.some((child: NavAccordionItem) => + hasActiveDescendant(child, pathname) + ) + + const accordionValue = props.subItem.url || props.subItem.name const LinkContainer = (props) => { const isExternal = props.url.startsWith('https://') @@ -73,6 +92,9 @@ const ContentAccordionLink = React.memo(function ContentAccordionLink(props: any }, 0) } }) + + if (!hasChildren && !props.subItem.url) return null + return (
  • {hasChildren ? ( @@ -80,13 +102,14 @@ const ContentAccordionLink = React.memo(function ContentAccordionLink(props: any collapsible type="single" className="space-y-0.5" - defaultValue={isChildActive ? props.subItem.url : undefined} + defaultValue={isChildActive ? accordionValue : undefined} > - + - + - -
      - {props.subItem.items - .filter((subItem) => subItem.enabled !== false) - .map((subSubItem) => { - if (subSubItem.items && subSubItem.items.length > 0) { - return + + + + {enabledChildren.map((child) => { + if (child.items && child.items.length > 0) { + return } return ( -
    • +
    • - {subSubItem.name} + {child.url === pathname && ( + + )} + {child.name}
    • ) })} -
    + +
    @@ -142,6 +172,7 @@ const ContentAccordionLink = React.memo(function ContentAccordionLink(props: any className={[ 'flex items-center gap-2', 'cursor-pointer transition text-sm', + 'focus-inset rounded-md', activeItem ? 'text-brand-link font-medium' : 'hover:text-foreground text-foreground-lighter', @@ -174,6 +205,7 @@ const ContentLink = React.memo(function ContentLink(props: any) { href={props.url} className={[ 'cursor-pointer transition text-sm', + 'focus-inset rounded-md', props.url === pathname ? 'text-brand-link' : 'hover:text-foreground text-foreground-lighter', @@ -205,31 +237,33 @@ const Content = (props) => {
      - {menu.items.map((x) => { - if (x.enabled === false) return null + {menu.items.map((entry) => { + if (entry.enabled === false) return null - if (x.items && x.items.length > 0) { - const enabledItems = x.items.filter((item) => item.enabled !== false) + if (entry.items && entry.items.length > 0) { + const enabledItems = entry.items.filter(isRenderable) if (enabledItems.length === 0) return null return ( -
    • +
    • - {x.name} + {entry.name}
        - {enabledItems.map((subItem) => { - return - })} + {enabledItems.map((subItem) => ( + + ))}
    • ) } - return x.url ? : null + return entry.url ? ( + + ) : null })}
  • diff --git a/apps/docs/features/docs/Reference.navigation.client.tsx b/apps/docs/features/docs/Reference.navigation.client.tsx index b676573baaf..1716b2c27ba 100644 --- a/apps/docs/features/docs/Reference.navigation.client.tsx +++ b/apps/docs/features/docs/Reference.navigation.client.tsx @@ -1,10 +1,14 @@ 'use client' +import { + NavSectionCaret, + NavSectionContent, + NavSectionList, +} from '~/components/Navigation/NavSection' import type { AbbrevApiReferenceSection } from '~/features/docs/Reference.utils' import { isElementInViewport } from '~/features/ui/helpers.dom' import { BASE_PATH } from '~/lib/constants' import { debounce } from 'lodash-es' -import { ChevronUp } from 'lucide-react' import Link from 'next/link' import { usePathname } from 'next/navigation' import { Collapsible } from 'radix-ui' @@ -350,28 +354,26 @@ function CompoundRefLink({ )} > {section.title} - + - -
      - {(section.items || []).map((item, idx) => { - return ( -
    • - -
    • - ) - })} -
    + + + + {(section.items || []).map((item, idx) => { + return ( +
  • + +
  • + ) + })} +
    +
    ) diff --git a/packages/config/css/animations.css b/packages/config/css/animations.css index 3e1362a5d4a..77e0d959e16 100644 --- a/packages/config/css/animations.css +++ b/packages/config/css/animations.css @@ -149,14 +149,14 @@ opacity: 0; } 100% { - height: var(--radix-accordion-content-height); + height: var(--radix-accordion-content-height, var(--radix-collapsible-content-height)); opacity: 1; } } @keyframes slideUp { 0% { - height: var(--radix-accordion-content-height); + height: var(--radix-accordion-content-height, var(--radix-collapsible-content-height)); opacity: 1; } 100% {