diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 47b54dbb1036..541909dbf171 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -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, diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 98c029d770cc..cdbdcd904484 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -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) { diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index 3a1b3ba916b6..c1d92d0fab43 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -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}, ]; diff --git a/src/demo-app/style/style-demo.html b/src/demo-app/style/style-demo.html new file mode 100644 index 000000000000..a3aee1f7bb3f --- /dev/null +++ b/src/demo-app/style/style-demo.html @@ -0,0 +1,8 @@ + + + + + + + +
Active classes: {{b.classList}}
diff --git a/src/demo-app/style/style-demo.scss b/src/demo-app/style/style-demo.scss new file mode 100644 index 000000000000..a04570ce0dee --- /dev/null +++ b/src/demo-app/style/style-demo.scss @@ -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; +} diff --git a/src/demo-app/style/style-demo.ts b/src/demo-app/style/style-demo.ts new file mode 100644 index 000000000000..e1151da131f8 --- /dev/null +++ b/src/demo-app/style/style-demo.ts @@ -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) {} +} diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index b0c08443f1b0..38aba4c9665f 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -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'; diff --git a/src/lib/core/style/focus-classes.spec.ts b/src/lib/core/style/focus-classes.spec.ts new file mode 100644 index 000000000000..e3e86b2004c3 --- /dev/null +++ b/src/lib/core/style/focus-classes.spec.ts @@ -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; + 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; + 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: ``}) +class PlainButton { + constructor(public renderer: Renderer) {} +} + + +@Component({template: ``}) +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); +} diff --git a/src/lib/core/style/focus-classes.ts b/src/lib/core/style/focus-classes.ts new file mode 100644 index 000000000000..efea43939010 --- /dev/null +++ b/src/lib/core/style/focus-classes.ts @@ -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 +}; diff --git a/src/lib/core/style/index.ts b/src/lib/core/style/index.ts new file mode 100644 index 000000000000..13c58e4ea291 --- /dev/null +++ b/src/lib/core/style/index.ts @@ -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 {} diff --git a/src/lib/module.ts b/src/lib/module.ts index 2c395929e1a5..490e419cb133 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -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,