@@ -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+
3752export 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+
312437async 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