Skip to content

Commit

Permalink
refactor(cdk/platform): add common utility for resolving focused elem…
Browse files Browse the repository at this point in the history
…ent (#22708)

Based on a discussion on an earlier PR, these changes move the logic for resolving the `activeElement` while piercing through the shadow DOM into a common helper. Furthermore, they expand the logic to pierce through multiple layers of shadow DOM.
  • Loading branch information
crisbeto committed May 21, 2021
1 parent 561a38d commit 25665dc
Show file tree
Hide file tree
Showing 10 changed files with 48 additions and 38 deletions.
15 changes: 6 additions & 9 deletions src/cdk/a11y/focus-trap/focus-trap.ts
Expand Up @@ -7,6 +7,7 @@
*/

import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform';
import {DOCUMENT} from '@angular/common';
import {
AfterContentInit,
Expand Down Expand Up @@ -388,8 +389,6 @@ export class FocusTrapFactory {
exportAs: 'cdkTrapFocus',
})
export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoCheck {
private _document: Document;

/** Underlying FocusTrap instance. */
focusTrap: FocusTrap;

Expand All @@ -413,9 +412,11 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
constructor(
private _elementRef: ElementRef<HTMLElement>,
private _focusTrapFactory: FocusTrapFactory,
/**
* @deprecated No longer being used. To be removed.
* @breaking-change 13.0.0
*/
@Inject(DOCUMENT) _document: any) {

this._document = _document;
this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true);
}

Expand Down Expand Up @@ -454,11 +455,7 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
}

private _captureFocus() {
// If the `activeElement` is inside a shadow root, `document.activeElement` will
// point to the shadow root so we have to descend into it ourselves.
const activeElement = this._document?.activeElement as HTMLElement|null;
this._previouslyFocusedElement =
activeElement?.shadowRoot?.activeElement as HTMLElement || activeElement;
this._previouslyFocusedElement = _getFocusedElementPierceShadowDom();
this.focusTrap.focusInitialElementWhenReady();
}

Expand Down
20 changes: 20 additions & 0 deletions src/cdk/platform/features/shadow-dom.ts
Expand Up @@ -32,3 +32,23 @@ export function _getShadowRoot(element: HTMLElement): ShadowRoot | null {

return null;
}

