@@ -381,111 +381,121 @@ describe('combobox typing surfaces', () => {
381381 expect ( focusElementForTyping ( 0 ) ) . toMatchObject ( { error : 'obstructed' } )
382382 } )
383383
384- /** Answers `elementFromPoint` with `overField` on the field's sampled points, `elsewhere` beyond. */
385- function pointTo ( overField : Element , elsewhere : ( x : number , y : number ) => Element ) : void {
386- Object . defineProperty ( document , 'elementFromPoint' , {
384+ /**
385+ * Answers hit testing with the paint stack (topmost first) over the field's sampled points and
386+ * elsewhere, as `elementsFromPoint` does; `elementFromPoint` is its top.
387+ */
388+ function stackTo (
389+ overField : Element [ ] ,
390+ elsewhere : ( x : number , y : number ) => Element [ ] ,
391+ target : Document | ShadowRoot = document
392+ ) : void {
393+ const stackAt = ( x : number , y : number ) =>
394+ x >= 10 && x <= 100 && y <= 20 ? overField : elsewhere ( x , y )
395+ Object . defineProperty ( target , 'elementsFromPoint' , { configurable : true , value : stackAt } )
396+ Object . defineProperty ( target , 'elementFromPoint' , {
387397 configurable : true ,
388- value : ( x : number , y : number ) =>
389- x >= 10 && x <= 100 && y <= 20 ? overField : elsewhere ( x , y ) ,
398+ value : ( x : number , y : number ) => stackAt ( x , y ) [ 0 ] ?? null ,
390399 } )
391400 }
392- const nearCentre = ( x : number , y : number ) =>
393- Math . abs ( x - window . innerWidth / 2 ) < 50 && Math . abs ( y - window . innerHeight / 2 ) < 50
401+ const page = ( ) => [ document . body , document . documentElement ]
402+ const byId = < T extends Element = HTMLDivElement > ( id : string ) =>
403+ document . getElementById ( id ) as unknown as T
394404
395405 it ( 'refuses a top-page field behind an unmarked backdrop and panel' , ( ) => {
396406 document . body . innerHTML = `
397- <input type="text" aria-label="Search" />
407+ <input id="field" type="text" aria-label="Search" />
398408 <div id="backdrop" style="position: fixed"></div>
399409 <div id="panel" style="position: fixed">Subscribe to our newsletter</div>
400410 `
401- const input = visible ( document . querySelector ( 'input' ) as HTMLInputElement )
402- const backdrop = document . querySelector ( '#backdrop' ) as HTMLDivElement
403- const panel = document . querySelector ( '#panel' ) as HTMLDivElement
411+ const input = visible ( byId < HTMLInputElement > ( 'field' ) )
404412 register ( input )
405- pointTo ( panel , ( x , y ) => ( nearCentre ( x , y ) ? panel : backdrop ) )
413+ stackTo ( [ byId ( 'panel' ) , byId ( 'backdrop' ) , input , ...page ( ) ] , ( ) => [
414+ byId ( 'backdrop' ) ,
415+ ...page ( ) ,
416+ ] )
406417
407418 expect ( focusElementForTyping ( 0 ) ) . toMatchObject ( { error : 'obstructed' } )
408419 } )
409420
410- it ( 'refuses a top-page field behind a backdrop inside a wrapper that lets the pointer through ' , ( ) => {
421+ it ( 'refuses a field behind an unmarked modal nested in a full-screen app shell ' , ( ) => {
411422 document . body . innerHTML = `
412- <input type="text" aria-label="Search" />
413- <div id="layer" style="position: fixed; pointer-events: none">
414- <div id="backdrop" style="position: absolute; pointer-events: auto"></div>
415- <div id="panel" style="pointer-events: auto">Subscribe to our newsletter</div>
423+ <div id="shell" style="position: fixed">
424+ <input id="field" type="text" aria-label="Search" />
425+ <div id="backdrop" style="position: fixed"><div id="panel">Confirm your plan</div></div>
416426 </div>
417427 `
418- const input = visible ( document . querySelector ( 'input' ) as HTMLInputElement )
419- const backdrop = document . querySelector ( '#backdrop' ) as HTMLDivElement
420- const panel = document . querySelector ( '#panel' ) as HTMLDivElement
428+ const input = visible ( byId < HTMLInputElement > ( 'field' ) )
421429 register ( input )
422- pointTo ( panel , ( ) => backdrop )
430+ stackTo ( [ byId ( 'panel' ) , byId ( 'backdrop' ) , input , byId ( 'shell' ) , ...page ( ) ] , ( ) => [
431+ byId ( 'backdrop' ) ,
432+ byId ( 'shell' ) ,
433+ ...page ( ) ,
434+ ] )
423435
424436 expect ( focusElementForTyping ( 0 ) ) . toMatchObject ( { error : 'obstructed' } )
425437 } )
426438
427- it ( 'types under a fixed bar or a toast whose wrapper lets the pointer through' , ( ) => {
428- document . body . innerHTML = `
429- <input type="text" aria-label="Search" />
430- <div id="bar" style="position: fixed">Free shipping today</div>
431- <div style="position: fixed; pointer-events: none"><div id="toast">Saved to your list</div></div>
432- `
433- const input = visible ( document . querySelector ( 'input' ) as HTMLInputElement )
434- register ( input )
435- for ( const [ cover , label ] of [
436- [ '#bar' , 'Free shipping today' ] ,
437- [ '#toast' , 'Saved to your list' ] ,
438- ] as const ) {
439- pointTo ( document . querySelector ( cover ) as HTMLDivElement , ( ) => document . body )
440- expect ( focusElementForTyping ( 0 ) ) . toMatchObject ( { focused : true , coveredBy : label } )
441- }
442- } )
443-
444- it ( 'types under a fixed header when a cookie banner pins the bottom corners' , ( ) => {
445- document . body . innerHTML = `
446- <input type="text" aria-label="Search" />
447- <div id="header" style="position: fixed">Menu</div>
448- <main>Results</main>
449- <div id="banner" style="position: fixed">We use cookies</div>
450- `
451- const input = visible ( document . querySelector ( 'input' ) as HTMLInputElement )
452- const header = document . querySelector ( '#header' ) as HTMLDivElement
453- const main = document . querySelector ( 'main' ) as HTMLElement
454- const banner = document . querySelector ( '#banner' ) as HTMLDivElement
439+ it ( 'refuses a field behind an unmarked modal inside the same shadow root' , ( ) => {
440+ document . body . innerHTML = '<div id="host"></div>'
441+ const shadow = byId ( 'host' ) . attachShadow ( { mode : 'open' } )
442+ shadow . innerHTML =
443+ '<input type="text" aria-label="Search" /><div id="backdrop" style="position: fixed"><div id="panel">Sign up</div></div>'
444+ const input = visible ( shadow . querySelector ( 'input' ) as HTMLInputElement )
445+ const backdrop = shadow . querySelector ( '#backdrop' ) as HTMLDivElement
446+ const panel = shadow . querySelector ( '#panel' ) as HTMLDivElement
455447 register ( input )
456- pointTo ( header , ( x , y ) =>
457- nearCentre ( x , y ) ? main : y < window . innerHeight / 2 ? header : banner
448+ stackTo (
449+ [ panel , backdrop , input , byId ( 'host' ) , ...page ( ) ] ,
450+ ( ) => [ backdrop , byId ( 'host' ) , ...page ( ) ] ,
451+ shadow
458452 )
459453
460- expect ( focusElementForTyping ( 0 ) ) . toMatchObject ( { focused : true , coveredBy : 'Menu ' } )
454+ expect ( focusElementForTyping ( 0 ) ) . toMatchObject ( { error : 'obstructed ' } )
461455 } )
462456
463- it ( 'types into a fixed search box over a full-screen fixed map when a card covers it ' , ( ) => {
457+ it ( 'types under covers that do not reach every corner of the viewport ' , ( ) => {
464458 document . body . innerHTML = `
465459 <div id="map" style="position: fixed">Map</div>
466- <div style="position: fixed"><input type="text" aria-label="Search" /></div>
467- <div id="card" style="position: fixed">Try the new app</div>
460+ <div id="header" style="position: fixed">Menu</div>
461+ <input id="field" type="text" aria-label="Search" />
462+ <div id="toast">Saved to your list</div>
463+ <div id="banner" style="position: fixed">We use cookies</div>
468464 `
469- const input = visible ( document . querySelector ( 'input' ) as HTMLInputElement )
465+ const input = visible ( byId < HTMLInputElement > ( 'field' ) )
470466 register ( input )
471- pointTo (
472- document . querySelector ( '#card' ) as HTMLDivElement ,
473- ( ) => document . querySelector ( '#map' ) as HTMLDivElement
474- )
475-
476- expect ( focusElementForTyping ( 0 ) ) . toMatchObject ( { focused : true , coveredBy : 'Try the new app' } )
467+ const topHalf = ( y : number ) => y < window . innerHeight / 2
468+ for ( const [ cover , label , elsewhere ] of [
469+ // a fixed header over a full-screen fixed map the field also sits above
470+ [
471+ 'header' ,
472+ 'Menu' ,
473+ ( _x : number , y : number ) =>
474+ ( topHalf ( y ) ? [ byId ( 'header' ) ] : [ ] ) . concat ( byId ( 'map' ) , ...page ( ) ) ,
475+ ] ,
476+ // a fixed header with a cookie banner pinned to the bottom corners
477+ [
478+ 'header' ,
479+ 'Menu' ,
480+ ( _x : number , y : number ) => [ topHalf ( y ) ? byId ( 'header' ) : byId ( 'banner' ) , ...page ( ) ] ,
481+ ] ,
482+ // a toast whose full-screen wrapper lets the pointer through, so the wrapper is in no stack
483+ [ 'toast' , 'Saved to your list' , ( ) => page ( ) ] ,
484+ ] as const ) {
485+ stackTo ( [ byId ( cover ) , input , byId ( 'map' ) , ...page ( ) ] , elsewhere )
486+ expect ( focusElementForTyping ( 0 ) ) . toMatchObject ( { focused : true , coveredBy : label } )
487+ }
477488 } )
478489
479490 it ( 'types inside a full-screen fixed app shell that also holds the field' , ( ) => {
480491 document . body . innerHTML = `
481492 <div id="shell" style="position: fixed">
482- <input type="text" aria-label="Search" /><div id="card">Shop for your home</div>
493+ <input id="field" type="text" aria-label="Search" /><div id="card">Shop for your home</div>
483494 </div>
484495 `
485- const shell = document . querySelector ( '#shell' ) as HTMLDivElement
486- const input = visible ( document . querySelector ( 'input' ) as HTMLInputElement )
496+ const input = visible ( byId < HTMLInputElement > ( 'field' ) )
487497 register ( input )
488- pointTo ( document . querySelector ( '# card') as HTMLDivElement , ( ) => shell )
498+ stackTo ( [ byId ( ' card') , input , byId ( 'shell' ) , ... page ( ) ] , ( ) => [ byId ( ' shell' ) , ... page ( ) ] )
489499
490500 expect ( focusElementForTyping ( 0 ) ) . toMatchObject ( {
491501 focused : true ,
0 commit comments