diff --git a/site/src/components/MobileToC.astro b/site/src/components/MobileToC.astro index c5e892fd..71d73d46 100644 --- a/site/src/components/MobileToC.astro +++ b/site/src/components/MobileToC.astro @@ -68,7 +68,7 @@ const { tocItems } = Astro.props document.body.style.overflow = 'hidden' toggleButton?.setAttribute('aria-expanded', 'true') setInert(false) - ;(closeButton ?? toc.querySelector('a'))?.focus() + ;(closeButton ?? toc.querySelector('a'))?.focus({ preventScroll: true }) } } function closeToC() { @@ -80,7 +80,7 @@ const { tocItems } = Astro.props document.body.style.overflow = '' toggleButton?.setAttribute('aria-expanded', 'false') setInert(true) - if (hadFocus) toggleButton?.focus() + if (hadFocus) toggleButton?.focus({ preventScroll: true }) } } // Event listeners diff --git a/site/src/styles/global.css b/site/src/styles/global.css index 56abe681..088d3967 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -16,8 +16,10 @@ @plugin "@tailwindcss/typography"; @plugin "@libtmux/theme"; -/* Body padding supplies the space before leading declaration metadata. */ -dl.py > dd > .gp-sphinx-api-mentions:first-child { +/* Body padding supplies the space before leading metadata or section content. */ +dl.py > dd > .gp-sphinx-api-mentions:first-child, +dl.py > dd > .gp-sphinx-api-description:first-child > :first-child, +dl.py > dd > .gp-sphinx-api-examples:first-child > :first-child { margin-block-start: 0; } diff --git a/site/test/api-glossary-navigation.test.ts b/site/test/api-glossary-navigation.test.ts index f413c40e..fd65f624 100644 --- a/site/test/api-glossary-navigation.test.ts +++ b/site/test/api-glossary-navigation.test.ts @@ -303,6 +303,27 @@ function drawerState(win: Window, open: boolean) { expect(doc.body.style.overflow).toBe(open ? 'hidden' : '') } +it.each([undefined, 'mcp' as const])('requests scroll-preserving %s contents focus on open and dismissal', async (product) => { + const win = await contentsPage(product, 1036) + hydrateDrawers(win) + const doc = win.document + const toggle = doc.getElementById('mobile-toc-toggle')! as HTMLElement + const close = doc.getElementById('mobile-toc-close')! as HTMLElement + toggle.focus() + const enter = vi.spyOn(close, 'focus') + const restore = vi.spyOn(toggle, 'focus') + toggle.click() + drawerState(win, true) + expect(doc.activeElement).toBe(close) + doc.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + drawerState(win, false) + expect(doc.activeElement).toBe(toggle) + expect({ enter: enter.mock.calls, restore: restore.mock.calls }).toEqual({ + enter: [[{ preventScroll: true }]], + restore: [[{ preventScroll: true }]], + }) +}) + it('keeps open contents through the sidebar handoff and cleans up at its desktop handoff', async () => { const win = await contentsPage('mcp', 1023) hydrateDrawers(win) diff --git a/site/test/declaration-body-spacing.test.ts b/site/test/declaration-body-spacing.test.ts new file mode 100644 index 00000000..7f71a861 --- /dev/null +++ b/site/test/declaration-body-spacing.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'node:fs' +import { Window, type Element } from 'happy-dom' +import { afterEach, expect, it } from 'vitest' + +const globalCss = readFileSync(new URL('../src/styles/global.css', import.meta.url), 'utf8') +// Exercise the ordinary unlayered edge rule without asking Happy DOM to compile Tailwind directives. +const edgeRules = globalCss.slice(globalCss.indexOf('/* Body padding'), globalCss.indexOf('/* Font-face')) +const css = edgeRules + readFileSync(new URL('../src/styles/vendor/gp-sphinx-api.css', import.meta.url), 'utf8') +let win: Window +afterEach(async () => { await win?.happyDOM.abort() }) + +function mount(bodies: string[]) { + win = new Window({ settings: { disableCSSFileLoading: true, disableJavaScriptEvaluation: true } }) + win.document.documentElement.style.fontSize = '16px' + win.document.head.innerHTML = `` + win.document.body.innerHTML = bodies.map((body, index) => + `
Declaration ${index}
${body}
`).join('') + return [...win.document.querySelectorAll('dl.py > dd')] +} + +// Happy DOM exposes logical and physical margins separately; box geometry belongs to browser acceptance. +function start(element: Element) { + const style = win.getComputedStyle(element) + const value = style.marginBlockStart || style.marginTop + return value === '0' ? '0px' : value +} + +it('lets body padding own a true first heading or paragraph inside an owned section', () => { + const bodies = mount([ + '

Description

Swift Server.

', + '

Examples

Run this.

server.sessions()
', + '

Paragraph-first prose.

Later heading

', + '

Paragraph-first example.

server.sessions()
', + ]) + for (const body of bodies) { + const first = body.firstElementChild!.firstElementChild! + expect(start(first)).toBe('0px') + expect(win.getComputedStyle(first).marginBottom).toBe('8px') + const intro = body.querySelector('.gp-sphinx-api-example-intro') + if (intro?.previousElementSibling) expect(start(intro)).toBe('12px') + const style = win.getComputedStyle(body) + expect([style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft]).toEqual(['12px', '16px', '12px', '16px']) + } +}) + +it('preserves later section headings, prose and example-introduction spacing', () => { + const [body] = mount([ + '

Summary before sections.

Description

First paragraph.

Interior heading

Later paragraph.

Examples

Example introduction.

server.sessions()
', + ]) + for (const heading of body!.querySelectorAll('.rubric')) { + expect(start(heading)).toBe('16px') + expect(win.getComputedStyle(heading).marginBottom).toBe('8px') + } + for (const paragraph of body!.querySelectorAll('.gp-sphinx-api-description p')) { + expect(start(paragraph)).toBe('0px') + expect(win.getComputedStyle(paragraph).marginBottom).toBe('8px') + } + expect(start(body!.querySelector('.gp-sphinx-api-example-intro')!)).toBe('12px') +}) + +it('keeps compact metadata and excludes wrapped, later and unowned content from the edge reset', () => { + const [metadata, wrapped, firstProse, unowned] = mount([ + '

Discussed in Guide

In other ports.

', + '', + '

Real prose precedes the heading.

Interior heading

', + '

Native-owned heading

', + ]) + expect(start(metadata!.firstElementChild!)).toBe('0px') + expect(start(metadata!.lastElementChild!)).toBe('8px') + expect(start(wrapped!.querySelector('.gp-sphinx-api-mentions')!)).toBe('8px') + expect(start(firstProse!.querySelector('.rubric')!)).toBe('16px') + expect(start(unowned!.querySelector('.rubric')!)).toBe('16px') +})