feat(style): add directive to determine how elements were focused. (#2646)

This commit is contained in:
mmalerba
2017-02-03 16:11:37 -08:00
committed by Kara
parent 2f0dad1625
commit 8a6d9022e0
11 changed files with 438 additions and 5 deletions
+3 -2
View File
@@ -4,8 +4,7 @@ import {HttpModule} from '@angular/http';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DemoApp, Home} from './demo-app/demo-app';
import {RouterModule} from '@angular/router';
import {MaterialModule, OverlayContainer,
FullscreenOverlayContainer} from '@angular/material';
import {MaterialModule, OverlayContainer, FullscreenOverlayContainer} from '@angular/material';
import {DEMO_APP_ROUTES} from './demo-app/routes';
import {ProgressBarDemo} from './progress-bar/progress-bar-demo';
import {JazzDialog, ContentElementDialog, DialogDemo, IFrameDialog} from './dialog/dialog-demo';
@@ -38,6 +37,7 @@ import {ProjectionDemo, ProjectionTestComponent} from './projection/projection-d
import {PlatformDemo} from './platform/platform-demo';
import {AutocompleteDemo} from './autocomplete/autocomplete-demo';
import {InputContainerDemo} from './input/input-container-demo';
import {StyleDemo} from './style/style-demo';
@NgModule({
imports: [
@@ -86,6 +86,7 @@ import {InputContainerDemo} from './input/input-container-demo';
SliderDemo,
SlideToggleDemo,
SpagettiPanel,
StyleDemo,
ToolbarDemo,
TooltipDemo,
TabsDemo,
+2 -1
View File
@@ -50,7 +50,8 @@ export class DemoApp {
{name: 'Tabs', route: 'tabs'},
{name: 'Toolbar', route: 'toolbar'},
{name: 'Tooltip', route: 'tooltip'},
{name: 'Platform', route: 'platform'}
{name: 'Platform', route: 'platform'},
{name: 'Style', route: 'style'}
];
constructor(private _element: ElementRef) {
+3 -1
View File
@@ -32,6 +32,7 @@ import {TABS_DEMO_ROUTES} from '../tabs/routes';
import {PlatformDemo} from '../platform/platform-demo';
import {AutocompleteDemo} from '../autocomplete/autocomplete-demo';
import {InputContainerDemo} from '../input/input-container-demo';
import {StyleDemo} from '../style/style-demo';
export const DEMO_APP_ROUTES: Routes = [
{path: '', component: Home},
@@ -65,5 +66,6 @@ export const DEMO_APP_ROUTES: Routes = [
{path: 'dialog', component: DialogDemo},
{path: 'tooltip', component: TooltipDemo},
{path: 'snack-bar', component: SnackBarDemo},
{path: 'platform', component: PlatformDemo}
{path: 'platform', component: PlatformDemo},
{path: 'style', component: StyleDemo},
];
+8
View File
@@ -0,0 +1,8 @@
<button #b class="demo-button" cdkFocusClasses>focus me!</button>
<button (click)="b.focus()">focus programmatically</button>
<button (click)="fom.focusVia(b, renderer, 'mouse')">focusVia: mouse</button>
<button (click)="fom.focusVia(b, renderer, 'keyboard')">focusVia: keyboard</button>
<button (click)="fom.focusVia(b, renderer, 'program')">focusVia: program</button>
<div>Active classes: {{b.classList}}</div>
+15
View File
@@ -0,0 +1,15 @@
.demo-button.cdk-focused {
border: 2px solid red;
}
.demo-button.cdk-mouse-focused {
background: green;
}
.demo-button.cdk-keyboard-focused {
background: yellow;
}
.demo-button.cdk-program-focused {
background: blue;
}
+13
View File
@@ -0,0 +1,13 @@
import {Component, Renderer} from '@angular/core';
import {FocusOriginMonitor} from '@angular/material';
@Component({
moduleId: module.id,
selector: 'style-demo',
templateUrl: 'style-demo.html',
styleUrls: ['style-demo.css'],
})
export class StyleDemo {
constructor(public renderer: Renderer, public fom: FocusOriginMonitor) {}
}
+1 -1
View File
@@ -99,7 +99,7 @@ export {
export {MdLineModule, MdLine, MdLineSetter} from './line/line';
// Style
export {applyCssTransform} from './style/apply-transform';
export * from './style/index';
// Error
export {MdError} from './errors/error';
+293
View File
@@ -0,0 +1,293 @@
import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing';
import {Component, Renderer} from '@angular/core';
import {StyleModule} from './index';
import {By} from '@angular/platform-browser';
import {TAB} from '../keyboard/keycodes';
import {FocusOriginMonitor} from './focus-classes';
import {PlatformModule} from '../platform/index';
import {Platform} from '../platform/platform';
// NOTE: Firefox only fires focus & blur events when it is the currently active window.
// This is not always the case on our CI setup, therefore we disable tests that depend on these
// events firing for Firefox. We may be able to fix this by configuring our CI to start Firefox with
// the following preference: focusmanager.testmode = true
describe('FocusOriginMonitor', () => {
let fixture: ComponentFixture<PlainButton>;
let buttonElement: HTMLElement;
let buttonRenderer: Renderer;
let focusOriginMonitor: FocusOriginMonitor;
let platform: Platform;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [StyleModule, PlatformModule],
declarations: [
PlainButton,
],
});
TestBed.compileComponents();
}));
beforeEach(inject([FocusOriginMonitor, Platform], (fom: FocusOriginMonitor, pfm: Platform) => {
fixture = TestBed.createComponent(PlainButton);
fixture.detectChanges();
buttonElement = fixture.debugElement.query(By.css('button')).nativeElement;
buttonRenderer = fixture.componentInstance.renderer;
focusOriginMonitor = fom;
platform = pfm;
focusOriginMonitor.registerElementForFocusClasses(buttonElement, buttonRenderer);
}));
it('manually registered element should receive focus classes', async(() => {
if (platform.FIREFOX) { return; }
buttonElement.focus();
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
}, 0);
}));
it('should detect focus via keyboard', async(() => {
if (platform.FIREFOX) { return; }
// Simulate focus via keyboard.
dispatchKeydownEvent(document, TAB);
buttonElement.focus();
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-keyboard-focused'))
.toBe(true, 'button should have cdk-keyboard-focused class');
}, 0);
}));
it('should detect focus via mouse', async(() => {
if (platform.FIREFOX) { return; }
// Simulate focus via mouse.
dispatchMousedownEvent(document);
buttonElement.focus();
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-mouse-focused'))
.toBe(true, 'button should have cdk-mouse-focused class');
}, 0);
}));
it('should detect programmatic focus', async(() => {
if (platform.FIREFOX) { return; }
// Programmatically focus.
buttonElement.focus();
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-program-focused'))
.toBe(true, 'button should have cdk-program-focused class');
}, 0);
}));
it('focusVia keyboard should simulate keyboard focus', async(() => {
if (platform.FIREFOX) { return; }
focusOriginMonitor.focusVia(buttonElement, buttonRenderer, 'keyboard');
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-keyboard-focused'))
.toBe(true, 'button should have cdk-keyboard-focused class');
}, 0);
}));
it('focusVia mouse should simulate mouse focus', async(() => {
if (platform.FIREFOX) { return; }
focusOriginMonitor.focusVia(buttonElement, buttonRenderer, 'mouse');
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-mouse-focused'))
.toBe(true, 'button should have cdk-mouse-focused class');
}, 0);
}));
it('focusVia program should simulate programmatic focus', async(() => {
if (platform.FIREFOX) { return; }
focusOriginMonitor.focusVia(buttonElement, buttonRenderer, 'program');
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-program-focused'))
.toBe(true, 'button should have cdk-program-focused class');
}, 0);
}));
});
describe('cdkFocusClasses', () => {
let fixture: ComponentFixture<ButtonWithFocusClasses>;
let buttonElement: HTMLElement;
let platform: Platform;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [StyleModule, PlatformModule],
declarations: [
ButtonWithFocusClasses,
],
});
TestBed.compileComponents();
}));
beforeEach(inject([Platform], (pfm: Platform) => {
fixture = TestBed.createComponent(ButtonWithFocusClasses);
fixture.detectChanges();
buttonElement = fixture.debugElement.query(By.css('button')).nativeElement;
platform = pfm;
}));
it('should initially not be focused', () => {
expect(buttonElement.classList.length).toBe(0, 'button should not have focus classes');
});
it('should detect focus via keyboard', async(() => {
if (platform.FIREFOX) { return; }
// Simulate focus via keyboard.
dispatchKeydownEvent(document, TAB);
buttonElement.focus();
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-keyboard-focused'))
.toBe(true, 'button should have cdk-keyboard-focused class');
}, 0);
}));
it('should detect focus via mouse', async(() => {
if (platform.FIREFOX) { return; }
// Simulate focus via mouse.
dispatchMousedownEvent(document);
buttonElement.focus();
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-mouse-focused'))
.toBe(true, 'button should have cdk-mouse-focused class');
}, 0);
}));
it('should detect programmatic focus', async(() => {
if (platform.FIREFOX) { return; }
// Programmatically focus.
buttonElement.focus();
fixture.detectChanges();
setTimeout(() => {
fixture.detectChanges();
expect(buttonElement.classList.length)
.toBe(2, 'button should have exactly 2 focus classes');
expect(buttonElement.classList.contains('cdk-focused'))
.toBe(true, 'button should have cdk-focused class');
expect(buttonElement.classList.contains('cdk-program-focused'))
.toBe(true, 'button should have cdk-program-focused class');
}, 0);
}));
});
@Component({template: `<button>focus me!</button>`})
class PlainButton {
constructor(public renderer: Renderer) {}
}
@Component({template: `<button cdkFocusClasses>focus me!</button>`})
class ButtonWithFocusClasses {}
/** Dispatches a mousedown event on the specified element. */
function dispatchMousedownEvent(element: Node) {
let event = document.createEvent('MouseEvent');
event.initMouseEvent(
'mousedown', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
element.dispatchEvent(event);
}
/** Dispatches a keydown event on the specified element. */
function dispatchKeydownEvent(element: Node, keyCode: number) {
let event: any = document.createEvent('KeyboardEvent');
(event.initKeyEvent || event.initKeyboardEvent).bind(event)(
'keydown', true, true, window, 0, 0, 0, 0, 0, keyCode);
Object.defineProperty(event, 'keyCode', {
get: function() { return keyCode; }
});
element.dispatchEvent(event);
}
+85
View File
@@ -0,0 +1,85 @@
import {Directive, Injectable, Optional, SkipSelf, Renderer, ElementRef} from '@angular/core';
export type FocusOrigin = 'mouse' | 'keyboard' | 'program';
/** Monitors mouse and keyboard events to determine the cause of focus events. */
@Injectable()
export class FocusOriginMonitor {
/** The focus origin that the next focus event is a result of. */
private _origin: FocusOrigin = null;
constructor() {
// Listen to keydown and mousedown in the capture phase so we can detect them even if the user
// stops propagation.
// TODO(mmalerba): Figure out how to handle touchstart
document.addEventListener(
'keydown', () => this._setOriginForCurrentEventQueue('keyboard'), true);
document.addEventListener(
'mousedown', () => this._setOriginForCurrentEventQueue('mouse'), true);
}
/** Register an element to receive focus classes. */
registerElementForFocusClasses(element: Element, renderer: Renderer) {
renderer.listen(element, 'focus', () => this._onFocus(element, renderer));
renderer.listen(element, 'blur', () => this._onBlur(element, renderer));
}
/** Focuses the element via the specified focus origin. */
focusVia(element: Node, renderer: Renderer, origin: FocusOrigin) {
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) {
this._origin = origin;
setTimeout(() => this._origin = null, 0);
}
/** Handles focus events on a registered element. */
private _onFocus(element: Element, renderer: Renderer) {
renderer.setElementClass(element, 'cdk-focused', true);
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 || this._origin == 'program');
this._origin = null;
}
/** Handles blur events on a registered element. */
private _onBlur(element: Element, renderer: Renderer) {
renderer.setElementClass(element, 'cdk-focused', false);
renderer.setElementClass(element, 'cdk-keyboard-focused', false);
renderer.setElementClass(element, 'cdk-mouse-focused', false);
renderer.setElementClass(element, 'cdk-program-focused', false);
}
}
/**
* Directive that determines how a particular element was focused (via keyboard, mouse, or
* programmatically) and adds corresponding classes to the element.
*/
@Directive({
selector: '[cdkFocusClasses]',
})
export class CdkFocusClasses {
constructor(elementRef: ElementRef, focusOriginMonitor: FocusOriginMonitor, renderer: Renderer) {
focusOriginMonitor.registerElementForFocusClasses(elementRef.nativeElement, renderer);
}
}
export function FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY(parentDispatcher: FocusOriginMonitor) {
return parentDispatcher || new FocusOriginMonitor();
}
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]],
useFactory: FOCUS_ORIGIN_MONITOR_PROVIDER_FACTORY
};
+13
View File
@@ -0,0 +1,13 @@
import {NgModule} from '@angular/core';
import {CdkFocusClasses, FOCUS_ORIGIN_MONITOR_PROVIDER} from './focus-classes';
export * from './focus-classes';
export * from './apply-transform';
@NgModule({
declarations: [CdkFocusClasses],
exports: [CdkFocusClasses],
providers: [FOCUS_ORIGIN_MONITOR_PROVIDER],
})
export class StyleModule {}
+2
View File
@@ -35,6 +35,7 @@ import {MdMenuModule} from './menu/index';
import {MdDialogModule} from './dialog/index';
import {PlatformModule} from './core/platform/index';
import {MdAutocompleteModule} from './autocomplete/index';
import {StyleModule} from './core/style/index';
const MATERIAL_MODULES = [
MdAutocompleteModule,
@@ -64,6 +65,7 @@ const MATERIAL_MODULES = [
OverlayModule,
PortalModule,
RtlModule,
StyleModule,
A11yModule,
PlatformModule,
ProjectionModule,