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
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .claude/rules/emcn-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items

## Component catalogue

- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). Variants: `primary`, `destructive`, `border-shadow`, `border`; the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`.
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`.
- **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner `<input>`); `className` styles the chrome wrapper.
- **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit.
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
Expand Down
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