Skip to content

Commit 93499e9

Browse files
zelliottwagnermaciel
authored andcommitted
fix(cdk/a11y): Fix the touch/program origin regression introduced in the recent FocusMonitor refactor. (#22754)
* fix(cdk/a11y): Fix the touch/program origin regression introduced in the recent FocusMonitor refactor.
1 parent 463a106 commit 93499e9

File tree

3 files changed

+67
-17
lines changed

3 files changed

+67
-17
lines changed

src/cdk/a11y/focus-monitor/focus-monitor.ts

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
2424
import {takeUntil} from 'rxjs/operators';
2525
import {coerceElement} from '@angular/cdk/coercion';
2626
import {DOCUMENT} from '@angular/common';
27-
import {InputModalityDetector, TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector';
27+
import {
28+
getTarget,
29+
InputModalityDetector,
30+
TOUCH_BUFFER_MS,
31+
} from '../input-modality/input-modality-detector';
2832

2933

3034
export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null;
@@ -96,6 +100,12 @@ export class FocusMonitor implements OnDestroy {
96100
/** The timeout id of the origin clearing timeout. */
97101
private _originTimeoutId: number;
98102

103+
/**
104+
* Whether the origin was determined via a touch interaction. Necessary as properly attributing
105+
* focus events to touch interactions requires special logic.
106+
*/
107+
private _originFromTouchInteraction = false;
108+
99109
/** Map of elements being monitored to their info. */
100110
private _elementInfo = new Map<HTMLElement, MonitoredElementInfo>();
101111

@@ -302,9 +312,15 @@ export class FocusMonitor implements OnDestroy {
302312
}
303313
}
304314

305-
private _getFocusOrigin(): FocusOrigin {
315+
private _getFocusOrigin(focusEventTarget: HTMLElement | null): FocusOrigin {
306316
if (this._origin) {
307-
return this._origin;
317+
// If the origin was realized via a touch interaction, we need to perform additional checks
318+
// to determine whether the focus origin should be attributed to touch or program.
319+
if (this._originFromTouchInteraction) {
320+
return this._shouldBeAttributedToTouch(focusEventTarget) ? 'touch' : 'program';
321+
} else {
322+
return this._origin;
323+
}
308324
}
309325

310326
// If the window has just regained focus, we can restore the most recent origin from before the
@@ -319,6 +335,29 @@ export class FocusMonitor implements OnDestroy {
319335
return (this._windowFocused && this._lastFocusOrigin) ? this._lastFocusOrigin : 'program';
320336
}
321337

338+
/**
339+
* Returns whether the focus event should be attributed to touch. Recall that in IMMEDIATE mode, a
340+
* touch origin isn't immediately reset at the next tick (see _setOrigin). This means that when we
341+
* handle a focus event following a touch interaction, we need to determine whether (1) the focus
342+
* event was directly caused by the touch interaction or (2) the focus event was caused by a
343+
* subsequent programmatic focus call triggered by the touch interaction.
344+
* @param focusEventTarget The target of the focus event under examination.
345+
*/
346+
private _shouldBeAttributedToTouch(focusEventTarget: HTMLElement | null): boolean {
347+
// Please note that this check is not perfect. Consider the following edge case:
348+
//
349+
// <div #parent tabindex="0">
350+
// <div #child tabindex="0" (click)="#parent.focus()"></div>
351+
// </div>
352+
//
353+
// Suppose there is a FocusMonitor in IMMEDIATE mode attached to #parent. When the user touches
354+
// #child, #parent is programmatically focused. This code will attribute the focus to touch
355+
// instead of program. This is a relatively minor edge-case that can be worked around by using
356+
// focusVia(parent, 'program') to focus #parent.
357+
return (this._detectionMode === FocusMonitorDetectionMode.EVENTUAL) ||
358+
!!focusEventTarget?.contains(this._inputModalityDetector._mostRecentTarget);
359+
}
360+
322361
/**
323362
* Sets the focus classes on the element based on the given focus origin.
324363
* @param element The element to update the classes on.
@@ -337,11 +376,12 @@ export class FocusMonitor implements OnDestroy {
337376
* function to clear the origin at the end of a timeout. The duration of the timeout depends on
338377
* the origin being set.
339378
* @param origin The origin to set.
340-
* @param isFromInteractionEvent Whether we are setting the origin from an interaction event.
379+
* @param isFromInteraction Whether we are setting the origin from an interaction event.
341380
*/
342-
private _setOrigin(origin: FocusOrigin, isFromInteractionEvent = false): void {
381+
private _setOrigin(origin: FocusOrigin, isFromInteraction = false): void {
343382
this._ngZone.runOutsideAngular(() => {
344383
this._origin = origin;
384+
this._originFromTouchInteraction = (origin === 'touch') && isFromInteraction;
345385

346386
// If we're in IMMEDIATE mode, reset the origin at the next tick (or in `TOUCH_BUFFER_MS` ms
347387
// for a touch event). We reset the origin at the next tick because Firefox focuses one tick
@@ -350,7 +390,7 @@ export class FocusMonitor implements OnDestroy {
350390
// the event queue. Before doing so, clear any pending timeouts.
351391
if (this._detectionMode === FocusMonitorDetectionMode.IMMEDIATE) {
352392
clearTimeout(this._originTimeoutId);
353-
const ms = ((origin === 'touch') && isFromInteractionEvent) ? TOUCH_BUFFER_MS : 1;
393+
const ms = this._originFromTouchInteraction ? TOUCH_BUFFER_MS : 1;
354394
this._originTimeoutId = setTimeout(() => this._origin = null, ms);
355395
}
356396
});
@@ -370,11 +410,12 @@ export class FocusMonitor implements OnDestroy {
370410
// If we are not counting child-element-focus as focused, make sure that the event target is the
371411
// monitored element itself.
372412
const elementInfo = this._elementInfo.get(element);
373-
if (!elementInfo || (!elementInfo.checkChildren && element !== getTarget(event))) {
413+
const focusEventTarget = getTarget(event);
414+
if (!elementInfo || (!elementInfo.checkChildren && element !== focusEventTarget)) {
374415
return;
375416
}
376417

377-
this._originChanged(element, this._getFocusOrigin(), elementInfo);
418+
this._originChanged(element, this._getFocusOrigin(focusEventTarget), elementInfo);
378419
}
379420

380421
/**
@@ -431,7 +472,7 @@ export class FocusMonitor implements OnDestroy {
431472
// The InputModalityDetector is also just a collection of global listeners.
432473
this._inputModalityDetector.modalityDetected
433474
.pipe(takeUntil(this._stopInputModalityDetector))
434-
.subscribe(modality => { this._setOrigin(modality, true /* isFromInteractionEvent */); });
475+
.subscribe(modality => { this._setOrigin(modality, true /* isFromInteraction */); });
435476
}
436477
}
437478

@@ -492,14 +533,6 @@ export class FocusMonitor implements OnDestroy {
492533
}
493534
}
494535

495-
/** Gets the target of an event, accounting for Shadow DOM. */
496-
function getTarget(event: Event): HTMLElement|null {
497-
// If an event is bound outside the Shadow DOM, the `event.target` will
498-
// point to the shadow root so we have to use `composedPath` instead.
499-
return (event.composedPath ? event.composedPath()[0] : event.target) as HTMLElement | null;
500-
}
501-
502-
503536
/**
504537
* Directive that determines how a particular element was focused (via keyboard, mouse, touch, or
505538
* programmatically) and adds corresponding classes to the element.

src/cdk/a11y/input-modality/input-modality-detector.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ export class InputModalityDetector implements OnDestroy {
100100
return this._modality.value;
101101
}
102102

103+
/**
104+
* The most recently detected input modality event target. Is null if no input modality has been
105+
* detected or if the associated event target is null for some unknown reason.
106+
*/
107+
_mostRecentTarget: HTMLElement | null = null;
108+
103109
/** The underlying BehaviorSubject that emits whenever an input modality is detected. */
104110
private readonly _modality = new BehaviorSubject<InputModality>(null);
105111

@@ -122,6 +128,7 @@ export class InputModalityDetector implements OnDestroy {
122128
if (this._options?.ignoreKeys?.some(keyCode => keyCode === event.keyCode)) { return; }
123129

124130
this._modality.next('keyboard');
131+
this._mostRecentTarget = getTarget(event);
125132
}
126133

127134
/**
@@ -137,6 +144,7 @@ export class InputModalityDetector implements OnDestroy {
137144
// Fake mousedown events are fired by some screen readers when controls are activated by the
138145
// screen reader. Attribute them to keyboard input modality.
139146
this._modality.next(isFakeMousedownFromScreenReader(event) ? 'keyboard' : 'mouse');
147+
this._mostRecentTarget = getTarget(event);
140148
}
141149

142150
/**
@@ -156,6 +164,7 @@ export class InputModalityDetector implements OnDestroy {
156164
this._lastTouchMs = Date.now();
157165

158166
this._modality.next('touch');
167+
this._mostRecentTarget = getTarget(event);
159168
}
160169

161170
constructor(
@@ -194,3 +203,10 @@ export class InputModalityDetector implements OnDestroy {
194203
document.removeEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions);
195204
}
196205
}
206+
207+
/** Gets the target of an event, accounting for Shadow DOM. */
208+
export function getTarget(event: Event): HTMLElement|null {
209+
// If an event is bound outside the Shadow DOM, the `event.target` will
210+
// point to the shadow root so we have to use `composedPath` instead.
211+
return (event.composedPath ? event.composedPath()[0] : event.target) as HTMLElement | null;
212+
}

tools/public_api_guard/cdk/a11y.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ export declare const INPUT_MODALITY_DETECTOR_OPTIONS: InjectionToken<InputModali
194194
export declare type InputModality = 'keyboard' | 'mouse' | 'touch' | null;
195195

196196
export declare class InputModalityDetector implements OnDestroy {
197+
_mostRecentTarget: HTMLElement | null;
197198
readonly modalityChanged: Observable<InputModality>;
198199
readonly modalityDetected: Observable<InputModality>;
199200
get mostRecentModality(): InputModality;

0 commit comments

Comments
 (0)