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
65 changes: 13 additions & 52 deletions src/report/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export function mountReport(
void mountedDiffs.initialRender.then(finishInitialRender)
const { mounted } = mountedDiffs
wireLayout(mounted)
wireGlobalFolds()
prepareForPrint()
}

Expand Down Expand Up @@ -273,6 +272,19 @@ function wireSectionFolds() {
fold.addEventListener('toggle', sync)
sync()
}
document.querySelectorAll<HTMLElement>('[data-section-fold-all]').forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
const section = button.closest<HTMLElement>('.section')
if (!section) return
const open = button.dataset.sectionFoldAll === 'unfold'
for (const details of section.querySelectorAll<HTMLDetailsElement>('details.file')) {
details.open = open
}
button.focus({ preventScroll: true })
})
})
}

function wireFragments(initialRender: Promise<void>) {
Expand Down Expand Up @@ -329,61 +341,10 @@ function wireLayout(mounted: MountedDiff[]) {
})
}

// The global control is one toggle: it folds every section while any is open and
// unfolds them once they are all closed. It only touches `open` on existing section
// folds, so the report data is untouched and a single section still toggles natively.
function sectionFolds(): HTMLDetailsElement[] {
return [...document.querySelectorAll<HTMLDetailsElement>('details.section-fold')]
}

// The focused child of a fold that just closed is no longer rendered, but browsers can
// leave focus on it. Only the fold's own summary row stays visible.
function hiddenByClosedFold(element: Element): boolean {
for (let node: Element | null = element; node; node = node.parentElement) {
if (!node.matches('details:not([open])')) continue
const summary = node.querySelector(':scope > summary')
// A closed details hides its content but not its own summary row, so keep
// walking: an outer closed details can still hide that summary.
if (summary && (summary === element || summary.contains(element))) continue
return true
}
return false
}

function wireGlobalFolds() {
const button = document.querySelector<HTMLButtonElement>('[data-fold-all]')
if (!button) return
const label = button.querySelector<HTMLElement>('[data-fold-all-label]')
const folds = sectionFolds()
if (folds.length === 0) return

const allFolded = () => folds.every((fold) => !fold.open)
const sync = () => {
const folded = allFolded()
if (label) label.textContent = folded ? 'Unfold all' : 'Fold all'
button.setAttribute(
'aria-label',
folded ? 'Unfold all review sections' : 'Fold all review sections',
)
}

button.addEventListener('click', () => {
// Read focus before closing: a browser blurs a descendant the moment its
// ancestor details closes, so afterwards activeElement may already be body.
const active = document.activeElement
const unfold = allFolded()
for (const fold of folds) fold.open = unfold
if (active instanceof HTMLElement && hiddenByClosedFold(active)) {
const section = active.closest('details.section-fold')
const summary = section?.querySelector<HTMLElement>('.section-toggle')
;(summary ?? button).focus({ preventScroll: true })
}
sync()
})
for (const fold of folds) fold.addEventListener('toggle', sync)
sync()
}

