@@ -386,6 +386,175 @@ async function main() {
386386 } ) ;
387387 } ) ;
388388
389+ registerCssLayoutSuites ( { test, renderFixture, routes } ) ;
390+ } ) ;
391+ }
392+
393+ /**
394+ * Registers the CSS-dependent WCAG criteria that axe cannot cover: they need a
395+ * real layout at a real viewport. Kept out of `main` so the suite can be
396+ * introduced independently of the harness it runs on.
397+ */
398+ function registerCssLayoutSuites ( { test, renderFixture, routes } ) {
399+ // CSS-dependent criteria that axe cannot cover: they need a real layout at a
400+ // real viewport. Each component is exercised through one representative demo;
401+ // the criteria a component is rated against are listed alongside it.
402+ const CSS_LAYOUT_SUITES = [
403+ { component : 'Accordion' , route : '/docs-components-accordion/AccordionUsage' } ,
404+ // The same demo renders the summary header, which is rated separately.
405+ { component : 'AccordionSummary' , route : '/docs-components-accordion/AccordionUsage' } ,
406+ {
407+ component : 'Avatar' ,
408+ route : '/docs-components-avatars/LetterAvatars' ,
409+ // 1.4.4 is asserted here as *text-only* resize, which the Avatar report
410+ // explicitly treats as out of scope: its fixed 40px box scales under
411+ // full-page zoom (the mechanism the criterion assumes) but not under
412+ // text-only zoom, which the report calls an author concern. Left rated
413+ // Manual rather than silently downgraded — see Avatar/accessibility.md.
414+ skipCriteria : [ '1.4.4' ] ,
415+ } ,
416+ { component : 'Button' , route : '/docs-components-buttons/BasicButtons' } ,
417+ { component : 'Checkbox' , route : '/docs-components-checkboxes/Checkboxes' } ,
418+ { component : 'LinearProgress' , route : '/docs-components-progress/LinearDeterminate' } ,
419+ { component : 'Radio' , route : '/docs-components-radio-buttons/RadioButtonsGroup' } ,
420+ { component : 'Switch' , route : '/docs-components-switches/BasicSwitches' } ,
421+ { component : 'TextField' , route : '/docs-components-text-fields/BasicTextFields' } ,
422+ { component : 'ToggleButton' , route : '/docs-components-toggle-button/ToggleButtons' } ,
423+ // The same demo renders the group wrapper, which is rated separately.
424+ { component : 'ToggleButtonGroup' , route : '/docs-components-toggle-button/ToggleButtons' } ,
425+ ] ;
426+
427+ /**
428+ * Reports the document's horizontal overflow after applying `css`, plus any
429+ * element whose own content escapes its box. Both are the failure modes the
430+ * reflow, resize-text, and text-spacing criteria care about.
431+ */
432+ async function measureOverflow ( page , css ) {
433+ return page . evaluate ( ( styleText ) => {
434+ const style = document . createElement ( 'style' ) ;
435+ style . textContent = styleText ;
436+ document . head . appendChild ( style ) ;
437+
438+ // Force layout before measuring.
439+ void document . documentElement . offsetHeight ;
440+
441+ const root = document . documentElement ;
442+ const horizontalOverflow = root . scrollWidth - root . clientWidth ;
443+
444+ // These criteria are about losing *text*, so only elements that hide
445+ // overflow and actually render text count. Measuring the text range
446+ // rather than scrollWidth avoids flagging boxes that deliberately clip
447+ // decoration — a Switch root clipping its ripple loses no content.
448+ const clipped = Array . from ( document . querySelectorAll ( '[class*="Mui"]' ) )
449+ . filter ( ( node ) => {
450+ const { overflowX, overflowY, visibility, display } = window . getComputedStyle ( node ) ;
451+ if ( overflowX !== 'hidden' && overflowY !== 'hidden' ) {
452+ return false ;
453+ }
454+ // Deliberately hidden content — a collapsed Accordion panel — is not
455+ // clipped content: these criteria are about what is on screen.
456+ if ( visibility === 'hidden' || display === 'none' ) {
457+ return false ;
458+ }
459+ const box = node . getBoundingClientRect ( ) ;
460+ if ( box . width === 0 || box . height === 0 ) {
461+ return false ;
462+ }
463+ return node . textContent . trim ( ) !== '' ;
464+ } )
465+ . filter ( ( node ) => {
466+ const range = document . createRange ( ) ;
467+ range . selectNodeContents ( node ) ;
468+ const text = range . getBoundingClientRect ( ) ;
469+ const box = node . getBoundingClientRect ( ) ;
470+ return (
471+ text . left < box . left - 0.5 ||
472+ text . right > box . right + 0.5 ||
473+ text . top < box . top - 0.5 ||
474+ text . bottom > box . bottom + 0.5
475+ ) ;
476+ } )
477+ . map ( ( node ) => node . className ) ;
478+
479+ style . remove ( ) ;
480+ return { horizontalOverflow, clipped : clipped . slice ( 0 , 3 ) } ;
481+ } , css ) ;
482+ }
483+
484+ const TEXT_SPACING_CSS =
485+ '* { line-height: 1.5 !important; letter-spacing: 0.12em !important;' +
486+ ' word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }' ;
487+
488+ CSS_LAYOUT_SUITES . forEach ( ( { component, route, skipCriteria = [ ] } ) => {
489+ // A slug only enters the demo bundle once its component is enrolled, so skip
490+ // entries this build doesn't contain rather than waiting for a fixture that
491+ // was never bundled.
492+ if ( ! routes . includes ( route ) ) {
493+ return ;
494+ }
495+ const covers = ( criterion ) => ! skipCriteria . includes ( criterion ) ;
496+
497+ describe ( `${ component } layout criteria` , ( ) => {
498+ test . runIf ( covers ( '1.4.10' ) ) (
499+ '1.4.10 Reflow: no horizontal scrolling at 320 CSS pixels' ,
500+ async ( { pooled } ) => {
501+ const { page } = pooled ;
502+ await page . setViewportSize ( { width : 320 , height : 512 } ) ;
503+ try {
504+ await renderFixture ( page , route ) ;
505+ const { horizontalOverflow } = await measureOverflow ( page , '' ) ;
506+ if ( horizontalOverflow > 1 ) {
507+ throw new Error (
508+ `${ component } overflows horizontally by ${ horizontalOverflow } px at 320px wide` ,
509+ ) ;
510+ }
511+ } finally {
512+ await page . setViewportSize ( DEFAULT_VIEWPORT ) ;
513+ }
514+ } ,
515+ ) ;
516+
517+ test . runIf ( covers ( '1.4.4' ) ) (
518+ '1.4.4 Resize Text: content survives a 200% text size' ,
519+ async ( { pooled } ) => {
520+ const { page } = pooled ;
521+ await renderFixture ( page , route ) ;
522+ const { horizontalOverflow, clipped } = await measureOverflow (
523+ page ,
524+ 'html { font-size: 200% !important; }' ,
525+ ) ;
526+ if ( horizontalOverflow > 1 ) {
527+ throw new Error (
528+ `${ component } overflows horizontally by ${ horizontalOverflow } px at 200% text size` ,
529+ ) ;
530+ }
531+ if ( clipped . length > 0 ) {
532+ throw new Error ( `${ component } clips its own content at 200%: ${ clipped . join ( ', ' ) } ` ) ;
533+ }
534+ } ,
535+ ) ;
536+
537+ test . runIf ( covers ( '1.4.12' ) ) (
538+ '1.4.12 Text Spacing: content survives the WCAG spacing overrides' ,
539+ async ( { pooled } ) => {
540+ const { page } = pooled ;
541+ await renderFixture ( page , route ) ;
542+ const { horizontalOverflow, clipped } = await measureOverflow ( page , TEXT_SPACING_CSS ) ;
543+ if ( horizontalOverflow > 1 ) {
544+ throw new Error (
545+ `${ component } overflows horizontally by ${ horizontalOverflow } px under the` +
546+ ' WCAG text-spacing overrides' ,
547+ ) ;
548+ }
549+ if ( clipped . length > 0 ) {
550+ throw new Error (
551+ `${ component } clips its own content under the WCAG text-spacing overrides:` +
552+ ` ${ clipped . join ( ', ' ) } ` ,
553+ ) ;
554+ }
555+ } ,
556+ ) ;
557+ } ) ;
389558 } ) ;
390559}
391560
0 commit comments