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
56 changes: 56 additions & 0 deletions src/report/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,61 @@ 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>(':scope > summary')
;(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 All @@ -372,6 +427,7 @@ export function mountReport(
void mountedDiffs.initialRender.then(finishInitialRender)
const { mounted } = mountedDiffs
wireLayout(mounted)
wireGlobalFolds()
prepareForPrint()
}

Expand Down
32 changes: 27 additions & 5 deletions src/report/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ 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 reviewMap = renderReviewMap(
document.sections.map((section, index) => ({
title: section.title,
fragment: targets[index]!.fragment,
})),
{ sections: sections.length, files },
layoutForm,
readingControls,
)
const summary =
document.summary.trim() === ''
Expand Down Expand Up @@ -205,7 +207,7 @@ ${steps.join('\n')}
function renderReviewMap(
sections: { title: string; fragment: string }[],
counts: { sections: number; files: number },
layoutForm: string,
controls: string,
): string {
const links = sections
.map(
Expand All @@ -214,7 +216,7 @@ function renderReviewMap(
)
.join('\n')
return `<nav class="review-map" aria-label="Review map">
${layoutForm}
${controls}
<p class="review-map-label">Review map</p>
<ol class="review-map-list">
${links}
Expand Down Expand Up @@ -308,9 +310,14 @@ body {
.source-metadata dt { color: #7e8d82; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; }
.source-metadata dd { margin: 0; min-width: 0; overflow: hidden; color: #4e5d53; text-overflow: ellipsis; white-space: nowrap; }
.source-metadata code { color: #263a2d; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
.review-controls {
display: grid;
gap: 8px;
margin: 0 10px 20px;
}
.layout-form {
display: flex;
margin: 0 10px 20px;
margin: 0;
border: 1px solid #bdcbbf;
border-radius: 7px;
overflow: hidden;
Expand All @@ -319,6 +326,18 @@ body {
.layout-form label { flex: 1; padding: 5px 10px; color: #607066; font-size: 13px; text-align: center; cursor: pointer; }
.layout-form input { display: none; }
.layout-form label:has(input:checked) { color: #ffffff; background: var(--accent); font-weight: 600; }
.fold-all {
padding: 6px 10px;
border: 1px solid #bdcbbf;
border-radius: 7px;
color: #53665a;
background: #f3f7f3;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.fold-all:hover { color: var(--accent); border-color: #8eaa95; background: #eef5ef; }
.fold-all:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.review-workspace {
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
Expand Down Expand Up @@ -529,13 +548,16 @@ main { max-width: none; min-width: 0; margin: 0; padding: 22px 28px 72px; }
backdrop-filter: blur(12px);
}
.review-map-label, .review-map-list, .review-map-counts { display: none; }
.layout-form { max-width: 260px; margin: 0 0 0 auto; }
.review-controls { display: flex; gap: 6px; justify-content: flex-end; margin: 0; }
.layout-form { flex: 1 1 auto; max-width: 220px; margin: 0 0 0 auto; }
.fold-all { flex: none; padding: 5px 9px; font-size: 12px; }
main { padding: 14px 10px 50px; }
}
@media (max-width: 520px) {
.report-cover { padding: 18px 16px 8px; }
.report-cover h1 { font-size: 21px; }
.layout-form label { padding: 4px 7px; font-size: 11px; }
.fold-all { padding: 4px 7px; font-size: 11px; }
.section-fold > summary { font-size: 14px; }
}
@media print {
Expand Down
176 changes: 176 additions & 0 deletions test/report-dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,4 +908,180 @@ describe('report browser client', () => {
},
120000,
)

test(
'one control folds and unfolds every section and individual folds still work',
async () => {
const value = document([
section(simplePatch('one', 'one!'), 'First section'),
section(simplePatch('two', 'two!'), 'Second section'),
])
const dom = loadReport(renderReport(value, clientBundle), {
url: 'https://reports.example/r/report-id',
})
const doc = dom.document as unknown as Document
const dataScript = doc.querySelector<HTMLScriptElement>('#diffwalk-report-data')!
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(dataScript.textContent).toBe(dataBefore)

// Individual sections still fold and unfold natively after the global action,
// and their toggle drives the global label.
folds[0]!
.querySelector('summary')!
.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }) as unknown as Event)
expect(folds[0]!.open).toBe(false)
expect(folds[1]!.open).toBe(true)
expect(label()).toBe('Fold all')

folds[1]!
.querySelector('summary')!
.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(folds.every((fold) => fold.open)).toBe(true)
expect(label()).toBe('Fold all')
},
120000,
)

test(
'folding all moves focus from a hidden child to the visible section row',
async () => {
const value = document([
{
title: 'First section',
steps: [{ text: 'First.', diff: simplePatch('one', 'one!'), changes: ['change-001'] }],
},
{
title: 'Second section',
steps: [{ text: 'Second.', diff: simplePatch('two', 'two!'), changes: ['change-002'] }],
},
])
const dom = loadReport(renderReport(value, clientBundle), {
url: 'https://reports.example/r/report-id',
})
const doc = dom.document as unknown as Document
runReportClient()

const firstSection = doc.querySelector<HTMLElement>('.section')!
const focused = firstSection.querySelector<HTMLButtonElement>(
'.step [data-copy-fragment]',
)!
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 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-fold > summary')!
expect(doc.activeElement).toBe(summary)
},
120000,
)

test(
'folding all moves focus off a closed nested file row onto the section row',
async () => {
const value = document([
section(simplePatch('one', 'one!'), 'First section'),
section(simplePatch('two', 'two!'), 'Second section'),
])
const dom = loadReport(renderReport(value, clientBundle), {
url: 'https://reports.example/r/report-id',
})
const doc = dom.document as unknown as Document
runReportClient()

const firstSection = doc.querySelector<HTMLElement>('.section')!
const file = firstSection.querySelector<HTMLDetailsElement>('details.file')!
file.open = false
const fileSummary = file.querySelector<HTMLElement>(':scope > summary')!
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()
})
}

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

const sectionSummary = firstSection.querySelector<HTMLElement>('.section-fold > summary')!
expect(doc.activeElement).toBe(sectionSummary)
expect(sectionSummary.closest('details')?.open).toBe(false)
},
120000,
)

test(
'a visible focused section row stays focused and mixed states fold then unfold',
async () => {
const value = document([
section(simplePatch('one', 'one!'), 'First section'),
section(simplePatch('two', 'two!'), 'Second section'),
])
const dom = loadReport(renderReport(value, clientBundle), {
url: 'https://reports.example/r/report-id',
})
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()
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')

button.click()
expect(folds.every((fold) => fold.open)).toBe(true)
expect(label()).toBe('Fold all')
},
120000,
)
})
15 changes: 15 additions & 0 deletions test/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,21 @@ describe('renderReport shell', () => {
expect(html).toContain('value="unified" checked')
})

test('the review map carries one global fold control after the layout toggle', () => {
const html = renderReport(document([section(simplePatch(), 'Plain')]), stubClient)
const form = html.indexOf('<form class="layout-form"')
const fold = html.indexOf('data-fold-all')
const label = html.indexOf('<p class="review-map-label">')

expect(fold).toBeGreaterThan(form)
expect(label).toBeGreaterThan(fold)
expect(html).toContain('<button type="button" class="fold-all" data-fold-all')
expect(html).toContain('aria-label="Fold all review sections"')
expect(html).toContain('data-fold-all-label>Fold all<')
expect(html).toContain('.review-controls {')
expect(html).toContain('.fold-all:focus-visible')
})

test('review map lists every section in document order with zero-padded anchors and counts', () => {
const value = document([
section(simplePatch('a', 'b'), 'First section'),
Expand Down