diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 2d323ab..3a34089 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -120,37 +120,66 @@ async function expectNoHorizontalScroll(page: Page, label: string): Promise { const doc = document.documentElement; const limit = doc.clientWidth; - /** Content inside its own scroll container is allowed to be wider than - * the viewport — that is the prescribed remedy, not the defect. Only - * what pushes the PAGE is a finding. */ - const insideScroller = (el: HTMLElement): boolean => { + const describe = (el: HTMLElement): string => { + const cls = + el.className && typeof el.className === 'string' + ? `.${el.className.trim().split(/\s+/).join('.')}` + : ''; + return `${el.tagName.toLowerCase()}${cls}`; + }; + + /** + * Content inside a scroll container may exceed the viewport — that is the + * remedy, not the defect. But only if the CONTAINER itself fits: a + * scroller that is wider than the viewport still pushes the page, and its + * children are then symptoms, not causes. + */ + const containedByFittingScroller = (el: HTMLElement): boolean => { for (let node = el.parentElement; node && node !== doc; node = node.parentElement) { const overflowX = getComputedStyle(node).overflowX; - if (overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'hidden') return true; + if (overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'hidden') { + return node.getBoundingClientRect().right <= limit + 1; + } } return false; }; const offenders: string[] = []; + let widest: HTMLElement | null = null; for (const el of Array.from(document.querySelectorAll('body *'))) { const rect = el.getBoundingClientRect(); // 1 px Toleranz gegen Subpixel-Rundung. - if (rect.width > 0 && rect.right > limit + 1 && !insideScroller(el)) { - const cls = - el.className && typeof el.className === 'string' - ? `.${el.className.trim().split(/\s+/).join('.')}` - : ''; + if (rect.width > 0 && rect.right > limit + 1 && !containedByFittingScroller(el)) { offenders.push( - `${el.tagName.toLowerCase()}${cls} (right=${Math.round(rect.right)}, width=${Math.round(rect.width)})`, + `${describe(el)} (right=${Math.round(rect.right)}, width=${Math.round(rect.width)})`, ); + if (!widest || rect.width > widest.getBoundingClientRect().width) widest = el; } } - return { scrollWidth: doc.scrollWidth, clientWidth: limit, offenders: offenders.slice(0, 20) }; + + // Where does the inflation start? The chain from body down to the widest + // offender, with each box's width — the first entry wider than the + // viewport is the element that actually needs constraining. + const chain: string[] = []; + for (let node: HTMLElement | null = widest; node && node !== doc; node = node.parentElement) { + chain.unshift(`${describe(node)} w=${Math.round(node.getBoundingClientRect().width)}`); + } + + return { + scrollWidth: doc.scrollWidth, + clientWidth: limit, + offenders: offenders.slice(0, 20), + chain, + }; }); expect( - { overflowBy: report.scrollWidth - report.clientWidth, offenders: report.offenders }, + { + overflowBy: report.scrollWidth - report.clientWidth, + offenders: report.offenders, + chain: report.chain, + }, `${label}: horizontaler Überlauf bei 320 px`, - ).toEqual({ overflowBy: 0, offenders: [] }); + ).toEqual({ overflowBy: 0, offenders: [], chain: [] }); } test.describe('reflow at 320px', () => {