-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
898d0d1
focus origin monitor improvements
mmalerba 3d654c1
addressed comment + tests
mmalerba 2688b41
addressed comments and replaced Observable w/ EventEmitter
mmalerba 5bfbb0b
simplify fat arrow
mmalerba 3a22e12
rename the files now that the directive name has changed
mmalerba File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; | ||
|
||
|
@@ -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 { | ||
|
@@ -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); | ||
}); | ||
}); | ||
} | ||
|
||
/** 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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about |
||
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: | ||
// | ||
|
@@ -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'. | ||
|
@@ -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 | ||
}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
This way we would get rid of unnecessary indentation and also have it pretty clean.