Skip to content

Commit 7e9dd2a

Browse files
authored
improvement(docs): simplify navigation and share editor previews (#7606)
* improvement(docs): simplify navigation and share editor previews * fix(docs): preserve focus in workflow illustrations
1 parent 589bf9e commit 7e9dd2a

474 files changed

Lines changed: 6854 additions & 5258 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/app/[[...slug]]/page.tsx

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type React from 'react'
2+
import { Children, cloneElement, isValidElement } from 'react'
23
import { highlight } from 'fumadocs-core/highlight'
34
import { findNeighbour } from 'fumadocs-core/page-tree'
45
import type { ApiPageProps } from 'fumadocs-openapi/ui'
56
import { createAPIPage } from 'fumadocs-openapi/ui'
67
import { Pre } from 'fumadocs-ui/components/codeblock'
78
import defaultMdxComponents from 'fumadocs-ui/mdx'
8-
import { DocsBody, DocsPage, DocsTitle } from 'fumadocs-ui/page'
9+
import { DocsBody, DocsPage } from 'fumadocs-ui/page'
910
import { notFound } from 'next/navigation'
1011
import { PageFooter } from '@/components/docs-layout/page-footer'
12+
import { PageHeader } from '@/components/docs-layout/page-header'
1113
import { PageNavigationArrows } from '@/components/docs-layout/page-navigation-arrows'
1214
import { LLMCopyButton } from '@/components/page-actions'
1315
import { StructuredData } from '@/components/structured-data'
@@ -51,6 +53,24 @@ async function ApiCodeBlock({ lang, code }: { lang: string; code: string }) {
5153
)
5254
}
5355

