Skip to content

Commit 1ce6136

Browse files
authored
fix: distinguish native keyboard scrolling in focus oracle (#2073)
1 parent 897e232 commit 1ce6136

3 files changed

Lines changed: 219 additions & 6 deletions

File tree

packages/runtime-core/src/browser-accessibility.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export interface BrowserAccessibilityScan {
8484
actionId?: string
8585
findings: BrowserAccessibilityFinding[]
8686
accessibilityTree?: { status: "captured" | "unsupported" | "inconclusive"; snapshot?: string; reason?: string; truncated?: boolean }
87-
diagnostics: Array<{ code: string; message: string }>
87+
diagnostics: Array<{ code: string; message: string; metadata?: Record<string, unknown> }>
8888
artifacts?: { screenshot?: string; domSnapshot?: string }
8989
}
9090

packages/runtime-playground/src/browser-accessibility-collector.ts

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ interface PageAccessibilityState {
1919
activeDialogKey?: string
2020
dialogs: ElementEvidence[]
2121
findings: Array<Omit<BrowserAccessibilityFinding, "fingerprint" | "stateDigest" | "transitionId" | "actionId">>
22-
diagnostics: Array<{ code: string; message: string }>
22+
diagnostics: Array<{ code: string; message: string; metadata?: Record<string, unknown> }>
23+
nativeKeyboardScroll?: NativeKeyboardScrollEvidence
2324
includeScopeMatches: number
2425
}
2526

@@ -29,11 +30,25 @@ interface ElementEvidence {
2930
tag: string
3031
role?: string
3132
visible: boolean
33+
rendered: boolean
3234
insideDialog: boolean
3335
states?: BrowserAccessibilityFinding["target"]["states"]
3436
box?: BrowserAccessibilityFinding["target"]["box"]
3537
}
3638

39+
interface NativeKeyboardScrollEvidence {
40+
target: ElementEvidence
41+
targetInScope: boolean
42+
activeElementRetained: boolean
43+
defaultPrevented: boolean
44+
eventCompleted: boolean
45+
synchronousScroll: boolean
46+
scroll: { fromX: number; fromY: number; toX: number; toY: number }
47+
boxBefore: NonNullable<ElementEvidence["box"]>
48+
visibleBefore: boolean
49+
inertBefore: boolean
50+
}
51+
3752
export function createBrowserAccessibilityCollector(page: Page, contract: BrowserAccessibilityContract): BrowserAccessibilityCollector {
3853
const scans: BrowserAccessibilityScan[] = []
3954
const focusHistory: BrowserAccessibilityEvidence["focusHistory"] = []
@@ -50,6 +65,7 @@ export function createBrowserAccessibilityCollector(page: Page, contract: Browse
5065
async beforeAction(action) {
5166
currentAction = action
5267
before = await inspectPage(page, contract)
68+
await beginNativeKeyboardScrollObservation(page, action)
5369
},
5470
async scan(input) {
5571
if (scanAttempts >= contract.budgets.maxScans) {
@@ -62,8 +78,28 @@ export function createBrowserAccessibilityCollector(page: Page, contract: Browse
6278
const findings = [...state.findings]
6379
const action = input.action ?? currentAction
6480

65-
if (contract.ruleTags.includes("focus-loss") && action && state.url === before?.url && state.active.locator === "body" && before.active.locator !== "body" && before.activeInScope) {
66-
findings.push(finding("focus", "focus-loss", "browser-focus-lost", "serious", "focus-lost-to-document", before.active, "focus retained or moved intentionally", "document body"))
81+
if (action && state.nativeKeyboardScroll && isNativeGeneratedSpaceScroll(action, state)) {
82+
const index = findings.findIndex((item) => item.code === "browser-focused-element-hidden" && item.target.frameId === state.active.frameId && item.target.locator === state.active.locator)
83+
if (index >= 0) {
84+
findings.splice(index, 1)
85+
const scroll = state.nativeKeyboardScroll.scroll
86+
state.diagnostics.push({
87+
code: "browser_focus_visibility_native_keyboard_scroll_suppressed",
88+
message: "Offscreen focus was retained after an uncanceled generated Space action produced native page scrolling.",
89+
metadata: {
90+
actionId: action.id,
91+
target: state.active.locator,
92+
scrollDelta: { x: scroll.toX - scroll.fromX, y: scroll.toY - scroll.fromY },
93+
activeElementRetained: true,
94+
},
95+
})
96+
}
97+
}
98+
99+
const interactionTarget = state.nativeKeyboardScroll?.target ?? before?.active
100+
const interactionTargetInScope = state.nativeKeyboardScroll?.targetInScope ?? before?.activeInScope
101+
if (contract.ruleTags.includes("focus-loss") && action && before && state.url === before.url && state.active.locator === "body" && interactionTarget && interactionTarget.locator !== "body" && interactionTargetInScope) {
102+
findings.push(finding("focus", "focus-loss", "browser-focus-lost", "serious", "focus-lost-to-document", interactionTarget, "focus retained or moved intentionally", "document body"))
67103
}
68104

69105
if (contract.ruleTags.includes("dialog-focus")) {
@@ -189,6 +225,7 @@ async function inspectPage(page: Page, contract: BrowserAccessibilityContract):
189225
dialogs: states.flatMap((state) => state.dialogs),
190226
findings: states.flatMap((state) => state.findings).slice(0, contract.budgets.maxViolationsPerScan * contract.budgets.maxTargetsPerViolation),
191227
diagnostics: [...stateDiagnostics, ...diagnostics],
228+
nativeKeyboardScroll: focused?.nativeKeyboardScroll ?? main?.nativeKeyboardScroll,
192229
includeScopeMatches: states.reduce((count, state) => count + state.includeScopeMatches, 0),
193230
}
194231
}
@@ -230,7 +267,7 @@ async function inspectFrame(frame: Frame, frameId: string, contract: BrowserAcce
230267
return value === null ? [] : [[name, value.slice(0, 120)]]
231268
}))
232269
if (target.closest("[inert]")) states.inert = "true"
233-
return { locator: path(target), frameId, tag: target.tagName.toLowerCase(), role: role(target), visible: visible(target), insideDialog: Boolean(target.closest("dialog,[role='dialog'],[role='alertdialog']")), states, box: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height), viewportWidth: innerWidth, viewportHeight: innerHeight } }
270+
return { locator: path(target), frameId, tag: target.tagName.toLowerCase(), role: role(target), visible: visible(target), rendered: rendered(target), insideDialog: Boolean(target.closest("dialog,[role='dialog'],[role='alertdialog']")), states, box: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height), viewportWidth: innerWidth, viewportHeight: innerHeight } }
234271
}
235272
const invalidScopes = [...includeScopes, ...excludeScopes].filter((selector) => { try { document.querySelector(selector); return false } catch { return true } })
236273
const includeScopeMatches = includeScopes.reduce((count, selector) => { try { return count + (document.querySelector(selector) ? 1 : 0) } catch { return count } }, 0)
@@ -305,10 +342,98 @@ async function inspectFrame(frame: Frame, frameId: string, contract: BrowserAcce
305342
try { nativeModal = element.matches(":modal") } catch { nativeModal = element.tagName === "DIALOG" && (element as HTMLDialogElement).open }
306343
return (nativeModal || element.getAttribute("aria-modal") === "true") && visible(element) && inScope(element)
307344
}).map(evidence)
308-
return { url: location.href, focusedDocument: document.hasFocus(), active, activeInScope, activeDialogKey, dialogs, findings, diagnostics: invalidScopes.length > 0 ? [{ code: "browser_accessibility_scope_invalid", message: "One or more accessibility scope selectors were invalid; the scan is inconclusive." }] : [], includeScopeMatches }
345+
const observed = (globalThis as typeof globalThis & { __wpCodeboxNativeKeyboardScroll?: { active?: Element; defaultPrevented: boolean; eventCompleted: boolean; synchronousScroll: boolean; fromX: number; fromY: number; boxBefore: NonNullable<ElementEvidence["box"]>; visibleBefore: boolean; inertBefore: boolean } }).__wpCodeboxNativeKeyboardScroll
346+
const nativeKeyboardScroll = observed ? {
347+
target: evidence(observed.active ?? null),
348+
targetInScope: Boolean(observed.active && inScope(observed.active)),
349+
activeElementRetained: observed.active === document.activeElement,
350+
defaultPrevented: observed.defaultPrevented,
351+
eventCompleted: observed.eventCompleted,
352+
synchronousScroll: observed.synchronousScroll,
353+
scroll: { fromX: observed.fromX, fromY: observed.fromY, toX: scrollX, toY: scrollY },
354+
boxBefore: observed.boxBefore,
355+
visibleBefore: observed.visibleBefore,
356+
inertBefore: observed.inertBefore,
357+
} : undefined
358+
return { url: location.href, focusedDocument: document.hasFocus(), active, activeInScope, activeDialogKey, dialogs, findings, diagnostics: invalidScopes.length > 0 ? [{ code: "browser_accessibility_scope_invalid", message: "One or more accessibility scope selectors were invalid; the scan is inconclusive." }] : [], includeScopeMatches, nativeKeyboardScroll }
309359
}, { includeScopes: contract.includeScopes, excludeScopes: contract.excludeScopes, rules: contract.ruleTags, maxTargets: contract.budgets.maxViolationsPerScan * contract.budgets.maxTargetsPerViolation, frameId })
310360
}
311361

