diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index b71190344a8f..fc1386c7386f 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -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; @@ -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(); @@ -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 @@ -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: + // + //
+ //
+ //
+ // + // 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. @@ -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 @@ -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); } }); @@ -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); } /** @@ -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 */); }); } } @@ -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. diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts index 66b41c466faf..6ec492a0c7f7 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.ts @@ -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(null); @@ -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); } /** @@ -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); } /** @@ -156,6 +164,7 @@ export class InputModalityDetector implements OnDestroy { this._lastTouchMs = Date.now(); this._modality.next('touch'); + this._mostRecentTarget = getTarget(event); } constructor( @@ -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; +} diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 0dfb661aaf9f..fa2e07b811df 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -194,6 +194,7 @@ export declare const INPUT_MODALITY_DETECTOR_OPTIONS: InjectionToken; readonly modalityDetected: Observable; get mostRecentModality(): InputModality;