From c0fa0d5601e5529f93a3dc2c85d5f1d03e4a831f Mon Sep 17 00:00:00 2001 From: Art Pai Date: Fri, 11 Sep 2026 18:28:25 +1000 Subject: [PATCH] Fold all review sections from one reader control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sections could only be folded one at a time, which is tedious on a long review. A single review-map toggle now closes or opens every section, moves focus to the surviving summary row when the focused child is hidden, and leaves the report data untouched so individual folds still work. Co-Authored-By: ことね --- src/report/client.ts | 56 +++++++++++++ src/report/shell.ts | 32 ++++++-- test/report-dom.test.ts | 176 ++++++++++++++++++++++++++++++++++++++++ test/report.test.ts | 15 ++++ 4 files changed, 274 insertions(+), 5 deletions(-) diff --git a/src/report/client.ts b/src/report/client.ts index 6d56f4f..e98a89f 100644 --- a/src/report/client.ts +++ b/src/report/client.ts @@ -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('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('[data-fold-all]') + if (!button) return + const label = button.querySelector('[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(':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('details')) { @@ -372,6 +427,7 @@ export function mountReport( void mountedDiffs.initialRender.then(finishInitialRender) const { mounted } = mountedDiffs wireLayout(mounted) + wireGlobalFolds() prepareForPrint() } diff --git a/src/report/shell.ts b/src/report/shell.ts index c41fead..a0434ff 100644 --- a/src/report/shell.ts +++ b/src/report/shell.ts @@ -50,13 +50,15 @@ function renderReportBody( ` + const foldAll = `` + const readingControls = `
${layoutForm}${foldAll}
` 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() === '' @@ -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( @@ -214,7 +216,7 @@ function renderReviewMap( ) .join('\n') return `