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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions apps/docs/components/docs/table-of-contents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,18 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo
{headings.map(({ id, title, depth }) => (
<li key={id}>
<a
href={`#${id}`}
href={`#${encodeURIComponent(id)}`}
aria-current={active === id ? 'location' : undefined}
onClick={(event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return
const details = event.currentTarget.closest('details')
if (details) details.open = false
}}
Expand All @@ -40,7 +49,16 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo
</ul>
)
return compact ? (
<details className='group relative min-w-0'>
<details
className='group relative min-w-0'
onKeyDown={(event) => {
if (event.key !== 'Escape' || !event.currentTarget.open) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.open = false
event.currentTarget.querySelector('summary')?.focus()
}}
>
<summary className='flex min-h-11 list-none items-center gap-2 text-docs-control [&::-webkit-details-marker]:hidden'>
On this page
<ChevronDown className='size-4 transition-transform group-open:rotate-180' />
Expand Down
53 changes: 53 additions & 0 deletions apps/docs/components/ui/heading-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use client'

import { useState } from 'react'
import { useCopyToClipboard } from '@sim/emcn'
import { Check, Link, TriangleAlert } from '@sim/emcn/icons'
import { cn } from '@/components/docs/classnames'

interface HeadingLinkProps {
id: string
}

/** Copies a section permalink while retaining native anchor navigation. */
export function HeadingLink({ id }: HeadingLinkProps) {
const { copied, copy } = useCopyToClipboard()
const [failed, setFailed] = useState(false)
const announcement = failed
? 'Could not copy. Copy the link from the address bar.'
: copied
? 'Link copied'
: ''
const Icon = failed ? TriangleAlert : copied ? Check : Link

return (
<>
<a
href={`#${encodeURIComponent(id)}`}
aria-label={announcement || 'Copy link to this section'}
title={announcement || 'Copy link to this section'}
onClick={async (event) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return
setFailed(!(await copy(event.currentTarget.href)))
}}
className={cn(
'pointer-coarse:before:-inset-3.5 relative ml-2 inline-flex rounded align-middle text-[var(--docs-muted)] opacity-0 pointer-coarse:opacity-100 transition-opacity pointer-coarse:before:absolute hover:text-[var(--docs-link)] focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2 group-hover/heading:opacity-100 motion-reduce:transition-none',
(copied || failed) && 'opacity-100'
)}
>
<Icon className='size-4' />
</a>
<span className='sr-only' role='status'>
{announcement}
</span>
</>
)
}
12 changes: 2 additions & 10 deletions apps/docs/components/ui/heading.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { HTMLAttributes } from 'react'
import { Link } from '@sim/emcn/icons'
import { cn } from '@/components/docs/classnames'
import { HeadingLink } from '@/components/ui/heading-link'

type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
Expand Down Expand Up @@ -29,15 +29,7 @@ export function Heading({ as: Tag = 'h2', className, children, id, ...props }: H
)}
>
{children}
{id && (
<a
href={`#${id}`}
aria-label='Link to this section'
className='ml-2 inline-flex rounded align-middle text-[var(--docs-muted)] opacity-0 pointer-coarse:opacity-100 transition-opacity hover:text-[var(--docs-link)] focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2 group-hover/heading:opacity-100 motion-reduce:transition-none'
>
<Link className='size-4' />
</a>
)}
{id && <HeadingLink id={id} />}
</Tag>
)
}
113 changes: 113 additions & 0 deletions apps/docs/scripts/test-navigation-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,119 @@ try {
? await (engine === 'webkit' ? webkit : chromium).launch()
: await webkit.connect(webkitEndpoint, { timeout: 180_000 })
const feedbackBrowser = browser
await check(
'Section permalinks copy the full URL and preserve native link navigation',
async () => {
const context = await feedbackBrowser.newContext({
permissions: ['clipboard-read', 'clipboard-write'],
viewport: { width: 1440, height: 900 },
})
try {
const page = await context.newPage()
await open(page, '/files/editor?view=reference')
const formatting = page.locator('#formatting-text').getByRole('link')
await formatting.click()
const first = new URL('/files/editor?view=reference#formatting-text', target).href
await expect(page).toHaveURL(first)
await expect(formatting).toHaveAccessibleName('Link copied')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(first)

const tables = page.locator('#tables').getByRole('link')
await tables.focus()
await page.keyboard.press('Enter')
const second = new URL('/files/editor?view=reference#tables', target).href
await expect(page).toHaveURL(second)
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(second)
const [newTab] = await Promise.all([
context.waitForEvent('page', { timeout: 10_000 }),
page
.locator('#code-blocks')
.getByRole('link')
.click({ modifiers: ['ControlOrMeta'] }),
])
await newTab.waitForLoadState('domcontentloaded')
await expect(newTab).toHaveURL(
new URL('/files/editor?view=reference#code-blocks', target).href
)
await newTab.close()
await page.bringToFront()
await expect(page).toHaveURL(second)
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(second)
await page.screenshot({ path: join(directory, 'section-permalink.png') })
} finally {
await context.close()
}
},
engine === 'webkit'
? 'Playwright WebKit cannot grant clipboard read/write permissions; Chromium verifies native clipboard contents.'
: undefined
)
await check('Section permalink navigation survives an unavailable clipboard', async () => {
const context = await feedbackBrowser.newContext()
try {
await context.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', { value: undefined })
})
const page = await context.newPage()
await open(page, '/files/editor')
const link = page.locator('#formatting-text').getByRole('link')
await link.click()
await expect(page).toHaveURL(new URL('/files/editor#formatting-text', target).href)
await expect(link).toHaveAccessibleName('Could not copy. Copy the link from the address bar.')
await expect(page.getByRole('status').filter({ hasText: 'Could not copy.' })).toBeVisible()
await page.screenshot({ path: join(directory, 'section-permalink-copy-unavailable.png') })
await page
.getByRole('navigation', { name: 'Page navigation', exact: true })
.getByRole('link', { name: 'Previous Files', exact: true })
.click()
await expect(page).toHaveURL(new URL('/files', target).href)
await expect(
page.getByRole('link', { name: 'Could not copy. Copy the link from the address bar.' })
).toHaveCount(0)
} finally {
await context.close()
}
})
await check(
'Compact page outline dismisses with Escape and preserves new-tab clicks',
async () => {
for (const width of [390, 1100]) {
const context = await feedbackBrowser.newContext({ viewport: { width, height: 900 } })
try {
const page = await context.newPage()
await open(page, '/files/editor')
const summary = page
.locator('summary')
.filter({ hasText: 'On this page' })
.filter({ visible: true })
const details = summary.locator('..')
await summary.click()
await expect(details).toHaveAttribute('open', '')
await details.getByRole('link', { name: 'Formatting text', exact: true }).focus()
await page.keyboard.press('Escape')
await expect(details).not.toHaveAttribute('open')
await expect(summary).toBeFocused()
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved

await summary.click()
const link = details.getByRole('link', { name: 'Formatting text', exact: true })
const [newTab] = await Promise.all([
context.waitForEvent('page', { timeout: 10_000 }),
link.click({ modifiers: ['ControlOrMeta'] }),
])
await newTab.waitForLoadState('domcontentloaded')
await expect(newTab).toHaveURL(new URL('/files/editor#formatting-text', target).href)
await expect(details).toHaveAttribute('open', '')
await newTab.close()
await link.click()
await expect(page).toHaveURL(new URL('/files/editor#formatting-text', target).href)
await expect(details).not.toHaveAttribute('open')
await page.screenshot({ path: join(directory, `section-outline-${width}.png`) })
} finally {
await context.close()
}
}
}
)
await check('Feedback forwards votes and preserves unchanged retry identity', async () => {
const cert = process.env.DOCS_FEEDBACK_TEST_CERT
const key = process.env.DOCS_FEEDBACK_TEST_KEY
Expand Down
Loading