Skip to content

Commit

Permalink
fix(focus-monitor): cleanup global listeners and don't require Rende…
Browse files Browse the repository at this point in the history
…rer2 (#7728)

* fix(focus-monitor): cleanup global listeners and don't require Renderer2
  • Loading branch information
mmalerba authored and josephperrott committed Nov 10, 2017
1 parent 0e03bf4 commit 8dc8dc4
Show file tree
Hide file tree
Showing 13 changed files with 127 additions and 92 deletions.
4 changes: 1 addition & 3 deletions src/cdk/a11y/focus-monitor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {A11yModule} from './index';
describe('FocusMonitor', () => {
let fixture: ComponentFixture<PlainButton>;
let buttonElement: HTMLElement;
let buttonRenderer: Renderer2;
let focusMonitor: FocusMonitor;
let changeHandler: (origin: FocusOrigin) => void;

Expand All @@ -28,11 +27,10 @@ describe('FocusMonitor', () => {
fixture.detectChanges();

buttonElement = fixture.debugElement.query(By.css('button')).nativeElement;
buttonRenderer = fixture.componentInstance.renderer;
focusMonitor = fm;

changeHandler = jasmine.createSpy('focus origin change handler');
focusMonitor.monitor(buttonElement, buttonRenderer, false).subscribe(changeHandler);
focusMonitor.monitor(buttonElement, false).subscribe(changeHandler);
patchElementFocus(buttonElement);
}));

Expand Down
118 changes: 82 additions & 36 deletions src/cdk/a11y/focus-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null;
type MonitoredElementInfo = {
unlisten: Function,
checkChildren: boolean,
renderer: Renderer2,
subject: Subject<FocusOrigin>
};

Expand All @@ -62,22 +61,38 @@ export class FocusMonitor {
/** Weak map of elements being monitored to their info. */
private _elementInfo = new WeakMap<Element, MonitoredElementInfo>();

constructor(private _ngZone: NgZone, private _platform: Platform) {
this._ngZone.runOutsideAngular(() => this._registerDocumentEvents());
}
/** A map of global objects to lists of current listeners. */
private _unregisterGlobalListeners = () => {};

/** The number of elements currently being monitored. */
private _monitoredElementCount = 0;

constructor(private _ngZone: NgZone, private _platform: Platform) {}

/**
* @docs-private
* @deprecated renderer param no longer needed.
*/
monitor(element: HTMLElement, renderer: Renderer2, checkChildren: boolean):
Observable<FocusOrigin>;
/**
* Monitors focus on an element and applies appropriate CSS classes.
* @param element The element to monitor
* @param renderer The renderer to use to apply CSS classes to the element.
* @param checkChildren Whether to count the element as focused when its children are focused.
* @returns An observable that emits when the focus state of the element changes.
* When the element is blurred, null will be emitted.
*/
monitor(element: HTMLElement, checkChildren: boolean): Observable<FocusOrigin>;
monitor(
element: HTMLElement,
renderer: Renderer2,
checkChildren: boolean): Observable<FocusOrigin> {
renderer: Renderer2 | boolean,
checkChildren?: boolean): Observable<FocusOrigin> {
// TODO(mmalerba): clean up after deprecated signature is removed.
if (!(renderer instanceof Renderer2)) {
checkChildren = renderer;
}
checkChildren = !!checkChildren;

// Do nothing if we're not on the browser platform.
if (!this._platform.isBrowser) {
return observableOf(null);
Expand All @@ -93,10 +108,10 @@ export class FocusMonitor {
let info: MonitoredElementInfo = {
unlisten: () => {},
checkChildren: checkChildren,
renderer: renderer,
subject: new Subject<FocusOrigin>()
};
this._elementInfo.set(element, info);
this._incrementMonitoredElementCount();

// Start listening. We need to listen in capture phase since focus events don't bubble.
let focusListener = (event: FocusEvent) => this._onFocus(event, element);
Expand Down Expand Up @@ -128,6 +143,7 @@ export class FocusMonitor {

this._setClasses(element);
this._elementInfo.delete(element);
this._decrementMonitoredElementCount();
}
}

Expand All @@ -142,49 +158,69 @@ export class FocusMonitor {
}

/** Register necessary event listeners on the document and window. */
private _registerDocumentEvents() {
private _registerGlobalListeners() {
// Do nothing if we're not on the browser platform.
if (!this._platform.isBrowser) {
return;
}

// Note: we listen to events in the capture phase so we can detect them even if the user stops
// propagation.

// On keydown record the origin and clear any touch event that may be in progress.
document.addEventListener('keydown', () => {
let documentKeydownListener = () => {
this._lastTouchTarget = null;
this._setOriginForCurrentEventQueue('keyboard');
}, true);
};

// On mousedown record the origin only if there is not touch target, since a mousedown can
// happen as a result of a touch event.
document.addEventListener('mousedown', () => {
let documentMousedownListener = () => {
if (!this._lastTouchTarget) {
this._setOriginForCurrentEventQueue('mouse');
}
}, true);
};

// When the touchstart event fires the focus event is not yet in the event queue. This means
// we can't rely on the trick used above (setting timeout of 0ms). Instead we wait 650ms to
// see if a focus happens.
document.addEventListener('touchstart', (event: TouchEvent) => {
let documentTouchstartListener = (event: TouchEvent) => {
if (this._touchTimeout != null) {
clearTimeout(this._touchTimeout);
}
this._lastTouchTarget = event.target;
this._touchTimeout = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);

// Note that we need to cast the event options to `any`, because at the time of writing
// (TypeScript 2.5), the built-in types don't support the `addEventListener` options param.
}, supportsPassiveEventListeners() ? ({passive: true, capture: true} as any) : true);
};

// Make a note of when the window regains focus, so we can restore the origin info for the
// focused element.
window.addEventListener('focus', () => {
let windowFocusListener = () => {
this._windowFocused = true;
setTimeout(() => this._windowFocused = false, 0);
};

// Note: we listen to events in the capture phase so we can detect them even if the user stops
// propagation.
this._ngZone.runOutsideAngular(() => {
document.addEventListener('keydown', documentKeydownListener, true);
document.addEventListener('mousedown', documentMousedownListener, true);
document.addEventListener('touchstart', documentTouchstartListener,
supportsPassiveEventListeners() ? ({passive: true, capture: true} as any) : true);
window.addEventListener('focus', windowFocusListener);
});

this._unregisterGlobalListeners = () => {
document.removeEventListener('keydown', documentKeydownListener, true);
document.removeEventListener('mousedown', documentMousedownListener, true);
document.removeEventListener('touchstart', documentTouchstartListener,
supportsPassiveEventListeners() ? ({passive: true, capture: true} as any) : true);
window.removeEventListener('focus', windowFocusListener);
};
}

private _toggleClass(element: Element, className: string, shouldSet: boolean) {
if (shouldSet) {
element.classList.add(className);
} else {
element.classList.remove(className);
}
}

/**
Expand All @@ -196,16 +232,11 @@ export class FocusMonitor {
const elementInfo = this._elementInfo.get(element);

if (elementInfo) {
const toggleClass = (className: string, shouldSet: boolean) => {
shouldSet ? elementInfo.renderer.addClass(element, className) :
elementInfo.renderer.removeClass(element, className);
};

toggleClass('cdk-focused', !!origin);
toggleClass('cdk-touch-focused', origin === 'touch');
toggleClass('cdk-keyboard-focused', origin === 'keyboard');
toggleClass('cdk-mouse-focused', origin === 'mouse');
toggleClass('cdk-program-focused', origin === 'program');
this._toggleClass(element, 'cdk-focused', !!origin);
this._toggleClass(element, 'cdk-touch-focused', origin === 'touch');
this._toggleClass(element, 'cdk-keyboard-focused', origin === 'keyboard');
this._toggleClass(element, 'cdk-mouse-focused', origin === 'mouse');
this._toggleClass(element, 'cdk-program-focused', origin === 'program');
}
}

Expand Down Expand Up @@ -235,7 +266,7 @@ export class FocusMonitor {
// result, this code will still consider it to have been caused by the touch event and will
// apply the cdk-touch-focused class rather than the cdk-program-focused class. This is a
// relatively small edge-case that can be worked around by using
// focusVia(parentEl, renderer, 'program') to focus the parent element.
// focusVia(parentEl, 'program') to focus the parent element.
//
// If we decide that we absolutely must handle this case correctly, we can do so by listening
// for the first focus event after the touchstart, and then the first blur event after that
Expand Down Expand Up @@ -304,6 +335,22 @@ export class FocusMonitor {
this._setClasses(element);
elementInfo.subject.next(null);
}

private _incrementMonitoredElementCount() {
// Register global listeners when first element is monitored.
if (++this._monitoredElementCount == 1) {
this._registerGlobalListeners();
}
}

private _decrementMonitoredElementCount() {
// Unregister global listeners when last element is unmonitored.
if (!--this._monitoredElementCount) {
this._unregisterGlobalListeners();
this._unregisterGlobalListeners = () => {};
}
}

}


Expand All @@ -323,10 +370,9 @@ export class CdkMonitorFocus implements OnDestroy {
private _monitorSubscription: Subscription;
@Output() cdkFocusChange = new EventEmitter<FocusOrigin>();

constructor(private _elementRef: ElementRef, private _focusMonitor: FocusMonitor,
renderer: Renderer2) {
constructor(private _elementRef: ElementRef, private _focusMonitor: FocusMonitor) {
this._monitorSubscription = this._focusMonitor.monitor(
this._elementRef.nativeElement, renderer,
this._elementRef.nativeElement,
this._elementRef.nativeElement.hasAttribute('cdkMonitorSubtreeFocus'))
.subscribe(origin => this.cdkFocusChange.emit(origin));
}
Expand Down
20 changes: 9 additions & 11 deletions src/lib/button-toggle/button-toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,29 @@
* found in the LICENSE file at https://angular.io/license
*/

import {FocusMonitor} from '@angular/cdk/a11y';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {UniqueSelectionDispatcher} from '@angular/cdk/collections';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChildren,
Directive,
ElementRef,
Renderer2,
EventEmitter,
forwardRef,
Input,
OnInit,
OnDestroy,
OnInit,
Optional,
Output,
QueryList,
ViewChild,
ViewEncapsulation,
forwardRef,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from '@angular/core';
import {NG_VALUE_ACCESSOR, ControlValueAccessor} from '@angular/forms';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {CanDisable, mixinDisabled} from '@angular/material/core';
import {FocusMonitor} from '@angular/cdk/a11y';
import {UniqueSelectionDispatcher} from '@angular/cdk/collections';

/** Acceptable types for a button toggle. */
export type ToggleType = 'checkbox' | 'radio';
Expand Down Expand Up @@ -386,7 +385,6 @@ export class MatButtonToggle implements OnInit, OnDestroy {
@Optional() toggleGroupMultiple: MatButtonToggleGroupMultiple,
private _changeDetectorRef: ChangeDetectorRef,
private _buttonToggleDispatcher: UniqueSelectionDispatcher,
private _renderer: Renderer2,
private _elementRef: ElementRef,
private _focusMonitor: FocusMonitor) {

Expand Down Expand Up @@ -421,7 +419,7 @@ export class MatButtonToggle implements OnInit, OnDestroy {
if (this.buttonToggleGroup && this._value == this.buttonToggleGroup.value) {
this._checked = true;
}
this._focusMonitor.monitor(this._elementRef.nativeElement, this._renderer, true);
this._focusMonitor.monitor(this._elementRef.nativeElement, true);
}

/** Focuses the button. */
Expand Down
6 changes: 3 additions & 3 deletions src/lib/button/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/

import {FocusMonitor} from '@angular/cdk/a11y';
import {Platform} from '@angular/cdk/platform';
import {
ChangeDetectionStrategy,
Component,
Expand All @@ -19,7 +21,6 @@ import {
Self,
ViewEncapsulation,
} from '@angular/core';
import {Platform} from '@angular/cdk/platform';
import {
CanColor,
CanDisable,
Expand All @@ -28,7 +29,6 @@ import {
mixinDisabled,
mixinDisableRipple
} from '@angular/material/core';
import {FocusMonitor} from '@angular/cdk/a11y';


// TODO(kara): Convert attribute selectors to classes when attr maps become available
Expand Down Expand Up @@ -141,7 +141,7 @@ export class MatButton extends _MatButtonMixinBase
private _platform: Platform,
private _focusMonitor: FocusMonitor) {
super(renderer, elementRef);
this._focusMonitor.monitor(this._elementRef.nativeElement, this._renderer, true);
this._focusMonitor.monitor(this._elementRef.nativeElement, true);
}

ngOnDestroy() {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/checkbox/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {
AfterViewInit,
Expand Down Expand Up @@ -36,7 +37,6 @@ import {
mixinTabIndex,
RippleRef,
} from '@angular/material/core';
import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y';


// Increasing integer for generating unique ids for checkbox components.
Expand Down Expand Up @@ -209,7 +209,7 @@ export class MatCheckbox extends _MatCheckboxMixinBase implements ControlValueAc

ngAfterViewInit() {
this._focusMonitor
.monitor(this._inputElement.nativeElement, this._renderer, false)
.monitor(this._inputElement.nativeElement, false)
.subscribe(focusOrigin => this._onInputFocusChange(focusOrigin));
}

Expand Down
4 changes: 1 addition & 3 deletions src/lib/expansion/expansion-panel-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
Host,
Input,
OnDestroy,
Renderer2,
ViewEncapsulation,
} from '@angular/core';
import {merge} from 'rxjs/observable/merge';
Expand Down Expand Up @@ -85,7 +84,6 @@ export class MatExpansionPanelHeader implements OnDestroy {
private _parentChangeSubscription = Subscription.EMPTY;

constructor(
renderer: Renderer2,
@Host() public panel: MatExpansionPanel,
private _element: ElementRef,
private _focusMonitor: FocusMonitor,
Expand All @@ -100,7 +98,7 @@ export class MatExpansionPanelHeader implements OnDestroy {
)
.subscribe(() => this._changeDetectorRef.markForCheck());

_focusMonitor.monitor(_element.nativeElement, renderer, false);
_focusMonitor.monitor(_element.nativeElement, false);
}

/** Height of the header while the panel is expanded. */
Expand Down
Loading

0 comments on commit 8dc8dc4

Please sign in to comment.