mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>
|
||||
)
|
||||
|
||||
@@ -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% {
|
||||
|
||||
Reference in new issue
Block a user