Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
58 changes: 39 additions & 19 deletions apps/docs/app/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type React from 'react'
import { Children, cloneElement, isValidElement } from 'react'
import { highlight } from 'fumadocs-core/highlight'
import { findNeighbour } from 'fumadocs-core/page-tree'
import type { ApiPageProps } from 'fumadocs-openapi/ui'
import { createAPIPage } from 'fumadocs-openapi/ui'
import { Pre } from 'fumadocs-ui/components/codeblock'
import defaultMdxComponents from 'fumadocs-ui/mdx'
import { DocsBody, DocsPage, DocsTitle } from 'fumadocs-ui/page'
import { DocsBody, DocsPage } from 'fumadocs-ui/page'
import { notFound } from 'next/navigation'
import { PageFooter } from '@/components/docs-layout/page-footer'
import { PageHeader } from '@/components/docs-layout/page-header'
import { PageNavigationArrows } from '@/components/docs-layout/page-navigation-arrows'
import { LLMCopyButton } from '@/components/page-actions'
import { StructuredData } from '@/components/structured-data'
Expand Down Expand Up @@ -51,6 +53,24 @@ async function ApiCodeBlock({ lang, code }: { lang: string; code: string }) {
)
}

interface ApiSlotElementProps extends React.HTMLAttributes<HTMLElement> {
items?: unknown[]
}

/** Labels Fumadocs auth selectors while retaining their selection state and content. */
function labelAuthSelectors(node: React.ReactNode): React.ReactNode {
if (!isValidElement<ApiSlotElementProps>(node)) return node

const props = node.props
const children =
props.children === undefined ? undefined : Children.map(props.children, labelAuthSelectors)

if (Array.isArray(props.items)) {
return cloneElement(node, { 'aria-label': 'Authentication method' }, children)
}
return children === undefined ? node : cloneElement(node, undefined, children)
}

