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 | <img width="430" height="288" alt="image"
src="https://github.com/user-attachments/assets/0052d4b7-7793-43cf-8416-2a5445b95148"
/> |
| after | <img width="430" height="288" alt="image"
src="https://github.com/user-attachments/assets/42713ee3-e147-4e53-a58d-3f3de278264d"
/> |

## 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)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Anthony Lio authored and GitHub committed 2026-09-10 15:03:55 +03:00
1 parent bdd4b8d369
commit c145f3e046
4 files changed
+116 -55

No files matched your search

@@ -0,0 +1,25 @@
import { ChevronDown } from 'lucide-react'
import type { HTMLAttributes } from 'react'
import { cn } from 'ui'
export const NavSectionCaret = ({ className }: { className?: string }) => (
<ChevronDown
width={16}
className={cn('data-open-parent:rotate-0 data-closed-parent:-rotate-90 transition', className)}
/>
)
export const NavSectionList = ({ className, ...props }: HTMLAttributes<HTMLUListElement>) => (
<ul className={cn('leading-5', className)} {...props} />
)
export const NavSectionContent = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'border-l border-muted pl-3 ml-1 mt-0.5',
'overflow-hidden data-open:animate-slide-down data-closed:animate-slide-up motion-reduce:animate-none',
className
)}
{...props}
/>
)
@@ -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<HTMLLIElement>(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 (
<li ref={!hasChildren && activeItem ? activeItemRef : null}>
{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}
>
<Accordion.Item key={props.subItem.url || props.subItem.name} value={props.subItem.url}>
<Accordion.Item key={accordionValue} value={accordionValue}>
<Accordion.Trigger
className={[
'flex items-center gap-2 w-full',
'cursor-pointer transition text-sm',
'focus-inset rounded-md',
activeItem
? 'text-brand-link font-medium'
: 'hover:text-foreground text-foreground-lighter',
@@ -104,35 +127,42 @@ const ContentAccordionLink = React.memo(function ContentAccordionLink(props: any
)}
{props.subItem.name}
</div>
<ChevronDown className="w-4 h-4 transition-transform data-open-parent:rotate-180" />
<NavSectionCaret />
</span>
</Accordion.Trigger>
<Accordion.Content className="transition data-open:animate-slide-down data-closed:animate-slide-up ml-2">
<ul>
{props.subItem.items
.filter((subItem) => subItem.enabled !== false)
.map((subSubItem) => {
if (subSubItem.items && subSubItem.items.length > 0) {
return <ContentAccordionLink key={subSubItem.name} subItem={subSubItem} />
<Accordion.Content asChild>
<NavSectionContent>
<NavSectionList>
{enabledChildren.map((child) => {
if (child.items && child.items.length > 0) {
return <ContentAccordionLink key={child.name} subItem={child} />
}
return (
<li key={`${props.subItem.name}-${subSubItem.url}`}>
<li key={`${props.subItem.name}-${child.url}`}>
<Link
href={`${subSubItem.url}`}
href={child.url}
className={[
'cursor-pointer transition text-sm',
subSubItem.url === pathname
'relative block py-1.25 pl-1 -ml-1 cursor-pointer transition text-sm',
'focus-inset rounded-md',
child.url === pathname
? 'text-brand-link'
: 'hover:text-brand-link text-foreground-lighter',
].join(' ')}
>
{subSubItem.name}
{child.url === pathname && (
<span
aria-hidden
className="absolute left-[-13px] top-1/2 h-[1em] w-px -translate-y-1/2 bg-current"
/>
)}
{child.name}
</Link>
</li>
)
})}
</ul>
</NavSectionList>
</NavSectionContent>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
@@ -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) => {
</Link>
<ul data-testid="docs-guide-navigation-list" className="flex flex-col gap-0">
{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 (
<li key={x.name}>
<li key={entry.name}>
<div className="flex flex-col gap-2.5">
<div className="h-px w-full bg-border my-3"></div>
<span className="font-mono text-xs uppercase text-foreground font-medium tracking-wider">
{x.name}
{entry.name}
</span>
<ul className="flex flex-col gap-2.5">
{enabledItems.map((subItem) => {
return <ContentAccordionLink key={subItem.name} subItem={subItem} />
})}
{enabledItems.map((subItem) => (
<ContentAccordionLink key={subItem.name} subItem={subItem} />
))}
</ul>
</div>
</li>
)
}
return x.url ? <ContentLink url={x.url} icon={x.icon} name={x.name} key={x.name} /> : null
return entry.url ? (
<ContentLink url={entry.url} icon={entry.icon} name={entry.name} key={entry.name} />
) : null
})}
</ul>
</div>
@@ -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({
)}
>
<span className={getLinkStyles(false)}>{section.title}</span>
<ChevronUp
width={16}
className={cn(
'group-disabled:cursor-not-allowed group-disabled:opacity-10',
'data-open-parent:rotate-0 data-closed-parent:rotate-90',
'transition'
)}
/>
<NavSectionCaret className="group-disabled:cursor-not-allowed group-disabled:opacity-10" />
</button>
</Collapsible.Trigger>
<Collapsible.Content
className={cn('border-l border-control pl-3 ml-1 data-open:mt-2 grid gap-2.5')}
>
<ul className="space-y-2">
{(section.items || []).map((item, idx) => {
return (
<li key={`${section.id}-${idx}`}>
<RefLink basePath={basePath} section={item} realNavigation={realNavigation} />
</li>
)
})}
</ul>
<Collapsible.Content asChild>
<NavSectionContent>
<NavSectionList>
{(section.items || []).map((item, idx) => {
return (
<li key={`${section.id}-${idx}`}>
<RefLink
basePath={basePath}
section={item}
className="block py-1.25"
realNavigation={realNavigation}
/>
</li>
)
})}
</NavSectionList>
</NavSectionContent>
</Collapsible.Content>
</Collapsible.Root>
)
+2 -2
View File
@@ -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% {