362+
async function beginNativeKeyboardScrollObservation(page: Page, action: BrowserAdaptiveAction): Promise<void> {
363+
if (!isGeneratedDocumentSpaceAction(action)) return
364+
await page.evaluate(() => {
365+
type Observation = { active?: Element; defaultPrevented: boolean; eventCompleted: boolean; synchronousScroll: boolean; fromX: number; fromY: number; boxBefore: { x: number; y: number; width: number; height: number; viewportWidth: number; viewportHeight: number }; visibleBefore: boolean; inertBefore: boolean }
366+
const target = globalThis as typeof globalThis & { __wpCodeboxNativeKeyboardScroll?: Observation; __wpCodeboxNativeKeyboardScrollInstalled?: boolean }
367+
target.__wpCodeboxNativeKeyboardScroll = undefined
368+
if (target.__wpCodeboxNativeKeyboardScrollInstalled) return
369+
target.__wpCodeboxNativeKeyboardScrollInstalled = true
370+
addEventListener("keydown", (event) => {
371+
if (event.key !== " " || !document.activeElement) return
372+
const rect = document.activeElement.getBoundingClientRect()
373+
const style = getComputedStyle(document.activeElement)
374+
const hiddenAncestor = document.activeElement.closest("[hidden],[inert],[aria-hidden='true']")
375+
target.__wpCodeboxNativeKeyboardScroll = {
376+
active: document.activeElement,
377+
defaultPrevented: event.defaultPrevented,
378+
eventCompleted: false,
379+
synchronousScroll: false,
380+
fromX: scrollX,
381+
fromY: scrollY,
382+
boxBefore: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height), viewportWidth: innerWidth, viewportHeight: innerHeight },
383+
visibleBefore: !hiddenAncestor && rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth,
384+
inertBefore: Boolean(document.activeElement.closest("[inert]")),
385+
}
386+
}, true)
387+
addEventListener("keydown", (event) => {
388+
const observed = target.__wpCodeboxNativeKeyboardScroll
389+
if (event.key !== " " || !observed) return
390+
observed.defaultPrevented = event.defaultPrevented
391+
observed.synchronousScroll ||= scrollX !== observed.fromX || scrollY !== observed.fromY
392+
})
393+
addEventListener("keyup", (event) => {
394+
const observed = target.__wpCodeboxNativeKeyboardScroll
395+
if (event.key !== " " || !observed) return
396+
observed.defaultPrevented ||= event.defaultPrevented
397+
observed.synchronousScroll ||= scrollX !== observed.fromX || scrollY !== observed.fromY
398+
observed.eventCompleted = true
399+
})
400+
})
401+
}
402+
403+
function isGeneratedDocumentSpaceAction(action: BrowserAdaptiveAction): boolean {
404+
if (action.family !== "keyboard" || action.frameId !== "document" || action.descriptorId || action.descriptor) return false
405+
const keys = action.steps.map((step) => step.kind === "press" && !step.selector ? String(step.key ?? "") : "")
406+
return keys.length > 0 && keys.every(Boolean) && keys.at(-1) === "Space" && action.id === `keyboard:document:${keys.join(">")}`
407+
}
408+
409+
function isNativeGeneratedSpaceScroll(action: BrowserAdaptiveAction, state: PageAccessibilityState): boolean {
410+
const observed = state.nativeKeyboardScroll
411+
const before = observed?.boxBefore
412+
const after = state.active.box
413+
if (!observed || !before || !after || !isGeneratedDocumentSpaceAction(action)) return false
414+
const deltaX = observed.scroll.toX - observed.scroll.fromX
415+
const deltaY = observed.scroll.toY - observed.scroll.fromY
416+
const geometryTolerance = 2
417+
return observed.eventCompleted
418+
&& !observed.defaultPrevented
419+
&& !observed.synchronousScroll
420+
&& observed.activeElementRetained
421+
&& observed.target.locator === state.active.locator
422+
&& observed.target.frameId === state.active.frameId
423+
&& observed.target.tag === "a"
424+
&& observed.target.role === "link"
425+
&& observed.visibleBefore
426+
&& !observed.inertBefore
427+
&& state.active.rendered
428+
&& state.active.states?.inert !== "true"
429+
&& deltaY > 0
430+
&& Math.abs(deltaX) <= geometryTolerance
431+
&& Math.abs((after.x - before.x) + deltaX) <= geometryTolerance
432+
&& Math.abs((after.y - before.y) + deltaY) <= geometryTolerance
433+
&& Math.abs(after.width - before.width) <= geometryTolerance
434+
&& Math.abs(after.height - before.height) <= geometryTolerance
435+
}
436+
312437
async function accessibilityTree(page: Page, contract: BrowserAccessibilityContract): Promise<NonNullable<BrowserAccessibilityScan["accessibilityTree"]>> {
313438
if (contract.capabilities.accessibilityTree === "disabled") return { status: "unsupported", reason: "disabled_by_contract" }
314439
try {

0 commit comments

Comments
 (0)