mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(style): add directive to determine how elements were focused. (#2646)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user