const APIPage = createAPIPage(openapi, {
renderCodeBlock: (props) => <ApiCodeBlock {...props} />,
playground: { enabled: false },
Expand All @@ -68,12 +88,14 @@ const APIPage = createAPIPage(openapi, {
content: {
renderOperationLayout: (slots) => {
return (
<div className='flex @4xl:flex-row flex-col @4xl:items-start gap-x-6 gap-y-4'>
<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'>
<div className='min-w-0 flex-1'>
{slots.header}
{slots.description}
{slots.apiPlayground}
{slots.authSchemes && <div className='api-section-divider'>{slots.authSchemes}</div>}
{slots.authSchemes && (
<div className='api-section-divider'>{labelAuthSelectors(slots.authSchemes)}</div>
)}
{slots.parameters}
{slots.body && <div className='api-section-divider'>{slots.body}</div>}
<ResponseSection>{slots.responses}</ResponseSection>
Expand Down Expand Up @@ -179,6 +201,8 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
breadcrumb={breadcrumbs}
/>
<DocsPage
role='main'
tabIndex={-1}
toc={data.toc.filter(isContentHeading)}
breadcrumb={{
enabled: false,
Expand All @@ -196,15 +220,12 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
component: footer,
}}
>
<div className='api-page-header relative mt-6 sm:mt-0'>
<div className='absolute top-1 right-0 flex items-center gap-2'>
<div className='hidden sm:flex'>
<LLMCopyButton content={apiPageContent} />
</div>
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
<PageHeader title={data.title} className='api-page-header'>
<div className='hidden sm:flex'>
<LLMCopyButton content={apiPageContent} />
</div>
<DocsTitle className='mb-2'>{data.title}</DocsTitle>
</div>
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
</PageHeader>
<DocsBody>
<APIPage {...apiProps} />
</DocsBody>
Expand All @@ -225,6 +246,8 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
breadcrumb={breadcrumbs}
/>
<DocsPage
role='main'
tabIndex={-1}
toc={data.toc.filter(isContentHeading)}
full={data.full || isAcademy}
breadcrumb={{
Expand All @@ -244,15 +267,12 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
component: footer,
}}
>
<div className='relative mt-6 sm:mt-0'>
<div className='absolute top-1 right-0 flex items-center gap-2'>
<div className='hidden sm:flex'>
<LLMCopyButton content={markdownContent} />
</div>
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
<PageHeader title={data.title}>
<div className='hidden sm:flex'>
<LLMCopyButton content={markdownContent} />
</div>
<DocsTitle className='mb-2'>{data.title}</DocsTitle>
</div>
<PageNavigationArrows previous={neighbours?.previous} next={neighbours?.next} />
</PageHeader>
<DocsBody>
<MDX
components={{
Expand Down
34 changes: 23 additions & 11 deletions apps/docs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import type { ReactNode } from 'react'
import { DocsLayout } from 'fumadocs-ui/layouts/docs'
import { SidebarProvider, SidebarTrigger, useSidebar } from 'fumadocs-ui/layouts/docs/slots/sidebar'
import { RootProvider } from 'fumadocs-ui/provider/next'
import type { Viewport } from 'next'
import { Inter } from 'next/font/google'
import { ThemeProvider } from 'next-themes'
import {
SidebarFolder,
SidebarItem,
SidebarSeparator,
} from '@/components/docs-layout/sidebar-components'
import { DocsSidebar } from '@/components/docs-layout/docs-sidebar'
import { Footer } from '@/components/footer/footer'
import { Navbar } from '@/components/navbar/navbar'
import { SimWordmark } from '@/components/ui/sim-logo'
Expand Down Expand Up @@ -61,11 +58,22 @@ export default function RootLayout({ children }: { children: ReactNode }) {
disableTransitionOnChange
>
<RootProvider theme={{ enabled: false }}>
<a
href='#nd-page'
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)]'
>
Skip to content
</a>
<Navbar />
<DocsLayout
tree={source.pageTree}
nav={{
title: <SimWordmark className='h-[18px]' />,
title: (
<>
<SimWordmark className='h-[18px]' />
<span className='sr-only'>Sim documentation home</span>
</>
),
}}
sidebar={{
tabs: false,
Expand All @@ -74,14 +82,18 @@ export default function RootLayout({ children }: { children: ReactNode }) {
footer: null,
banner: null,
prefetch: false,
components: {
Item: SidebarItem,
Folder: SidebarFolder,
Separator: SidebarSeparator,
}}
slots={{
sidebar: {
provider: SidebarProvider,
root: DocsSidebar,
trigger: SidebarTrigger,
useSidebar,
},
}}
containerProps={{
className: '!pt-0',
className:
'!pt-0 [--text-muted:var(--text-secondary)] [--color-fd-muted-foreground:var(--text-secondary)]',
}}
>
{children}
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ export const metadata = {

export default function NotFound() {
return (
<DocsPage>
<DocsPage role='main' tabIndex={-1}>
<div className='flex min-h-[60vh] flex-col items-center justify-center px-4 py-24 text-center'>
<div className='flex w-full max-w-[410px] flex-col items-center gap-3'>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Page not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
<p className='text-[var(--text-secondary)] text-lg'>
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className='mt-3 flex flex-wrap items-center justify-center gap-2'>
Expand Down
119 changes: 119 additions & 0 deletions apps/docs/components/docs-layout/docs-sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/** @vitest-environment jsdom */
import { act, type ComponentProps, createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DocsSidebar } from '@/components/docs-layout/docs-sidebar'

const state = vi.hoisted(() => ({ mobile: false, open: false, setOpen: vi.fn() }))
vi.mock('fumadocs-core/utils/use-media-query', () => ({ useMediaQuery: () => state.mobile }))
vi.mock('fumadocs-ui/components/sidebar/base', () => ({ useSidebar: () => state }))
vi.mock('next/navigation', () => ({ usePathname: () => '/integrations/zendesk' }))
vi.mock('fumadocs-ui/contexts/tree', () => ({
useTreeContext: () => ({
root: {
$id: 'docs',
children: [{ type: 'page', name: 'Zendesk', url: '/integrations/zendesk' }],
},
}),
}))
vi.mock('@/components/docs-layout/sidebar-components', () => ({
SidebarItem: ({ item }: { item: { name: string; url: string } }) =>
createElement('a', { href: item.url, 'aria-current': 'page' }, item.name),
SidebarFolder: ({ children }: { children: ReactNode }) => children,
SidebarSeparator: () => null,
}))
vi.mock('@sim/emcn', () => ({
cn: (...classes: string[]) => classes.join(' '),
Chip: ({ leftIcon: _icon, ...props }: ComponentProps<'button'> & { leftIcon?: unknown }) =>
createElement('button', props),
ChipLink: ({ onNavigate, ...props }: ComponentProps<'a'> & { onNavigate?: () => void }) =>
createElement('a', {
...props,
onClick: (event) => {
event.preventDefault()
onNavigate?.()
},
}),
useScrollEdges: () => ({ top: false, bottom: false }),
scrollFadeAttributes: () => ({}),
scrollFadeClass: 'scroll-fade',
}))
vi.mock('@sim/emcn/icons', () => ({ X: () => null }))

let container: HTMLDivElement
let root: Root

beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
state.mobile = false
state.open = false
state.setOpen.mockClear()
HTMLDialogElement.prototype.showModal = function () {
this.open = true
}
HTMLDialogElement.prototype.close = function () {
this.open = false
}
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

function render() {
act(() => root.render(<DocsSidebar />))
}

describe('documentation sidebar surfaces', () => {
it('renders one desktop navigation tree', () => {
render()
expect(container.querySelectorAll('aside[aria-label="Documentation navigation"]')).toHaveLength(
1
)
expect(container.querySelectorAll('a[aria-current="page"]')).toHaveLength(1)
expect(container.querySelector('dialog')).toBeNull()
})

it('opens the mobile modal and synchronizes native dismissal', () => {
state.mobile = true
state.open = true
render()
const dialog = container.querySelector('dialog')!
expect(dialog.open).toBe(true)
expect(container.querySelector('aside')).toBeNull()
expect(container.querySelectorAll('a[aria-current="page"]')).toHaveLength(1)
act(() => dialog.dispatchEvent(new Event('close')))
expect(state.setOpen).toHaveBeenCalledWith(false)
})

it('clears mobile disclosure state when resizing to desktop', () => {
state.mobile = true
state.open = true
render()
state.mobile = false
render()
expect(container.querySelector('dialog')).toBeNull()
expect(state.setOpen).toHaveBeenCalledWith(false)
})

it('scrolls a deep selected row into view without scrolling the document', () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
this: Element
) {
return DOMRect.fromRect(
this.hasAttribute('aria-current')
? { x: 0, y: 800, width: 250, height: 30 }
: { x: 0, y: 0, width: 280, height: 600 }
)
})
render()
expect(container.querySelector('.scroll-fade')?.scrollTop).toBe(242)
expect(document.documentElement.scrollTop).toBe(0)
})
})
Loading
Loading