Skip to content

Commit

Permalink
fix(cdk/a11y): Fix the touch/program origin regression introduced in …
Browse files Browse the repository at this point in the history
…the recent FocusMonitor refactor. (#22754)

* fix(cdk/a11y): Fix the touch/program origin regression introduced in the recent FocusMonitor refactor.
  • Loading branch information
zelliott authored and wagnermaciel committed Jun 10, 2021
1 parent 463a106 commit 93499e9
Show file tree
Hide file tree
Showing 3 changed files with 67 additions and 17 deletions.
67 changes: 50 additions & 17 deletions src/cdk/a11y/focus-monitor/focus-monitor.ts
Expand Up @@ -24,7 +24,11 @@ import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
import {coerceElement} from '@angular/cdk/coercion';
import {DOCUMENT} from '@angular/common';
import {InputModalityDetector, TOUCH_BUFFER_MS} from '../input-modality/input-modality-detector';
import {
getTarget,
InputModalityDetector,
TOUCH_BUFFER_MS,
} from '../input-modality/input-modality-detector';


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

/**
* Whether the origin was determined via a touch interaction. Necessary as properly attributing
* focus events to touch interactions requires special logic.
*/
private _originFromTouchInteraction = false;

/** Map of elements being monitored to their info. */
private _elementInfo = new Map<HTMLElement, MonitoredElementInfo>();

Expand Down Expand Up @@ -302,9 +312,15 @@ export class FocusMonitor implements OnDestroy {
}
}

private _getFocusOrigin(): FocusOrigin {
private _getFocusOrigin(focusEventTarget: HTMLElement | null): FocusOrigin {
if (this._origin) {
return this._origin;
// If the origin was realized via a touch interaction, we need to perform additional checks
// to determine whether the focus origin should be attributed to touch or program.
if (this._originFromTouchInteraction) {
return this._shouldBeAttributedToTouch(focusEventTarget) ? 'touch' : 'program';
} else {
return this._origin;
}
}

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

/**
* Returns whether the focus event should be attributed to touch. Recall that in IMMEDIATE mode, a
* touch origin isn't immediately reset at the next tick (see _setOrigin). This means that when we
* handle a focus event following a touch interaction, we need to determine whether (1) the focus
* event was directly caused by the touch interaction or (2) the focus event was caused by a
* subsequent programmatic focus call triggered by the touch interaction.
* @param focusEventTarget The target of the focus event under examination.
*/
private _shouldBeAttributedToTouch(focusEventTarget: HTMLElement | null): boolean {
// Please note that this check is not perfect. Consider the following edge case:
//
// <div #parent tabindex="0">
// <div #child tabindex="0" (click)="#parent.focus()"></div>
// </div>
//
// Suppose there is a FocusMonitor in IMMEDIATE mode attached to #parent. When the user touches
// #child, #parent is programmatically focused. This code will attribute the focus to touch
// instead of program. This is a relatively minor edge-case that can be worked around by using
// focusVia(parent, 'program') to focus #parent.
return (this._detectionMode === FocusMonitorDetectionMode.EVENTUAL) ||
!!focusEventTarget?.contains(this._inputModalityDetector._mostRecentTarget);
}

/**
* Sets the focus classes on the element based on the given focus origin.
* @param element The element to update the classes on.
Expand All @@ -337,11 +376,12 @@ export class FocusMonitor implements OnDestroy {
* function to clear the origin at the end of a timeout. The duration of the timeout depends on
* the origin being set.
* @param origin The origin to set.
* @param isFromInteractionEvent Whether we are setting the origin from an interaction event.
* @param isFromInteraction Whether we are setting the origin from an interaction event.
*/
private _setOrigin(origin: FocusOrigin, isFromInteractionEvent = false): void {
private _setOrigin(origin: FocusOrigin, isFromInteraction = false): void {
this._ngZone.runOutsideAngular(() => {
this._origin = origin;
this._originFromTouchInteraction = (origin === 'touch') && isFromInteraction;

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

this._originChanged(element, this._getFocusOrigin(), elementInfo);
this._originChanged(element, this._getFocusOrigin(focusEventTarget), elementInfo);
}

/**
Expand Down Expand Up @@ -431,7 +472,7 @@ export class FocusMonitor implements OnDestroy {
// The InputModalityDetector is also just a collection of global listeners.
this._inputModalityDetector.modalityDetected
.pipe(takeUntil(this._stopInputModalityDetector))
.subscribe(modality => { this._setOrigin(modality, true /* isFromInteractionEvent */); });
.subscribe(modality => { this._setOrigin(modality, true /* isFromInteraction */); });
}
}

Expand Down Expand Up @@ -492,14 +533,6 @@ export class FocusMonitor implements OnDestroy {
}
}

/** Gets the target of an event, accounting for Shadow DOM. */
function getTarget(event: Event): HTMLElement|null {
// If an event is bound outside the Shadow DOM, the `event.target` will
// point to the shadow root so we have to use `composedPath` instead.
return (event.composedPath ? event.composedPath()[0] : event.target) as HTMLElement | null;
}


/**
* Directive that determines how a particular element was focused (via keyboard, mouse, touch, or
* programmatically) and adds corresponding classes to the element.
Expand Down
16 changes: 16 additions & 0 deletions src/cdk/a11y/input-modality/input-modality-detector.ts
Expand Up @@ -100,6 +100,12 @@ export class InputModalityDetector implements OnDestroy {
return this._modality.value;
}

/**
* The most recently detected input modality event target. Is null if no input modality has been
* detected or if the associated event target is null for some unknown reason.
*/
_mostRecentTarget: HTMLElement | null = null;

/** The underlying BehaviorSubject that emits whenever an input modality is detected. */
private readonly _modality = new BehaviorSubject<InputModality>(null);

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

this._modality.next('keyboard');
this._mostRecentTarget = getTarget(event);
}

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

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

this._modality.next('touch');
this._mostRecentTarget = getTarget(event);
}

constructor(
Expand Down Expand Up @@ -194,3 +203,10 @@ export class InputModalityDetector implements OnDestroy {
document.removeEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions);
}
}

/** Gets the target of an event, accounting for Shadow DOM. */
export function getTarget(event: Event): HTMLElement|null {
// If an event is bound outside the Shadow DOM, the `event.target` will
// point to the shadow root so we have to use `composedPath` instead.
return (event.composedPath ? event.composedPath()[0] : event.target) as HTMLElement | null;
}
1 change: 1 addition & 0 deletions tools/public_api_guard/cdk/a11y.d.ts
Expand Up @@ -194,6 +194,7 @@ export declare const INPUT_MODALITY_DETECTOR_OPTIONS: InjectionToken<InputModali
export declare type InputModality = 'keyboard' | 'mouse' | 'touch' | null;

export declare class InputModalityDetector implements OnDestroy {
_mostRecentTarget: HTMLElement | null;
readonly modalityChanged: Observable<InputModality>;
readonly modalityDetected: Observable<InputModality>;
get mostRecentModality(): InputModality;
Expand Down

0 comments on commit 93499e9

Please sign in to comment.