Skip to content

Commit

Permalink
fix(document-injection): Update to use injected document (#18780)
Browse files Browse the repository at this point in the history
* fix(document-injection): Update to use injected document

Update several classes in Material and the CDK to reference
the injected document instead of accessing the global document
and window variables. This fixes use-cases where the document
is dynamically provided. This change follows the pattern used
in several other places such as the Cdk OverlayContainer.

* fix(document-injection): Update to use injected document

Utilize private functions for _getWindow() and _getDocument()
instead of using public getters.

* fix(document-injection): Update to use injected document

Update golden files to reflect API changes.

(cherry picked from commit 204db56)
  • Loading branch information
timsawyer authored and andrewseguin committed Mar 12, 2020
1 parent 2555344 commit 350e598
Show file tree
Hide file tree
Showing 12 changed files with 172 additions and 30 deletions.
30 changes: 29 additions & 1 deletion src/cdk/a11y/focus-monitor/focus-monitor.ts
Expand Up @@ -15,9 +15,12 @@ import {
NgZone,
OnDestroy,
Output,
Optional,
Inject,
} from '@angular/core';
import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
import {coerceElement} from '@angular/cdk/coercion';
import {DOCUMENT} from '@angular/common';


// This is the value used by AngularJS Material. Through trial and error (on iPhone 6S) they found
Expand Down Expand Up @@ -134,7 +137,15 @@ export class FocusMonitor implements OnDestroy {
this._windowFocusTimeoutId = setTimeout(() => this._windowFocused = false);
}

constructor(private _ngZone: NgZone, private _platform: Platform) {}
/** Used to reference correct document/window */
protected _document?: Document;

constructor(private _ngZone: NgZone,
private _platform: Platform,
/** @breaking-change 11.0.0 make document required */
@Optional() @Inject(DOCUMENT) document?: any) {
this._document = document;
}

/**
* Monitors focus on an element and applies appropriate CSS classes.
Expand Down Expand Up @@ -257,6 +268,17 @@ export class FocusMonitor implements OnDestroy {
this._elementInfo.forEach((_info, element) => this.stopMonitoring(element));
}

/** Access injected document if available or fallback to global document reference */
private _getDocument(): Document {
return this._document || document;
}

/** Use defaultView of injected document if available or fallback to global window reference */
private _getWindow(): Window {
const doc = this._getDocument();
return doc.defaultView || window;
}

private _toggleClass(element: Element, className: string, shouldSet: boolean) {
if (shouldSet) {
element.classList.add(className);
Expand Down Expand Up @@ -393,6 +415,9 @@ export class FocusMonitor implements OnDestroy {
// Note: we listen to events in the capture phase so we
// can detect them even if the user stops propagation.
this._ngZone.runOutsideAngular(() => {
const document = this._getDocument();
const window = this._getWindow();

document.addEventListener('keydown', this._documentKeydownListener,
captureEventListenerOptions);
document.addEventListener('mousedown', this._documentMousedownListener,
Expand All @@ -407,6 +432,9 @@ export class FocusMonitor implements OnDestroy {
private _decrementMonitoredElementCount() {
// Unregister global listeners when last element is unmonitored.
if (!--this._monitoredElementCount) {
const document = this._getDocument();
const window = this._getWindow();

document.removeEventListener('keydown', this._documentKeydownListener,
captureEventListenerOptions);
document.removeEventListener('mousedown', this._documentMousedownListener,
Expand Down
1 change: 1 addition & 0 deletions src/cdk/drag-drop/directives/drag.spec.ts
Expand Up @@ -1948,6 +1948,7 @@ describe('CdkDrag', () => {
// mode in unit tests and there are some issues with doing it in e2e tests.
const fakeDocument = {
body: document.body,
documentElement: document.documentElement,
fullscreenElement: document.createElement('div'),
ELEMENT_NODE: Node.ELEMENT_NODE,
querySelectorAll: function(...args: [string]) {
Expand Down
26 changes: 23 additions & 3 deletions src/cdk/scrolling/scroll-dispatcher.ts
Expand Up @@ -7,11 +7,11 @@
*/

import {Platform} from '@angular/cdk/platform';
import {ElementRef, Injectable, NgZone, OnDestroy} from '@angular/core';
import {ElementRef, Injectable, NgZone, OnDestroy, Optional, Inject} from '@angular/core';
import {fromEvent, of as observableOf, Subject, Subscription, Observable, Observer} from 'rxjs';
import {auditTime, filter} from 'rxjs/operators';
import {CdkScrollable} from './scrollable';

import {DOCUMENT} from '@angular/common';

/** Time in ms to throttle the scrolling events by default. */
export const DEFAULT_SCROLL_TIME = 20;
Expand All @@ -22,7 +22,15 @@ export const DEFAULT_SCROLL_TIME = 20;
*/
@Injectable({providedIn: 'root'})
export class ScrollDispatcher implements OnDestroy {
constructor(private _ngZone: NgZone, private _platform: Platform) { }
/** Used to reference correct document/window */
protected _document?: Document;

constructor(private _ngZone: NgZone,
private _platform: Platform,
/** @breaking-change 11.0.0 make document required */
@Optional() @Inject(DOCUMENT) document?: any) {
this._document = document;
}

/** Subject for notifying that a registered scrollable reference element has been scrolled. */
private _scrolled = new Subject<CdkScrollable|void>();
Expand Down Expand Up @@ -136,6 +144,17 @@ export class ScrollDispatcher implements OnDestroy {
return scrollingContainers;
}

/** Access injected document if available or fallback to global document reference */
private _getDocument(): Document {
return this._document || document;
}

/** Use defaultView of injected document if available or fallback to global window reference */
private _getWindow(): Window {
const doc = this._getDocument();
return doc.defaultView || window;
}

/** Returns true if the element is contained within the provided Scrollable. */
private _scrollableContainsElement(scrollable: CdkScrollable, elementRef: ElementRef): boolean {
let element: HTMLElement | null = elementRef.nativeElement;
Expand All @@ -153,6 +172,7 @@ export class ScrollDispatcher implements OnDestroy {
/** Sets up the global scroll listeners. */
private _addGlobalListener() {
this._globalSubscription = this._ngZone.runOutsideAngular(() => {
const window = this._getWindow();
return fromEvent(window.document, 'scroll').subscribe(() => this._scrolled.next());
});
}
Expand Down
29 changes: 27 additions & 2 deletions src/cdk/scrolling/viewport-ruler.ts
Expand Up @@ -7,9 +7,10 @@
*/

import {Platform} from '@angular/cdk/platform';
import {Injectable, NgZone, OnDestroy} from '@angular/core';
import {Injectable, NgZone, OnDestroy, Optional, Inject} from '@angular/core';
import {merge, of as observableOf, fromEvent, Observable, Subscription} from 'rxjs';
import {auditTime} from 'rxjs/operators';
import {DOCUMENT} from '@angular/common';

/** Time in ms to throttle the resize events by default. */
export const DEFAULT_RESIZE_TIME = 20;
Expand All @@ -35,8 +36,18 @@ export class ViewportRuler implements OnDestroy {
/** Subscription to streams that invalidate the cached viewport dimensions. */
private _invalidateCache: Subscription;

constructor(private _platform: Platform, ngZone: NgZone) {
/** Used to reference correct document/window */
protected _document?: Document;

constructor(private _platform: Platform,
ngZone: NgZone,
/** @breaking-change 11.0.0 make document required */
@Optional() @Inject(DOCUMENT) document?: any) {
this._document = document;

ngZone.runOutsideAngular(() => {
const window = this._getWindow();

this._change = _platform.isBrowser ?
merge(fromEvent(window, 'resize'), fromEvent(window, 'orientationchange')) :
observableOf();
Expand Down Expand Up @@ -105,6 +116,8 @@ export class ViewportRuler implements OnDestroy {
// `scrollTop` and `scrollLeft` is inconsistent. However, using the bounding rect of
// `document.documentElement` works consistently, where the `top` and `left` values will
// equal negative the scroll position.
const document = this._getDocument();
const window = this._getWindow();
const documentElement = document.documentElement!;
const documentRect = documentElement.getBoundingClientRect();

Expand All @@ -125,8 +138,20 @@ export class ViewportRuler implements OnDestroy {
return throttleTime > 0 ? this._change.pipe(auditTime(throttleTime)) : this._change;
}

/** Access injected document if available or fallback to global document reference */
private _getDocument(): Document {
return this._document || document;
}

/** Use defaultView of injected document if available or fallback to global window reference */
private _getWindow(): Window {
const doc = this._getDocument();
return doc.defaultView || window;
}

/** Updates the cached viewport size. */
private _updateViewportSize() {
const window = this._getWindow();
this._viewportSize = this._platform.isBrowser ?
{width: window.innerWidth, height: window.innerHeight} :
{width: 0, height: 0};
Expand Down
32 changes: 27 additions & 5 deletions src/cdk/text-field/autosize.ts
Expand Up @@ -21,11 +21,13 @@ import {
OnDestroy,
NgZone,
HostListener,
Optional,
Inject,
} from '@angular/core';
import {Platform} from '@angular/cdk/platform';
import {auditTime, takeUntil} from 'rxjs/operators';
import {fromEvent, Subject} from 'rxjs';

import {DOCUMENT} from '@angular/common';

/** Directive to automatically resize a textarea to fit its content. */
@Directive({
Expand Down Expand Up @@ -89,10 +91,16 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy {
/** Cached height of a textarea with a single row. */
private _cachedLineHeight: number;

constructor(
private _elementRef: ElementRef<HTMLElement>,
private _platform: Platform,
private _ngZone: NgZone) {
/** Used to reference correct document/window */
protected _document?: Document;

constructor(private _elementRef: ElementRef<HTMLElement>,
private _platform: Platform,
private _ngZone: NgZone,
/** @breaking-change 11.0.0 make document required */
@Optional() @Inject(DOCUMENT) document?: any) {
this._document = document;

this._textareaElement = this._elementRef.nativeElement as HTMLTextAreaElement;
}

Expand Down Expand Up @@ -124,6 +132,8 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy {
this.resizeToFitContent();

this._ngZone.runOutsideAngular(() => {
const window = this._getWindow();

fromEvent(window, 'resize')
.pipe(auditTime(16), takeUntil(this._destroyed))
.subscribe(() => this.resizeToFitContent(true));
Expand Down Expand Up @@ -263,13 +273,25 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy {
// no-op handler that ensures we're running change detection on input events.
}

/** Access injected document if available or fallback to global document reference */
private _getDocument(): Document {
return this._document || document;
}

/** Use defaultView of injected document if available or fallback to global window reference */
private _getWindow(): Window {
const doc = this._getDocument();
return doc.defaultView || window;
}

/**
* Scrolls a textarea to the caret position. On Firefox resizing the textarea will
* prevent it from scrolling to the caret position. We need to re-set the selection
* in order for it to scroll to the proper position.
*/
private _scrollToCaretPosition(textarea: HTMLTextAreaElement) {
const {selectionStart, selectionEnd} = textarea;
const document = this._getDocument();

// IE will throw an "Unspecified error" if we try to set the selection range after the
// element has been removed from the DOM. Assert that the directive hasn't been destroyed
Expand Down
9 changes: 9 additions & 0 deletions src/material/autocomplete/autocomplete-trigger.ts
Expand Up @@ -225,6 +225,8 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, AfterViewIn
}

ngAfterViewInit() {
const window = this._getWindow();

if (typeof window !== 'undefined') {
this._zone.runOutsideAngular(() => {
window.addEventListener('blur', this._windowBlurHandler);
Expand All @@ -245,6 +247,8 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, AfterViewIn
}

ngOnDestroy() {
const window = this._getWindow();

if (typeof window !== 'undefined') {
window.removeEventListener('blur', this._windowBlurHandler);
}
Expand Down Expand Up @@ -752,5 +756,10 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, AfterViewIn
return !element.readOnly && !element.disabled && !this._autocompleteDisabled;
}

/** Use defaultView of injected document if available or fallback to global window reference */
private _getWindow(): Window {
return this._document?.defaultView || window;
}

static ngAcceptInputType_autocompleteDisabled: BooleanInput;
}
44 changes: 30 additions & 14 deletions src/material/core/common-behaviors/common-module.ts
Expand Up @@ -10,7 +10,7 @@ import {HighContrastModeDetector} from '@angular/cdk/a11y';
import {BidiModule} from '@angular/cdk/bidi';
import {Inject, InjectionToken, isDevMode, NgModule, Optional, Version} from '@angular/core';
import {VERSION as CDK_VERSION} from '@angular/cdk';

import {DOCUMENT} from '@angular/common';

// Private version constant to circumvent test/build issues,
// i.e. avoid core to depend on the @angular/material primary entry-point
Expand Down Expand Up @@ -62,18 +62,19 @@ export class MatCommonModule {
/** Whether we've done the global sanity checks (e.g. a theme is loaded, there is a doctype). */
private _hasDoneGlobalChecks = false;

/** Reference to the global `document` object. */
private _document = typeof document === 'object' && document ? document : null;

/** Reference to the global 'window' object. */
private _window = typeof window === 'object' && window ? window : null;

/** Configured sanity checks. */
private _sanityChecks: SanityChecks;

/** Used to reference correct document/window */
protected _document?: Document;

constructor(
highContrastModeDetector: HighContrastModeDetector,
@Optional() @Inject(MATERIAL_SANITY_CHECKS) sanityChecks: any) {
@Optional() @Inject(MATERIAL_SANITY_CHECKS) sanityChecks: any,
/** @breaking-change 11.0.0 make document required */
@Optional() @Inject(DOCUMENT) document?: any) {
this._document = document;

// While A11yModule also does this, we repeat it here to avoid importing A11yModule
// in MatCommonModule.
highContrastModeDetector._applyBodyHighContrastModeCssClasses();
Expand All @@ -90,22 +91,36 @@ export class MatCommonModule {
}
}

/** Access injected document if available or fallback to global document reference */
private _getDocument(): Document | null {
const doc = this._document || document;
return typeof doc === 'object' && doc ? doc : null;
}

/** Use defaultView of injected document if available or fallback to global window reference */
private _getWindow(): Window | null {
const doc = this._getDocument();
const win = doc?.defaultView || window;
return typeof win === 'object' && win ? win : null;
}

/** Whether any sanity checks are enabled. */
private _checksAreEnabled(): boolean {
return isDevMode() && !this._isTestEnv();
}

/** Whether the code is running in tests. */
private _isTestEnv() {
const window = this._window as any;
const window = this._getWindow() as any;
return window && (window.__karma__ || window.jasmine);
}

private _checkDoctypeIsDefined(): void {
const isEnabled = this._checksAreEnabled() &&
(this._sanityChecks === true || (this._sanityChecks as GranularSanityChecks).doctype);
const document = this._getDocument();

if (isEnabled && this._document && !this._document.doctype) {
if (isEnabled && document && !document.doctype) {
console.warn(
'Current document does not have a doctype. This may cause ' +
'some Angular Material components not to behave as expected.'
Expand All @@ -118,16 +133,17 @@ export class MatCommonModule {
// and the `body` won't be defined if the consumer put their scripts in the `head`.
const isDisabled = !this._checksAreEnabled() ||
(this._sanityChecks === false || !(this._sanityChecks as GranularSanityChecks).theme);
const document = this._getDocument();

if (isDisabled || !this._document || !this._document.body ||
if (isDisabled || !document || !document.body ||
typeof getComputedStyle !== 'function') {
return;
}

const testElement = this._document.createElement('div');
const testElement = document.createElement('div');

testElement.classList.add('mat-theme-loaded-marker');
this._document.body.appendChild(testElement);
document.body.appendChild(testElement);

const computedStyle = getComputedStyle(testElement);

Expand All @@ -142,7 +158,7 @@ export class MatCommonModule {
);
}

this._document.body.removeChild(testElement);
document.body.removeChild(testElement);
}

/** Checks whether the material version matches the cdk version */
Expand Down

0 comments on commit 350e598

Please sign in to comment.