Skip to content

Commit 755dee5

Browse files
authored
fix(docs): copy section permalinks and polish outline navigation (#9972)
* fix(docs): copy section permalinks and polish outline navigation * test(docs): verify focus returns from outline links
1 parent 6649d8a commit 755dee5

4 files changed

Lines changed: 188 additions & 12 deletions

File tree

‎apps/docs/components/docs/table-of-contents.tsx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,18 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo
1919
{headings.map(({ id, title, depth }) => (
2020
<li key={id}>
2121
<a
22-
href={`#${id}`}
22+
href={`#${encodeURIComponent(id)}`}
2323
aria-current={active === id ? 'location' : undefined}
2424
onClick={(event) => {
25+
if (
26+
event.defaultPrevented ||
27+
event.button !== 0 ||
28+
event.metaKey ||
29+
event.ctrlKey ||
30+
event.shiftKey ||
31+
event.altKey
32+
)
33+
return
2534
const details = event.currentTarget.closest('details')
2635
if (details) details.open = false
2736
}}
@@ -40,7 +49,16 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo
4049
</ul>
4150
)
4251
return compact ? (
43-
<details className='group relative min-w-0'>
52+
<details
53+
className='group relative min-w-0'
54+
onKeyDown={(event) => {
55+
if (event.key !== 'Escape' || !event.currentTarget.open) return
56+
event.preventDefault()
57+
event.stopPropagation()
58+
event.currentTarget.open = false
59+
event.currentTarget.querySelector('summary')?.focus()
60+
}}
61+
>
4462
<summary className='flex min-h-11 list-none items-center gap-2 text-docs-control [&::-webkit-details-marker]:hidden'>
4563
On this page
4664
<ChevronDown className='size-4 transition-transform group-open:rotate-180' />
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { useCopyToClipboard } from '@sim/emcn'
5+
import { Check, Link, TriangleAlert } from '@sim/emcn/icons'
6+
import { cn } from '@/components/docs/classnames'
7+
8+
interface HeadingLinkProps {
9+
id: string
10+
}
11+
12+
/** Copies a section permalink while retaining native anchor navigation. */
13+
export function HeadingLink({ id }: HeadingLinkProps) {
14+
const { copied, copy } = useCopyToClipboard()
15+
const [failed, setFailed] = useState(false)
16+
const announcement = failed
17+
? 'Could not copy. Copy the link from the address bar.'
18+
: copied
19+
? 'Link copied'
20+
: ''
21+
const Icon = failed ? TriangleAlert : copied ? Check : Link
22+
23+
return (
24+
<>
25+
<a
26+
href={`#${encodeURIComponent(id)}`}
27+
aria-label={announcement || 'Copy link to this section'}
28+
title={announcement || 'Copy link to this section'}
29+
onClick={async (event) => {
30+
if (
31+
event.defaultPrevented ||
32+
event.button !== 0 ||
33+
event.metaKey ||
34+
event.ctrlKey ||
35+
event.shiftKey ||
36+
event.altKey
37+
)
38+
return
39+
setFailed(!(await copy(event.currentTarget.href)))
40+
}}
41+
className={cn(
42+
'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',
43+
(copied || failed) && 'opacity-100'
44+
)}
45+
>
46+
<Icon className='size-4' />
47+
</a>
48+
<span className='sr-only' role='status'>
49+
{announcement}
50+
</span>
51+
</>
52+
)
53+
}

‎apps/docs/components/ui/heading.tsx‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { HTMLAttributes } from 'react'
2-
import { Link } from '@sim/emcn/icons'
32
import { cn } from '@/components/docs/classnames'
3+
import { HeadingLink } from '@/components/ui/heading-link'
44

55
type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
66
interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
@@ -29,15 +29,7 @@ export function Heading({ as: Tag = 'h2', className, children, id, ...props }: H
2929
)}
3030
>
3131
{children}
32-
{id && (
33-
<a
34-
href={`#${id}`}
35-
aria-label='Link to this section'
36-
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'
37-
>
38-
<Link className='size-4' />
39-
</a>
40-
)}
32+
{id && <HeadingLink id={id} />}
4133
</Tag>
4234
)
4335
}

‎apps/docs/scripts/test-navigation-e2e.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,119 @@ try {
178178
? await (engine === 'webkit' ? webkit : chromium).launch()
179179
: await webkit.connect(webkitEndpoint, { timeout: 180_000 })
180180
const feedbackBrowser = browser
181+
await check(
182+
'Section permalinks copy the full URL and preserve native link navigation',
183+
async () => {
184+
const context = await feedbackBrowser.newContext({
185+
permissions: ['clipboard-read', 'clipboard-write'],
186+
viewport: { width: 1440, height: 900 },
187+
})
188+
try {
189+
const page = await context.newPage()
190+
await open(page, '/files/editor?view=reference')
191+
const formatting = page.locator('#formatting-text').getByRole('link')
192+
await formatting.click()
193+
const first = new URL('/files/editor?view=reference#formatting-text', target).href
194+
await expect(page).toHaveURL(first)
195+
await expect(formatting).toHaveAccessibleName('Link copied')
196+
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(first)
197+
198+
const tables = page.locator('#tables').getByRole('link')
199+
await tables.focus()
200+
await page.keyboard.press('Enter')
201+
const second = new URL('/files/editor?view=reference#tables', target).href
202+
await expect(page).toHaveURL(second)
203+
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(second)
204+
const [newTab] = await Promise.all([
205+
context.waitForEvent('page', { timeout: 10_000 }),
206+
page
207+
.locator('#code-blocks')
208+
.getByRole('link')
209+
.click({ modifiers: ['ControlOrMeta'] }),
210+
])
211+
await newTab.waitForLoadState('domcontentloaded')
212+
await expect(newTab).toHaveURL(
213+
new URL('/files/editor?view=reference#code-blocks', target).href
214+
)
215+
await newTab.close()
216+
await page.bringToFront()
217+
await expect(page).toHaveURL(second)
218+
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(second)
219+
await page.screenshot({ path: join(directory, 'section-permalink.png') })
220+
} finally {
221+
await context.close()
222+
}
223+
},
224+
engine === 'webkit'
225+
? 'Playwright WebKit cannot grant clipboard read/write permissions; Chromium verifies native clipboard contents.'
226+
: undefined
227+
)
228+
await check('Section permalink navigation survives an unavailable clipboard', async () => {
229+
const context = await feedbackBrowser.newContext()
230+
try {
231+
await context.addInitScript(() => {
232+
Object.defineProperty(navigator, 'clipboard', { value: undefined })
233+
})
234+
const page = await context.newPage()
235+
await open(page, '/files/editor')
236+
const link = page.locator('#formatting-text').getByRole('link')
237+
await link.click()
238+
await expect(page).toHaveURL(new URL('/files/editor#formatting-text', target).href)
239+
await expect(link).toHaveAccessibleName('Could not copy. Copy the link from the address bar.')
240+
await expect(page.getByRole('status').filter({ hasText: 'Could not copy.' })).toBeVisible()
241+
await page.screenshot({ path: join(directory, 'section-permalink-copy-unavailable.png') })
242+
await page
243+
.getByRole('navigation', { name: 'Page navigation', exact: true })
244+
.getByRole('link', { name: 'Previous Files', exact: true })
245+
.click()
246+
await expect(page).toHaveURL(new URL('/files', target).href)
247+
await expect(
248+
page.getByRole('link', { name: 'Could not copy. Copy the link from the address bar.' })
249+
).toHaveCount(0)
250+
} finally {
251+
await context.close()
252+
}
253+
})
254+
await check(
255+
'Compact page outline dismisses with Escape and preserves new-tab clicks',
256+
async () => {
257+
for (const width of [390, 1100]) {
258+
const context = await feedbackBrowser.newContext({ viewport: { width, height: 900 } })
259+
try {
260+
const page = await context.newPage()
261+
await open(page, '/files/editor')
262+
const summary = page
263+
.locator('summary')
264+
.filter({ hasText: 'On this page' })
265+
.filter({ visible: true })
266+
const details = summary.locator('..')
267+
await summary.click()
268+
await expect(details).toHaveAttribute('open', '')
269+
await details.getByRole('link', { name: 'Formatting text', exact: true }).focus()
270+
await page.keyboard.press('Escape')
271+
await expect(details).not.toHaveAttribute('open')
272+
await expect(summary).toBeFocused()
273+
274+
await summary.click()
275+
const link = details.getByRole('link', { name: 'Formatting text', exact: true })
276+
const [newTab] = await Promise.all([
277+
context.waitForEvent('page', { timeout: 10_000 }),
278+
link.click({ modifiers: ['ControlOrMeta'] }),
279+
])
280+
await newTab.waitForLoadState('domcontentloaded')
281+
await expect(newTab).toHaveURL(new URL('/files/editor#formatting-text', target).href)
282+
await expect(details).toHaveAttribute('open', '')
283+
await newTab.close()
284+
await link.click()
285+
await expect(page).toHaveURL(new URL('/files/editor#formatting-text', target).href)
286+
await expect(details).not.toHaveAttribute('open')
287+
await page.screenshot({ path: join(directory, `section-outline-${width}.png`) })
288+
} finally {
289+
await context.close()
290+
}
291+
}
292+
}
293+
)
181294
await check('Feedback forwards votes and preserves unchanged retry identity', async () => {
182295
const cert = process.env.DOCS_FEEDBACK_TEST_CERT
183296
const key = process.env.DOCS_FEEDBACK_TEST_KEY

0 commit comments

Comments
 (0)