function prepareForPrint() {
window.addEventListener('beforeprint', () => {
for (const details of document.querySelectorAll<HTMLDetailsElement>('details')) {
Expand Down
12 changes: 7 additions & 5 deletions src/report/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ function renderReportBody(
<label><input type="radio" name="layout" value="unified" ${layout === 'unified' ? 'checked' : ''}> Unified</label>
<button type="submit" hidden aria-hidden="true" tabindex="-1"></button>
</form>`
const foldAll = `<button type="button" class="fold-all" data-fold-all aria-label="Fold all review sections"><span data-fold-all-label>Fold all</span></button>`
const readingControls = `<div class="review-controls">${layoutForm}${foldAll}</div>`
const readingControls = `<div class="review-controls">${layoutForm}</div>`
const reviewMap = renderReviewMap(
document.sections.map((section, index) => ({
title: section.title,
Expand Down Expand Up @@ -178,7 +177,7 @@ function renderSection(
<div class="file-summary">${label} <span class="file-stats">Renamed · content unchanged</span></div>
</div>`
}
return `<details class="file" open>
return `<details class="file">
<summary class="file-summary">${label} <span class="file-stats">+${stats.additions} −${stats.deletions}</span></summary>
<div class="file-diff" data-diff-mount="${index}-${stepIndex}-${fileIndex}"></div>
</details>`
Expand All @@ -195,7 +194,7 @@ function renderSection(

const markup = `<section class="section" id="${target.fragment}" data-section-index="${index}" data-target-kind="section">
<details class="section-fold" open>
<summary class="section-title" tabindex="-1"><button type="button" class="section-toggle" aria-expanded="true" aria-controls="${target.fragment}" aria-label="Toggle section ${sectionIndex(index)}: ${escapeHtml(section.title)}"><span class="section-toggle-arrow" aria-hidden="true">▾</span> <span class="section-title-index">${sectionIndex(index)}</span></button><a class="section-title-text" href="#${target.fragment}">${escapeHtml(section.title)}</a></summary>
<summary class="section-title" tabindex="-1"><button type="button" class="section-toggle" aria-expanded="true" aria-controls="${target.fragment}" aria-label="Toggle section ${sectionIndex(index)}: ${escapeHtml(section.title)}"><span class="section-toggle-arrow" aria-hidden="true">▾</span> <span class="section-title-index">${sectionIndex(index)}</span></button><a class="section-title-text" href="#${target.fragment}">${escapeHtml(section.title)}</a><span class="section-fold-controls"><button type="button" class="section-fold-all" data-section-fold-all="unfold" aria-label="Unfold all in section ${sectionIndex(index)}">Unfold all</button><button type="button" class="section-fold-all" data-section-fold-all="fold" aria-label="Fold all in section ${sectionIndex(index)}">Fold all</button></span></summary>
${steps.join('\n')}
</details>
</section>`
Expand Down Expand Up @@ -488,7 +487,10 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; }
.section-title-index { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82em; }
.section-title-text { min-width: 0; color: inherit; text-decoration: none; }
.section-title-text:hover { text-decoration: underline; }
.section-toggle:focus-visible, .section-title-text:focus-visible, .permalink:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.section-fold-controls { display: flex; gap: 6px; margin-left: auto; font-size: 12px; font-weight: 500; }
.section-fold-all { border: 1px solid #cbd7cd; border-radius: 5px; padding: 4px 7px; color: #4d6654; background: #f7faf7; cursor: pointer; font: inherit; white-space: nowrap; }
.section-fold-all:hover { color: var(--accent); border-color: #8eaa95; background: #eef5ef; }
.section-toggle:focus-visible, .section-title-text:focus-visible, .section-fold-all:focus-visible, .permalink:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.section-fold > summary::-webkit-details-marker { display: none; }
.section-fold[open] > summary { border-bottom-color: var(--border); }
.prose { color: #3c4d41; font-size: 14px; }
Expand Down
106 changes: 28 additions & 78 deletions test/report-dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -800,8 +800,8 @@ describe('report browser client', () => {
expect(doc.querySelector('[data-copy-fragment]')).toBeNull()
expect(doc.querySelector('a[href="#change-001"]')).toBeNull()

summary.click()
expect(fold.open).toBe(true)
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }) as unknown as Event)
expect(fold.open).toBe(false)
const click = new dom.window.MouseEvent('click', { bubbles: true, cancelable: true })
title.dispatchEvent(click as unknown as Event)
expect(click.defaultPrevented).toBe(false)
Expand Down Expand Up @@ -1023,7 +1023,7 @@ describe('report browser client', () => {
)

test(
'one control folds and unfolds every section and individual folds still work',
'each section control folds and unfolds only its own section details',
async () => {
const value = document([
section(simplePatch('one', 'one!'), 'First section'),
Expand All @@ -1037,51 +1037,27 @@ describe('report browser client', () => {
const dataBefore = dataScript.textContent
runReportClient()

const button = doc.querySelector<HTMLButtonElement>('[data-fold-all]')!
const folds = [...doc.querySelectorAll<HTMLDetailsElement>('details.section-fold')]
const label = () => button.querySelector('[data-fold-all-label]')?.textContent
const aria = () => button.getAttribute('aria-label')
expect(folds).toHaveLength(2)
expect(folds.every((fold) => fold.open)).toBe(true)
expect(label()).toBe('Fold all')
expect(aria()).toBe('Fold all review sections')

button.click()
expect(folds.every((fold) => !fold.open)).toBe(true)
expect(label()).toBe('Unfold all')
expect(aria()).toBe('Unfold all review sections')

button.click()
expect(folds.every((fold) => fold.open)).toBe(true)
expect(label()).toBe('Fold all')
expect(aria()).toBe('Fold all review sections')
expect([...doc.querySelectorAll<HTMLDetailsElement>('details.file')].every((file) => !file.open)).toBe(true)

expect(dataScript.textContent).toBe(dataBefore)

// Individual section buttons still fold and unfold after the global action,
// and their toggle drives the global label.
folds[0]!
.querySelector('.section-toggle')!
.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }) as unknown as Event)
expect(folds[0]!.open).toBe(false)
const first = doc.querySelector<HTMLElement>('.section')!
first.querySelector<HTMLButtonElement>('[data-section-fold-all="unfold"]')!.click()
expect(folds[0]!.open).toBe(true)
expect(folds[1]!.open).toBe(true)
expect(label()).toBe('Fold all')

folds[1]!
.querySelector('.section-toggle')!
.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }) as unknown as Event)
expect(folds.every((fold) => !fold.open)).toBe(true)
expect(label()).toBe('Unfold all')

button.click()
expect(first.querySelector<HTMLDetailsElement>('details.file')?.open).toBe(true)
first.querySelector<HTMLButtonElement>('[data-section-fold-all="fold"]')!.click()
expect(folds.every((fold) => fold.open)).toBe(true)
expect(label()).toBe('Fold all')
expect(first.querySelector<HTMLDetailsElement>('details.file')?.open).toBe(false)
expect(doc.querySelectorAll('.step-text')).toHaveLength(2)
},
120000,
)

test(
'folding all moves focus from a hidden child to the visible section row',
'folding all keeps section content and the control visible',
async () => {
const value = document([
{
Expand All @@ -1100,28 +1076,13 @@ describe('report browser client', () => {
runReportClient()

const firstSection = doc.querySelector<HTMLElement>('.section')!
const focused = firstSection.querySelector<HTMLAnchorElement>(
'.step-actions > a',
)!
focused.focus()
expect(doc.activeElement).toBe(focused)

// Real browsers blur a focused descendant the moment its ancestor details
// closes; happy-dom keeps focus, so simulate that blur to prove the global
// action still restores focus on the surviving section row.
for (const fold of doc.querySelectorAll<HTMLDetailsElement>('details.section-fold')) {
fold.addEventListener('toggle', () => {
if (!fold.open && fold.contains(focused)) focused.blur()
})
}

doc.querySelector<HTMLButtonElement>('[data-fold-all]')!.click()
const foldAll = firstSection.querySelector<HTMLButtonElement>('[data-section-fold-all="fold"]')!
foldAll.click()

const folds = [...doc.querySelectorAll<HTMLDetailsElement>('details.section-fold')]
expect(folds.every((fold) => !fold.open)).toBe(true)
expect(firstSection.querySelector('details')?.open).toBe(false)
const summary = firstSection.querySelector<HTMLElement>('.section-toggle')!
expect(doc.activeElement).toBe(summary)
expect(folds.every((fold) => fold.open)).toBe(true)
expect(firstSection.querySelector('.step-text')?.textContent).toContain('First.')
expect(doc.activeElement).toBe(foldAll)
},
120000,
)
Expand All @@ -1146,17 +1107,12 @@ describe('report browser client', () => {
fileSummary.focus()
expect(doc.activeElement).toBe(fileSummary)

for (const fold of doc.querySelectorAll<HTMLDetailsElement>('details.section-fold')) {
fold.addEventListener('toggle', () => {
if (!fold.open && fold.contains(fileSummary)) fileSummary.blur()
})
}
const foldAll = firstSection.querySelector<HTMLButtonElement>('[data-section-fold-all="fold"]')!
foldAll.click()

doc.querySelector<HTMLButtonElement>('[data-fold-all]')!.click()

const sectionSummary = firstSection.querySelector<HTMLElement>('.section-toggle')!
expect(doc.activeElement).toBe(sectionSummary)
expect(sectionSummary.closest('details')?.open).toBe(false)
expect(doc.activeElement).toBe(foldAll)
expect(firstSection.querySelector<HTMLDetailsElement>('.section-fold')?.open).toBe(true)
expect(file.open).toBe(false)
},
120000,
)
Expand All @@ -1174,26 +1130,20 @@ describe('report browser client', () => {
const doc = dom.document as unknown as Document
runReportClient()

const button = doc.querySelector<HTMLButtonElement>('[data-fold-all]')!
const folds = [...doc.querySelectorAll<HTMLDetailsElement>('details.section-fold')]
const label = () => button.querySelector('[data-fold-all-label]')?.textContent

// A section summary is never hidden by its own fold, so focus must not move.
const summary = folds[0]!.querySelector<HTMLElement>(':scope > summary')!
summary.focus()
const button = doc.querySelector<HTMLButtonElement>('[data-section-fold-all="unfold"]')!
button.click()
expect(doc.activeElement).toBe(summary)

// Mixed state: any open section means the global control folds all.
folds[1]!.open = true
expect(label()).toBe('Fold all')
button.click()
expect(folds.every((fold) => !fold.open)).toBe(true)
expect(label()).toBe('Unfold all')
expect(doc.activeElement).toBe(button)

button.click()
const secondFile = folds[1]!.querySelector<HTMLDetailsElement>('details.file')!
secondFile.open = true
folds[0]!.querySelector<HTMLButtonElement>('[data-section-fold-all="fold"]')!.click()
expect(folds.every((fold) => fold.open)).toBe(true)
expect(label()).toBe('Fold all')
expect(secondFile.open).toBe(true)
},
120000,
)
Expand Down
16 changes: 8 additions & 8 deletions test/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,16 +338,16 @@ describe('renderReport shell', () => {
expect(html).toContain('value="unified" checked')
})

test('the review map carries one global fold control after the layout toggle', () => {
test('each open section has local fold controls and files start collapsed', () => {
const html = renderReport(document([section(simplePatch(), 'Plain')]), stubClient)
const form = html.indexOf('data-layout-form')
const fold = html.indexOf('data-fold-all')
const label = html.indexOf('>Review map<')

expect(fold).toBeGreaterThan(form)
expect(label).toBeGreaterThan(fold)
expect(html).toContain('aria-label="Fold all review sections"')
expect(html).toContain('data-fold-all-label>Fold all<')
expect(html).not.toContain('data-fold-all')
expect(html).toContain('data-section-fold-all="unfold"')
expect(html).toContain('data-section-fold-all="fold"')
expect(html).toContain('aria-label="Unfold all in section 01"')
expect(html).toContain('aria-label="Fold all in section 01"')
expect(html).toContain('<details class="section-fold" open>')
expect(html).toContain('<details class="file">')
})

test('review map lists every section in document order with zero-padded anchors and counts', () => {
Expand Down
Binary file modified test/visual/__screenshots__/desktop/focused-control.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/desktop/print.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/desktop/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/desktop/review-map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/desktop/section.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/narrow/focused-control.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/narrow/print.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/narrow/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/narrow/review-map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/narrow/section.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/phone/focused-control.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/phone/print.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/phone/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/phone/review-map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/phone/section.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/tablet/focused-control.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/tablet/print.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/tablet/report.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/tablet/review-map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/visual/__screenshots__/tablet/section.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 14 additions & 4 deletions test/visual/report.visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,26 @@ test.describe('report visuals', () => {
await expect(page.locator('nav')).toHaveScreenshot('review-map.png')
})

// Keyboard focus styling was previously asserted as the `.fold-all:focus-visible`
// stylesheet text. Tab reaches the fold control first, so a keyboard-driven focus
// Keyboard focus styling is asserted on the section-local fold control.
// Tab reaches the section control first, so a keyboard-driven focus
// captures the real ring instead of the rule that draws it.
test('keyboard focus ring on the fold control', async ({ page }) => {
await page.locator('.section-title-text').first().focus()
await page.keyboard.press('Tab')
const focused = await page.evaluate(() => document.activeElement?.className ?? '')
if (!focused.includes('fold-all')) {
if (!focused.includes('section-fold-all')) {
throw new Error(`Expected the fold control to receive focus, got ${focused}`)
}
await expect(page.locator('nav')).toHaveScreenshot('focused-control.png')
await expect(page.locator('main .section').first()).toHaveScreenshot('focused-control.png')
})

// One section captures the title row, prose measure, and the file fold.
test('section', async ({ page }) => {
await page.locator('main .section').first().locator('details.section-fold').evaluate((element) => {
;(element as HTMLDetailsElement).open = true
for (const detail of element.querySelectorAll<HTMLDetailsElement>('details.file')) detail.open = true
})
await expect(page.locator('main .section').first().locator('.step-text')).toBeVisible()
await expect(page.locator('main .section').first()).toHaveScreenshot('section.png')
})

Expand Down Expand Up @@ -116,6 +122,10 @@ test.describe('report visuals', () => {
})

test('print report', async ({ page }) => {
await page.locator('details').evaluateAll((details) => {
for (const detail of details) (detail as HTMLDetailsElement).open = true
})
await expect(page.locator('main .step-text').first()).toBeVisible()
await page.emulateMedia({ media: 'print' })
await expect(page).toHaveScreenshot('print.png', { fullPage: true })
})
Expand Down
Loading