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 `