Skip to content

Commit b038c1b

Browse files
committed
fix(desktop-browser): treat an element painted above the field and under every viewport corner as a modal backdrop
1 parent 285e4f0 commit b038c1b

2 files changed

Lines changed: 100 additions & 95 deletions

File tree

‎apps/desktop/src/main/browser-agent/page-functions.test.ts‎

Lines changed: 76 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

‎apps/desktop/src/main/browser-agent/page-functions.ts‎

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2041,9 +2041,8 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
20412041
}
20422042
}
20432043
// Top-page typing goes through keyboard focus, so a non-modal overlay does not block it. A modal
2044-
// still does: a dialog, or unmarked fixed layers that catch the pointer at the viewport's corners
2045-
// and centre, include the field's cover, and do not hold the field (a full-screen app shell
2046-
// does). Framed fields keep the check for their mapped point.
2044+
// still does: a dialog, or an unmarked backdrop, which paints above the field and under every
2045+
// corner of the viewport. Framed fields keep the check for their mapped point.
20472046
const topPageField = view === window && window.top === window
20482047
const insideDialog = (node: Element): boolean => {
20492048
for (let current: Element | null = node; current; current = composedParent(current)) {
@@ -2059,38 +2058,34 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
20592058
}
20602059
return false
20612060
}
2062-
const fixedLayerOf = (node: Element | null): Element | null => {
2063-
let layer: Element | null = null
2064-
for (let current = node; current; current = composedParent(current)) {
2065-
if (view.getComputedStyle(current).position === 'fixed') layer = current
2061+
const behindBackdrop = (): boolean => {
2062+
const stackRoot = root as ParentNode & {
2063+
elementsFromPoint?: (x: number, y: number) => Element[]
20662064
}
2067-
return layer
2068-
}
2069-
const screenBlockedByLayer = (covers: Element[]): boolean => {
2070-
const documentAt = editable.ownerDocument
2071-
const width = documentAt.documentElement.clientWidth || view.innerWidth
2072-
const height = documentAt.documentElement.clientHeight || view.innerHeight
2065+
if (typeof stackRoot.elementsFromPoint !== 'function') return false
2066+
const stackAt = stackRoot.elementsFromPoint.bind(stackRoot)
2067+
const aboveField = new Set<Element>()
2068+
for (const point of blockedPoints) {
2069+
for (const element of stackAt(point.x, point.y)) {
2070+
if (element === editable || editable.contains(element) || element.contains(editable)) break
2071+
aboveField.add(element)
2072+
}
2073+
}
2074+
if (aboveField.size === 0) return false
2075+
const width = editable.ownerDocument.documentElement.clientWidth || view.innerWidth
2076+
const height = editable.ownerDocument.documentElement.clientHeight || view.innerHeight
20732077
const inset = 4
2074-
const probes: Array<[number, number]> = [
2078+
for (const [x, y] of [
20752079
[inset, inset],
20762080
[width - inset, inset],
20772081
[inset, height - inset],
20782082
[width - inset, height - inset],
2079-
[width / 2, height / 2],
2080-
]
2081-
const layers = new Set<Element>()
2082-
for (const [x, y] of probes) {
2083-
const layer = fixedLayerOf(documentAt.elementFromPoint(x, y))
2084-
if (!layer) return false
2085-
layers.add(layer)
2086-
}
2087-
for (let current: Element | null = editable; current; current = composedParent(current)) {
2088-
if (layers.has(current)) return false
2083+
]) {
2084+
const corner = new Set(stackAt(x, y))
2085+
for (const element of aboveField) if (!corner.has(element)) aboveField.delete(element)
2086+
if (aboveField.size === 0) return false
20892087
}
2090-
return covers.some((cover) => {
2091-
const layer = fixedLayerOf(cover)
2092-
return layer !== null && layers.has(layer)
2093-
})
2088+
return true
20942089
}
20952090
const hits = Array.from(new Set(blockedPoints.flatMap((point) => (point.hit ? [point.hit] : []))))
20962091
let coveredBy: string | undefined
@@ -2099,7 +2094,7 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
20992094
topPageField &&
21002095
hits.length > 0 &&
21012096
!hits.some(insideDialog) &&
2102-
!screenBlockedByLayer(hits)
2097+
!behindBackdrop()
21032098
) {
21042099
chosenPoint = { x: blockedPoints[0].x, y: blockedPoints[0].y }
21052100
coveredBy = blockerLabel(firstBlocker)

0 commit comments

Comments
 (0)