56+
interface ApiSlotElementProps extends React.HTMLAttributes<HTMLElement> {
57+
items?: unknown[]
58+
}
59+
60+
/** Labels Fumadocs auth selectors while retaining their selection state and content. */
61+
function labelAuthSelectors(node: React.ReactNode): React.ReactNode {
62+
if (!isValidElement<ApiSlotElementProps>(node)) return node
63+
64+
const props = node.props
65+
const children =
66+
props.children === undefined ? undefined : Children.map(props.children, labelAuthSelectors)
67+
68+
if (Array.isArray(props.items)) {
69+
return cloneElement(node, { 'aria-label': 'Authentication method' }, children)
70+
}
71+
return children === undefined ? node : cloneElement(node, undefined, children)
72+
}
73+
5474
const APIPage = createAPIPage(openapi, {
5575
renderCodeBlock: (props) => <ApiCodeBlock {...props} />,
5676
playground: { enabled: false },
@@ -68,12 +88,14 @@ const APIPage = createAPIPage(openapi, {
6888
content: {
6989
renderOperationLayout: (slots) => {
7090
return (
71-
<div className='flex @4xl:flex-row flex-col @4xl:items-start gap-x-6 gap-y-4'>
91+
<div className='flex @4xl:flex-row flex-col @4xl:items-start gap-x-6 gap-y-4 [--badge-amber-text:#854d0e] [--badge-blue-text:#1e40af] [--badge-error-text:#991b1b] [--badge-orange-text:#9a3412] [--badge-success-text:#166534] dark:[--badge-amber-text:#fcd34d] dark:[--badge-blue-text:#93c5fd] dark:[--badge-error-text:#fca5a5] dark:[--badge-orange-text:#fdba74] dark:[--badge-success-text:#86efac] [&_button[aria-haspopup=dialog]]:min-h-6'>
7292
<div className='min-w-0 flex-1'>
7393
{slots.header}
7494
{slots.description}
7595
{slots.apiPlayground}
76-
{slots.authSchemes && <div className='api-section-divider'>{slots.authSchemes}</div>}
96+
{slots.authSchemes && (
97+
<div className='api-section-divider'>{labelAuthSelectors(slots.authSchemes)}</div>
98+
)}
7799
{slots.parameters}
78100
{slots.body && <div className='api-section-divider'>{slots.body}</div>}
79101
<ResponseSection>{slots.responses}</ResponseSection>
@@ -179,6 +201,8 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
179201
breadcrumb={breadcrumbs}
180202
/>
181203
<DocsPage
204+
role='main'
205+
tabIndex={-1}
182206
toc={data.toc.filter(isContentHeading)}
183207
breadcrumb={{
184208
enabled: false,
@@ -196,15 +220,12 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
196220
component: footer,
197221
}}
198222
>
199-
<div className='api-page-header relative mt-6 sm:mt-0'>
200-
<div className='absolute top-1 right-0 flex items-center gap-2'>
201-
<div className='hidden sm:flex'>
202-
<LLMCopyButton content={apiPageContent} />
203-
</div>
204-
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
223+
<PageHeader title={data.title} className='api-page-header'>
224+
<div className='hidden sm:flex'>
225+
<LLMCopyButton content={apiPageContent} />
205226
</div>
206-
<DocsTitle className='mb-2'>{data.title}</DocsTitle>
207-
</div>
227+
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
228+
</PageHeader>
208229
<DocsBody>
209230
<APIPage {...apiProps} />
210231
</DocsBody>
@@ -225,6 +246,8 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
225246
breadcrumb={breadcrumbs}
226247
/>
227248
<DocsPage
249+
role='main'
250+
tabIndex={-1}
228251
toc={data.toc.filter(isContentHeading)}
229252
full={data.full || isAcademy}
230253
breadcrumb={{
@@ -244,15 +267,12 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
244267
component: footer,
245268
}}
246269
>
247-
<div className='relative mt-6 sm:mt-0'>
248-
<div className='absolute top-1 right-0 flex items-center gap-2'>
249-
<div className='hidden sm:flex'>
250-
<LLMCopyButton content={markdownContent} />
251-
</div>
252-
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
270+
<PageHeader title={data.title}>
271+
<div className='hidden sm:flex'>
272+
<LLMCopyButton content={markdownContent} />
253273
</div>
254-
<DocsTitle className='mb-2'>{data.title}</DocsTitle>
255-
</div>
274+
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
275+
</PageHeader>
256276
<DocsBody>
257277
<MDX
258278
components={{

apps/docs/app/layout.tsx

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import type { ReactNode } from 'react'
22
import { DocsLayout } from 'fumadocs-ui/layouts/docs'
3+
import { SidebarProvider, SidebarTrigger, useSidebar } from 'fumadocs-ui/layouts/docs/slots/sidebar'
34
import { RootProvider } from 'fumadocs-ui/provider/next'
45
import type { Viewport } from 'next'
56
import { Inter } from 'next/font/google'
67
import { ThemeProvider } from 'next-themes'
7-
import {
8-
SidebarFolder,
9-
SidebarItem,
10-
SidebarSeparator,
11-
} from '@/components/docs-layout/sidebar-components'
8+
import { DocsSidebar } from '@/components/docs-layout/docs-sidebar'
129
import { Footer } from '@/components/footer/footer'
1310
import { Navbar } from '@/components/navbar/navbar'
1411
import { SimWordmark } from '@/components/ui/sim-logo'
@@ -61,11 +58,22 @@ export default function RootLayout({ children }: { children: ReactNode }) {
6158
disableTransitionOnChange
6259
>
6360
<RootProvider theme={{ enabled: false }}>
61+
<a
62+
href='#nd-page'
63+
className='sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-[100] focus:rounded-lg focus:bg-[var(--bg)] focus:px-4 focus:py-2 focus:text-[var(--text-primary)]'
64+
>
65+
Skip to content
66+
</a>
6467
<Navbar />
6568
<DocsLayout
6669
tree={source.pageTree}
6770
nav={{
68-
title: <SimWordmark className='h-[18px]' />,
71+
title: (
72+
<>
73+
<SimWordmark className='h-[18px]' />
74+
<span className='sr-only'>Sim documentation home</span>
75+
</>
76+
),
6977
}}
7078
sidebar={{
7179
tabs: false,
@@ -74,14 +82,18 @@ export default function RootLayout({ children }: { children: ReactNode }) {
7482
footer: null,
7583
banner: null,
7684
prefetch: false,
77-
components: {
78-
Item: SidebarItem,
79-
Folder: SidebarFolder,
80-
Separator: SidebarSeparator,
85+
}}
86+
slots={{
87+
sidebar: {
88+
provider: SidebarProvider,
89+
root: DocsSidebar,
90+
trigger: SidebarTrigger,
91+
useSidebar,
8192
},
8293
}}
8394
containerProps={{
84-
className: '!pt-0',
95+
className:
96+
'!pt-0 [--text-muted:var(--text-secondary)] [--color-fd-muted-foreground:var(--text-secondary)]',
8597
}}
8698
>
8799
{children}

apps/docs/app/not-found.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ export const metadata = {
77

88
export default function NotFound() {
99
return (
10-
<DocsPage>
10+
<DocsPage role='main' tabIndex={-1}>
1111
<div className='flex min-h-[60vh] flex-col items-center justify-center px-4 py-24 text-center'>
1212
<div className='flex w-full max-w-[410px] flex-col items-center gap-3'>
1313
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
1414
Page not found
1515
</h1>
16-
<p className='text-[var(--text-muted)] text-lg'>
16+
<p className='text-[var(--text-secondary)] text-lg'>
1717
The page you&apos;re looking for doesn&apos;t exist or has been moved.
1818
</p>
1919
<div className='mt-3 flex flex-wrap items-center justify-center gap-2'>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/** @vitest-environment jsdom */
2+
import { act, type ComponentProps, createElement, type ReactNode } from 'react'
3+
import { createRoot, type Root } from 'react-dom/client'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { DocsSidebar } from '@/components/docs-layout/docs-sidebar'
6+
7+
const state = vi.hoisted(() => ({ mobile: false, open: false, setOpen: vi.fn() }))
8+
vi.mock('fumadocs-core/utils/use-media-query', () => ({ useMediaQuery: () => state.mobile }))
9+
vi.mock('fumadocs-ui/components/sidebar/base', () => ({ useSidebar: () => state }))
10+
vi.mock('next/navigation', () => ({ usePathname: () => '/integrations/zendesk' }))
11+
vi.mock('fumadocs-ui/contexts/tree', () => ({
12+
useTreeContext: () => ({
13+
root: {
14+
$id: 'docs',
15+
children: [{ type: 'page', name: 'Zendesk', url: '/integrations/zendesk' }],
16+
},
17+
}),
18+
}))
19+
vi.mock('@/components/docs-layout/sidebar-components', () => ({
20+
SidebarItem: ({ item }: { item: { name: string; url: string } }) =>
21+
createElement('a', { href: item.url, 'aria-current': 'page' }, item.name),
22+
SidebarFolder: ({ children }: { children: ReactNode }) => children,
23+
SidebarSeparator: () => null,
24+
}))
25+
vi.mock('@sim/emcn', () => ({
26+
cn: (...classes: string[]) => classes.join(' '),
27+
Chip: ({ leftIcon: _icon, ...props }: ComponentProps<'button'> & { leftIcon?: unknown }) =>
28+
createElement('button', props),
29+
ChipLink: ({ onNavigate, ...props }: ComponentProps<'a'> & { onNavigate?: () => void }) =>
30+
createElement('a', {
31+
...props,
32+
onClick: (event) => {
33+
event.preventDefault()
34+
onNavigate?.()
35+
},
36+
}),
37+
useScrollEdges: () => ({ top: false, bottom: false }),
38+
scrollFadeAttributes: () => ({}),
39+
scrollFadeClass: 'scroll-fade',
40+
}))
41+
vi.mock('@sim/emcn/icons', () => ({ X: () => null }))
42+
43+
let container: HTMLDivElement
44+
let root: Root
45+
46+
beforeEach(() => {
47+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
48+
state.mobile = false
49+
state.open = false
50+
state.setOpen.mockClear()
51+
HTMLDialogElement.prototype.showModal = function () {
52+
this.open = true
53+
}
54+
HTMLDialogElement.prototype.close = function () {
55+
this.open = false
56+
}
57+
container = document.createElement('div')
58+
document.body.appendChild(container)
59+
root = createRoot(container)
60+
})
61+
62+
afterEach(() => {
63+
act(() => root.unmount())
64+
container.remove()
65+
vi.restoreAllMocks()
66+
vi.unstubAllGlobals()
67+
})
68+
69+
function render() {
70+
act(() => root.render(<DocsSidebar />))
71+
}
72+
73+
describe('documentation sidebar surfaces', () => {
74+
it('renders one desktop navigation tree', () => {
75+
render()
76+
expect(container.querySelectorAll('aside[aria-label="Documentation navigation"]')).toHaveLength(
77+
1
78+
)
79+
expect(container.querySelectorAll('a[aria-current="page"]')).toHaveLength(1)
80+
expect(container.querySelector('dialog')).toBeNull()
81+
})
82+
83+
it('opens the mobile modal and synchronizes native dismissal', () => {
84+
state.mobile = true
85+
state.open = true
86+
render()
87+
const dialog = container.querySelector('dialog')!
88+
expect(dialog.open).toBe(true)
89+
expect(container.querySelector('aside')).toBeNull()
90+
expect(container.querySelectorAll('a[aria-current="page"]')).toHaveLength(1)
91+
act(() => dialog.dispatchEvent(new Event('close')))
92+
expect(state.setOpen).toHaveBeenCalledWith(false)
93+
})
94+
95+
it('clears mobile disclosure state when resizing to desktop', () => {
96+
state.mobile = true
97+
state.open = true
98+
render()
99+
state.mobile = false
100+
render()
101+
expect(container.querySelector('dialog')).toBeNull()
102+
expect(state.setOpen).toHaveBeenCalledWith(false)
103+
})
104+
105+
it('scrolls a deep selected row into view without scrolling the document', () => {
106+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
107+
this: Element
108+
) {
109+
return DOMRect.fromRect(
110+
this.hasAttribute('aria-current')
111+
? { x: 0, y: 800, width: 250, height: 30 }
112+
: { x: 0, y: 0, width: 280, height: 600 }
113+
)
114+
})
115+
render()
116+
expect(container.querySelector('.scroll-fade')?.scrollTop).toBe(242)
117+
expect(document.documentElement.scrollTop).toBe(0)
118+
})
119+
})

0 commit comments

Comments
 (0)