Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(FocusOriginMonitor): support monitoring subtree focus as well as element #3113

Merged
merged 5 commits into from
Feb 16, 2017
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/demo-app/style/style-demo.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<button #b class="demo-button" cdkFocusClasses>focus me!</button>
<button #b class="demo-focusable" cdkMonitorElementFocus>focus me!</button>
<button (click)="b.focus()">focus programmatically</button>

<button (click)="fom.focusVia(b, renderer, 'mouse')">focusVia: mouse</button>
Expand All @@ -7,3 +7,15 @@
<button (click)="fom.focusVia(b, renderer, 'program')">focusVia: program</button>

<div>Active classes: {{b.classList}}</div>

<br>

<div class="demo-focusable" tabindex="0" cdkMonitorElementFocus>
<p>div with element focus monitored</p>
<button>1</button><button>2</button>
</div>

<div class="demo-focusable" tabindex="0" cdkMonitorSubtreeFocus>
<p>div with subtree focus monitored</p>
<button>1</button><button>2</button>
</div>
10 changes: 5 additions & 5 deletions src/demo-app/style/style-demo.scss
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
.demo-button.cdk-focused {
.demo-focusable.cdk-focused {
border: 2px solid red;
}

.demo-button.cdk-mouse-focused {
.demo-focusable.cdk-mouse-focused {
background: green;
}

.demo-button.cdk-keyboard-focused {
.demo-focusable.cdk-keyboard-focused {
background: yellow;
}

.demo-button.cdk-program-focused {
.demo-focusable.cdk-program-focused {
background: blue;
}

.demo-button.cdk-touch-focused {
.demo-focusable.cdk-touch-focused {
background: purple;
}
2 changes: 1 addition & 1 deletion src/lib/core/style/focus-classes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('FocusOriginMonitor', () => {
focusOriginMonitor = fom;

changeHandler = jasmine.createSpy('focus origin change handler');
focusOriginMonitor.registerElementForFocusClasses(buttonElement, buttonRenderer)
focusOriginMonitor.monitor(buttonElement, buttonRenderer)
.subscribe(changeHandler);

// Patch the element focus to properly emit focus events when the browser is blurred.
Expand Down
268 changes: 192 additions & 76 deletions src/lib/core/style/focus-classes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {Directive, Injectable, Optional, SkipSelf, Renderer, ElementRef} from '@angular/core';
import {
Directive, Injectable, Optional, SkipSelf, Renderer, ElementRef,
OnDestroy, NgZone
} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';

Expand All @@ -11,6 +14,14 @@ export const TOUCH_BUFFER_MS = 650;
export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program';


type MonitoredElementInfo = {
unlisten: Function,
includesChildren: boolean,
renderer: Renderer,
subject: Subject<FocusOrigin>
};


/** Monitors mouse and keyboard events to determine the cause of focus events. */
@Injectable()
export class FocusOriginMonitor {
Expand All @@ -29,66 +40,143 @@ export class FocusOriginMonitor {
/** The timeout id of the touch timeout, used to cancel timeout later. */
private _touchTimeout: number;

constructor() {
// 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', () => {
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', () => {
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: Event) => {
if (this._touchTimeout != null) {
clearTimeout(this._touchTimeout);
}
this._lastTouchTarget = event.target;
this._touchTimeout = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
}, true);

// Make a note of when the window regains focus, so we can restore the origin info for the
// focused element.
window.addEventListener('focus', () => {
this._windowFocused = true;
setTimeout(() => this._windowFocused = false, 0);
/** Weak map of elements being monitored to their info. */
private _elementInfo = new WeakMap<Element, MonitoredElementInfo>();

constructor(private _ngZone: NgZone) {
this._ngZone.runOutsideAngular(() => {
// 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', () => {
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', () => {
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: Event) => {
if (this._touchTimeout != null) {
clearTimeout(this._touchTimeout);
}
this._lastTouchTarget = event.target;
this._touchTimeout = setTimeout(() => this._lastTouchTarget = null, TOUCH_BUFFER_MS);
}, true);

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an optional nit. How about extracting this into its own function?

constructor(ngZone: NgZone) {
  ngZone.runOutsideAngular(() => this._registerDocumentEvents());
}

This way we would get rid of unnecessary indentation and also have it pretty clean.

});
});
}

/** Register an element to receive focus classes. */
registerElementForFocusClasses(element: Element, renderer: Renderer): Observable<FocusOrigin> {
let subject = new Subject<FocusOrigin>();
renderer.listen(element, 'focus',
(event: Event) => this._onFocus(event, element, renderer, subject));
renderer.listen(element, 'blur', () => this._onBlur(element, renderer, subject));
return subject.asObservable();
/**
* 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 includeChildren 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: Element, renderer: Renderer, includeChildren: boolean): Observable<FocusOrigin> {
// Check if we're already monitoring this element.
if (this._elementInfo.has(element)) {
let info = this._elementInfo.get(element);
info.includesChildren = includeChildren;
return info.subject.asObservable();
}

// Create monitored element info.
let info: MonitoredElementInfo = {
unlisten: null,
includesChildren: includeChildren,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about checkChildren?

renderer: renderer,
subject: new Subject<FocusOrigin>()
};
this._elementInfo.set(element, info);

// Start listening. We need to listen in capture phase since focus events don't bubble.
let focusListener = (event: FocusEvent) => this._onFocus(event, element);
let blurListener = (event: FocusEvent) => this._onBlur(event, element);
this._ngZone.runOutsideAngular(() => {
element.addEventListener('focus', focusListener, true);
element.addEventListener('blur', blurListener, true);
});

// Create an unlisten function for later.
info.unlisten = () => {
element.removeEventListener('focus', focusListener, true);
element.removeEventListener('blur', blurListener, true);
};

return info.subject.asObservable();
}

/**
* Stops monitoring an element and removes all focus classes.
* @param element The element to stop monitoring.
*/
unmonitor(element: Element): void {
if (!this._elementInfo.has(element)) {
return;
}
this._elementInfo.get(element).unlisten();
this._setClasses(element, null);
this._elementInfo.delete(element);
}

/** Focuses the element via the specified focus origin. */
focusVia(element: Node, renderer: Renderer, origin: FocusOrigin) {
/**
* Focuses the element via the specified focus origin.
* @param element The element to focus.
* @param renderer The renderer to use to invoke the focus method on the element.
* @param origin The focus origin.
*/
focusVia(element: Node, renderer: Renderer, origin: FocusOrigin): void {
this._setOriginForCurrentEventQueue(origin);
renderer.invokeElementMethod(element, 'focus');
}

/** Sets the origin and schedules an async function to clear it at the end of the event queue. */
private _setOriginForCurrentEventQueue(origin: FocusOrigin) {
/**
* Sets the focus classes on the element based on the given focus origin.
* @param element The element to update the classes on.
* @param origin The focus origin.
*/
private _setClasses(element: Element, origin: FocusOrigin): void {
let renderer = this._elementInfo.get(element).renderer;
renderer.setElementClass(element, 'cdk-focused', !!origin);
renderer.setElementClass(element, 'cdk-touch-focused', origin == 'touch');
renderer.setElementClass(element, 'cdk-keyboard-focused', origin == 'keyboard');
renderer.setElementClass(element, 'cdk-mouse-focused', origin == 'mouse');
renderer.setElementClass(element, 'cdk-program-focused', origin == 'program');
}

/**
* Sets the origin and schedules an async function to clear it at the end of the event queue.
* @param origin The origin to set.
*/
private _setOriginForCurrentEventQueue(origin: FocusOrigin): void {
this._origin = origin;
setTimeout(() => this._origin = null, 0);
}

/** Checks whether the given focus event was caused by a touchstart event. */
private _wasCausedByTouch(event: Event): boolean {
/**
* Checks whether the given focus event was caused by a touchstart event.
* @param event The focus event to check.
* @returns Whether the event was caused by a touch.
*/
private _wasCausedByTouch(event: FocusEvent): boolean {
// Note(mmalerba): This implementation is not quite perfect, there is a small edge case.
// Consider the following dom structure:
//
Expand All @@ -111,10 +199,24 @@ export class FocusOriginMonitor {
(focusTarget == this._lastTouchTarget || focusTarget.contains(this._lastTouchTarget));
}

/** Handles focus events on a registered element. */
private _onFocus(event: Event, element: Element, renderer: Renderer,
subject: Subject<FocusOrigin>) {
// If we couldn't detect a cause for the focus event, it's due to one of two reasons:
/**
* Handles focus events on a registered element.
* @param event The focus event.
* @param element The monitored element.
*/
private _onFocus(event: FocusEvent, element: Element) {
// NOTE(mmalerba): We currently set the classes based on the focus origin of the most recent
// focus event affecting the monitored element. If we want to use the origin of the first event
// instead we should check for the cdk-focused class here and return if the element already has
// it. (This only matters for elements that have includesChildren = true).

// If we are not counting child-element-focus as focused, make sure that the event target is the
// monitored element itself.
if (!this._elementInfo.get(element).includesChildren && element != event.target) {
return;
}

// If we couldn't detect a cause for the focus event, it's due to one of three reasons:
// 1) The window has just regained focus, in which case we want to restore the focused state of
// the element from before the window blurred.
// 2) It was caused by a touch event, in which case we mark the origin as 'touch'.
Expand All @@ -130,54 +232,68 @@ export class FocusOriginMonitor {
}
}

renderer.setElementClass(element, 'cdk-focused', true);
renderer.setElementClass(element, 'cdk-touch-focused', this._origin == 'touch');
renderer.setElementClass(element, 'cdk-keyboard-focused', this._origin == 'keyboard');
renderer.setElementClass(element, 'cdk-mouse-focused', this._origin == 'mouse');
renderer.setElementClass(element, 'cdk-program-focused', this._origin == 'program');
subject.next(this._origin);

this._setClasses(element, this._origin);
this._elementInfo.get(element).subject.next(this._origin);
this._lastFocusOrigin = this._origin;
this._origin = null;
}

/** Handles blur events on a registered element. */
private _onBlur(element: Element, renderer: Renderer, subject: Subject<FocusOrigin>) {
renderer.setElementClass(element, 'cdk-focused', false);
renderer.setElementClass(element, 'cdk-touch-focused', false);
renderer.setElementClass(element, 'cdk-keyboard-focused', false);
renderer.setElementClass(element, 'cdk-mouse-focused', false);
renderer.setElementClass(element, 'cdk-program-focused', false);
subject.next(null);
/**
* Handles blur events on a registered element.
* @param event The blur event.
* @param element The monitored element.
*/
private _onBlur(event: FocusEvent, element: Element) {
// If we are counting child-element-focus as focused, make sure that we aren't just blurring in
// order to focus another child of the monitored element.
if (this._elementInfo.get(element).includesChildren && event.relatedTarget instanceof Node &&
element.contains(event.relatedTarget)) {
return;
}

this._setClasses(element, null);
this._elementInfo.get(element).subject.next(null);
}
}


/**
* Directive that determines how a particular element was focused (via keyboard, mouse, or
* Directive that determines how a particular element was focused (via keyboard, mouse, touch, or
* programmatically) and adds corresponding classes to the element.
*
* There are two variants of this directive:
* 1) cdkMonitorElementFocus: does not consider an element to be focused if one of its children is
* focused.
* 2) cdkMonitorSubtreeFocus: considers an element focused if it or any of its children are focused.
*/
@Directive({
selector: '[cdkFocusClasses]',
selector: '[cdkMonitorElementFocus], [cdkMonitorSubtreeFocus]',
})
export class CdkFocusClasses {
export class CdkMonitorFocus implements OnDestroy {
changes: Observable<FocusOrigin>;

constructor(elementRef: ElementRef, focusOriginMonitor: FocusOriginMonitor, renderer: Renderer) {
this.changes =
focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer);
constructor(private _elementRef: ElementRef, private _focusOriginMonitor: FocusOriginMonitor,
renderer: Renderer) {
this.changes = this._focusOriginMonitor.monitor(
this._elementRef.nativeElement, renderer,
this._elementRef.nativeElement.hasAttribute('cdkMonitorSubtreeFocus'));
}

ngOnDestroy() {
this._focusOriginMonitor.unmonitor(this._elementRef.nativeElement);
}
}


export function FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY(parentDispatcher: FocusOriginMonitor) {
return parentDispatcher || new FocusOriginMonitor();
export function FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY(parentDispatcher: FocusOriginMonitor,
ngZone: NgZone) {
return parentDispatcher || new FocusOriginMonitor(ngZone);
}


export const FOCUS_ORIGIN_MONITOR_PROVIDER = {
// If there is already a FocusOriginMonitor available, use that. Otherwise, provide a new one.
provide: FocusOriginMonitor,
deps: [[new Optional(), new SkipSelf(), FocusOriginMonitor]],
deps: [[new Optional(), new SkipSelf(), FocusOriginMonitor], NgZone],
useFactory: FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY
};
Loading