/**
* Gets the currently-focused element on the page while
* also piercing through Shadow DOM boundaries.
*/
export function _getFocusedElementPierceShadowDom(): HTMLElement | null {
let activeElement = typeof document !== 'undefined' && document ?
document.activeElement as HTMLElement | null : null;

while (activeElement && activeElement.shadowRoot) {
const newActiveElement = activeElement.shadowRoot.activeElement as HTMLElement | null;
if (newActiveElement === activeElement) {
break;
} else {
activeElement = newActiveElement;
}
}

return activeElement;
}
1 change: 1 addition & 0 deletions src/material/bottom-sheet/BUILD.bazel
Expand Up @@ -27,6 +27,7 @@ ng_module(
"//src/cdk/keycodes",
"//src/cdk/layout",
"//src/cdk/overlay",
"//src/cdk/platform",
"//src/cdk/portal",
"//src/material/core",
"@npm//@angular/animations",
Expand Down
15 changes: 4 additions & 11 deletions src/material/bottom-sheet/bottom-sheet-container.ts
Expand Up @@ -34,6 +34,7 @@ import {matBottomSheetAnimations} from './bottom-sheet-animations';
import {Subscription} from 'rxjs';
import {DOCUMENT} from '@angular/common';
import {FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y';
import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform';

// TODO(crisbeto): consolidate some logic between this, MatDialog and MatSnackBar

Expand Down Expand Up @@ -207,7 +208,7 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr
if (this.bottomSheetConfig.autoFocus) {
this._focusTrap.focusInitialElementWhenReady();
} else {
const activeElement = this._getActiveElement();
const activeElement = _getFocusedElementPierceShadowDom();

// Otherwise ensure that focus is on the container. It's possible that a different
// component tried to move focus while the open animation was running. See:
Expand All @@ -226,7 +227,7 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr

// We need the extra check, because IE can set the `activeElement` to null in some cases.
if (this.bottomSheetConfig.restoreFocus && toFocus && typeof toFocus.focus === 'function') {
const activeElement = this._getActiveElement();
const activeElement = _getFocusedElementPierceShadowDom();
const element = this._elementRef.nativeElement;

// Make sure that focus is still inside the bottom sheet or is on the body (usually because a
Expand All @@ -246,19 +247,11 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr

/** Saves a reference to the element that was focused before the bottom sheet was opened. */
private _savePreviouslyFocusedElement() {
this._elementFocusedBeforeOpened = this._getActiveElement();
this._elementFocusedBeforeOpened = _getFocusedElementPierceShadowDom();

// The `focus` method isn't available during server-side rendering.
if (this._elementRef.nativeElement.focus) {
Promise.resolve().then(() => this._elementRef.nativeElement.focus());
}
}

/** Gets the currently-focused element on the page. */
private _getActiveElement(): HTMLElement | null {
// If the `activeElement` is inside a shadow root, `document.activeElement` will
// point to the shadow root so we have to descend into it ourselves.
const activeElement = this._document.activeElement;
return activeElement?.shadowRoot?.activeElement as HTMLElement || activeElement;
}
}
1 change: 1 addition & 0 deletions src/material/datepicker/BUILD.bazel
Expand Up @@ -32,6 +32,7 @@ ng_module(
"//src/cdk/coercion",
"//src/cdk/keycodes",
"//src/cdk/overlay",
"//src/cdk/platform",
"//src/cdk/portal",
"//src/material/button",
"//src/material/core",
Expand Down
13 changes: 7 additions & 6 deletions src/material/datepicker/datepicker-base.ts
Expand Up @@ -50,6 +50,7 @@ import {
} from '@angular/material/core';
import {merge, Subject, Observable, Subscription} from 'rxjs';
import {filter, take} from 'rxjs/operators';
import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform';
import {MatCalendar, MatCalendarView} from './calendar';
import {matDatepickerAnimations} from './datepicker-animations';
import {createMissingDateImplError} from './datepicker-errors';
Expand Down Expand Up @@ -452,7 +453,11 @@ export abstract class MatDatepickerBase<C extends MatDatepickerControl<D>, S,
@Inject(MAT_DATEPICKER_SCROLL_STRATEGY) scrollStrategy: any,
@Optional() private _dateAdapter: DateAdapter<D>,
@Optional() private _dir: Directionality,
@Optional() @Inject(DOCUMENT) private _document: any,
/**
* @deprecated No longer being used. To be removed.
* @breaking-change 13.0.0
*/
@Optional() @Inject(DOCUMENT) _document: any,
private _model: MatDateSelectionModel<S, D>) {
if (!this._dateAdapter && (typeof ngDevMode === 'undefined' || ngDevMode)) {
throw createMissingDateImplError('DateAdapter');
Expand Down Expand Up @@ -553,11 +558,7 @@ export abstract class MatDatepickerBase<C extends MatDatepickerControl<D>, S,
throw Error('Attempted to open an MatDatepicker with no associated input.');
}

// If the `activeElement` is inside a shadow root, `document.activeElement` will
// point to the shadow root so we have to descend into it ourselves.
const activeElement: HTMLElement|null = this._document?.activeElement;
this._focusedElementBeforeOpen =
activeElement?.shadowRoot?.activeElement as HTMLElement || activeElement;
this._focusedElementBeforeOpen = _getFocusedElementPierceShadowDom();
this._openOverlay();
this._opened = true;
this.openedStream.emit();
Expand Down
1 change: 1 addition & 0 deletions src/material/dialog/BUILD.bazel
Expand Up @@ -26,6 +26,7 @@ ng_module(
"//src/cdk/bidi",
"//src/cdk/keycodes",
"//src/cdk/overlay",
"//src/cdk/platform",
"//src/cdk/portal",
"//src/material/core",
"@npm//@angular/animations",
Expand Down
15 changes: 4 additions & 11 deletions src/material/dialog/dialog-container.ts
Expand Up @@ -8,6 +8,7 @@

import {AnimationEvent} from '@angular/animations';
import {FocusMonitor, FocusOrigin, FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y';
import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform';
import {
BasePortalOutlet,
CdkPortalOutlet,
Expand Down Expand Up @@ -182,7 +183,7 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet {
// We need the extra check, because IE can set the `activeElement` to null in some cases.
if (this._config.restoreFocus && previousElement &&
typeof previousElement.focus === 'function') {
const activeElement = this._getActiveElement();
const activeElement = _getFocusedElementPierceShadowDom();
const element = this._elementRef.nativeElement;

// Make sure that focus is still inside the dialog or is on the body (usually because a
Expand Down Expand Up @@ -213,7 +214,7 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet {
/** Captures the element that was focused before the dialog was opened. */
private _capturePreviouslyFocusedElement() {
if (this._document) {
this._elementFocusedBeforeDialogWasOpened = this._getActiveElement() as HTMLElement;
this._elementFocusedBeforeDialogWasOpened = _getFocusedElementPierceShadowDom();
}
}

Expand All @@ -228,17 +229,9 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet {
/** Returns whether focus is inside the dialog. */
private _containsFocus() {
const element = this._elementRef.nativeElement;
const activeElement = this._getActiveElement();
const activeElement = _getFocusedElementPierceShadowDom();
return element === activeElement || element.contains(activeElement);
}

/** Gets the currently-focused element on the page. */
private _getActiveElement(): Element | null {
// If the `activeElement` is inside a shadow root, `document.activeElement` will
// point to the shadow root so we have to descend into it ourselves.
const activeElement = this._document.activeElement;
return activeElement?.shadowRoot?.activeElement as HTMLElement || activeElement;
}
}

/**
Expand Down
3 changes: 2 additions & 1 deletion tools/public_api_guard/cdk/a11y.d.ts
Expand Up @@ -51,7 +51,8 @@ export declare class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChan
get enabled(): boolean;
set enabled(value: boolean);
focusTrap: FocusTrap;
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory, _document: any);
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory,
_document: any);
ngAfterContentInit(): void;
ngDoCheck(): void;
ngOnChanges(changes: SimpleChanges): void;
Expand Down
2 changes: 2 additions & 0 deletions tools/public_api_guard/cdk/platform.d.ts
@@ -1,3 +1,5 @@
export declare function _getFocusedElementPierceShadowDom(): HTMLElement | null;

export declare function _getShadowRoot(element: HTMLElement): ShadowRoot | null;

export declare function _supportsShadowDom(): boolean;
Expand Down

0 comments on commit 25665dc

Please sign in to comment.