feat(focus-trap): allow setting initially focused element (#4577)

This commit is contained in:
mmalerba
2017-05-18 17:06:46 -07:00
committed by Jeremy Elbourn
parent 0b5b6247f6
commit c946631899
9 changed files with 98 additions and 34 deletions
+24
View File
@@ -61,3 +61,27 @@
<button (click)="invert = !invert">Change sides</button>
</div>
</md-sidenav-container>
<h2>Sidenav with focus attributes</h2>
<md-sidenav-container class="demo-sidenav-container">
<md-sidenav #focusSidenav>
<md-nav-list>
<a md-list-item routerLink>Link</a>
<a md-list-item routerLink cdk-focus-region-start>Focus region start</a>
<a md-list-item routerLink>Link</a>
<a md-list-item routerLink cdk-focus-initial>Initially focused</a>
<a md-list-item routerLink cdk-focus-region-end>Focus region end</a>
<a md-list-item routerLink>Link</a>
</md-nav-list>
</md-sidenav>
<div class="demo-sidenav-content">
<h1>My Content</h1>
<div>
<header>Sidenav</header>
<button md-button (click)="focusSidenav.toggle()">Toggle Drawer</button>
</div>
</div>
</md-sidenav-container>
+13 -4
View File
@@ -14,7 +14,7 @@ describe('FocusTrap', () => {
FocusTrapWithBindings,
SimpleFocusTrap,
FocusTrapTargets,
FocusTrapWithSvg
FocusTrapWithSvg,
],
providers: [InteractivityChecker, Platform, FocusTrapFactory]
});
@@ -104,6 +104,13 @@ describe('FocusTrap', () => {
focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap;
});
it('should be able to set initial focus target', () => {
// Because we can't mimic a real tab press focus change in a unit test, just call the
// focus event handler directly.
focusTrapInstance.focusInitialElement();
expect(document.activeElement.id).toBe('middle');
});
it('should be able to prioritize the first focus target', () => {
// Because we can't mimic a real tab press focus change in a unit test, just call the
// focus event handler directly.
@@ -131,7 +138,6 @@ describe('FocusTrap', () => {
expect(() => focusTrapInstance.focusLastTabbableElement()).not.toThrow();
});
});
});
@@ -167,8 +173,11 @@ class FocusTrapWithBindings {
template: `
<div cdkTrapFocus>
<input>
<button id="last" cdk-focus-end></button>
<button id="first" cdk-focus-start>SAVE</button>
<button>before</button>
<button id="first" cdk-focus-region-start></button>
<button id="middle" cdk-focus-initial></button>
<button id="last" cdk-focus-region-end></button>
<button>after</button>
<input>
</div>
`
+42 -12
View File
@@ -81,6 +81,10 @@ export class FocusTrap {
});
}
focusInitialElementWhenReady() {
this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusInitialElement());
}
/**
* Waits for microtask queue to empty, then focuses
* the first tabbable element within the focus trap region.
@@ -97,11 +101,45 @@ export class FocusTrap {
this._ngZone.onMicrotaskEmpty.first().subscribe(() => this.focusLastTabbableElement());
}
/**
* Get the specified boundary element of the trapped region.
* @param bound The boundary to get (start or end of trapped region).
* @returns The boundary element.
*/
private _getRegionBoundary(bound: 'start' | 'end'): HTMLElement | null {
let markers = [
...Array.prototype.slice.call(this._element.querySelectorAll(`[cdk-focus-region-${bound}]`)),
// Deprecated version of selector, for temporary backwards comparability:
...Array.prototype.slice.call(this._element.querySelectorAll(`[cdk-focus-${bound}]`)),
];
markers.forEach((el: HTMLElement) => {
if (el.hasAttribute(`cdk-focus-${bound}`)) {
console.warn(`Found use of deprecated attribute 'cdk-focus-${bound}',` +
` use 'cdk-focus-region-${bound}' instead.`, el);
}
});
if (bound == 'start') {
return markers.length ? markers[0] : this._getFirstTabbableElement(this._element);
}
return markers.length ?
markers[markers.length - 1] : this._getLastTabbableElement(this._element);
}
/** Focuses the element that should be focused when the focus trap is initialized. */
focusInitialElement() {
let redirectToElement = this._element.querySelector('[cdk-focus-initial]') as HTMLElement;
if (redirectToElement) {
redirectToElement.focus();
} else {
this.focusFirstTabbableElement();
}
}
/** Focuses the first tabbable element within the focus trap region. */
focusFirstTabbableElement() {
let redirectToElement = this._element.querySelector('[cdk-focus-start]') as HTMLElement ||
this._getFirstTabbableElement(this._element);
let redirectToElement = this._getRegionBoundary('start');
if (redirectToElement) {
redirectToElement.focus();
}
@@ -109,15 +147,7 @@ export class FocusTrap {
/** Focuses the last tabbable element within the focus trap region. */
focusLastTabbableElement() {
let focusTargets = this._element.querySelectorAll('[cdk-focus-end]');
let redirectToElement: HTMLElement = null;
if (focusTargets.length) {
redirectToElement = focusTargets[focusTargets.length - 1] as HTMLElement;
} else {
redirectToElement = this._getLastTabbableElement(this._element);
}
let redirectToElement = this._getRegionBoundary('end');
if (redirectToElement) {
redirectToElement.focus();
}
+1 -1
View File
@@ -125,7 +125,7 @@ export class MdDialogContainer extends BasePortalHost {
// If were to attempt to focus immediately, then the content of the dialog would not yet be
// ready in instances where change detection has to run first. To deal with this, we simply
// wait for the microtask queue to be empty.
this._focusTrap.focusFirstTabbableElementWhenReady();
this._focusTrap.focusInitialElementWhenReady();
}
/** Restores focus to the element that was focused before the dialog opened. */
+3 -1
View File
@@ -20,7 +20,9 @@
border-top-color: mat-color($foreground, divider);
}
.mat-nav-list .mat-list-item-content {
.mat-nav-list .mat-list-item {
outline: none;
&:hover, &.mat-list-item-focus {
background: mat-color($background, 'hover');
}
+1 -1
View File
@@ -1,4 +1,4 @@
<div class="mat-list-item-content" [class.mat-list-item-focus]="_hasFocus">
<div class="mat-list-item-content">
<div class="mat-list-item-ripple" md-ripple
[mdRippleTrigger]="_getHostElement()"
[mdRippleDisabled]="!isRippleEnabled()">
+6 -5
View File
@@ -28,18 +28,19 @@ describe('MdList', () => {
it('should add and remove focus class on focus/blur', () => {
let fixture = TestBed.createComponent(ListWithOneAnchorItem);
let listItem = fixture.debugElement.query(By.directive(MdListItem));
let listItemDiv = fixture.debugElement.query(By.css('.mat-list-item-content'));
fixture.detectChanges();
expect(listItemDiv.nativeElement.classList).not.toContain('mat-list-item-focus');
let listItem = fixture.debugElement.query(By.directive(MdListItem));
let listItemEl = fixture.debugElement.query(By.css('.mat-list-item'));
expect(listItemEl.nativeElement.classList).not.toContain('mat-list-item-focus');
listItem.componentInstance._handleFocus();
fixture.detectChanges();
expect(listItemDiv.nativeElement.classList).toContain('mat-list-item-focus');
expect(listItemEl.nativeElement.classList).toContain('mat-list-item-focus');
listItem.componentInstance._handleBlur();
fixture.detectChanges();
expect(listItemDiv.nativeElement.classList).not.toContain('mat-list-item-focus');
expect(listItemEl.nativeElement.classList).not.toContain('mat-list-item-focus');
});
it('should not apply any additional class to a list without lines', () => {
+7 -9
View File
@@ -1,17 +1,17 @@
import {
AfterContentInit,
Component,
ViewEncapsulation,
ContentChildren,
ContentChild,
QueryList,
ContentChildren,
Directive,
ElementRef,
Input,
Optional,
QueryList,
Renderer2,
AfterContentInit,
ViewEncapsulation
} from '@angular/core';
import {MdLine, MdLineSetter, coerceBooleanProperty} from '../core';
import {coerceBooleanProperty, MdLine, MdLineSetter} from '../core';
@Directive({
selector: 'md-divider, mat-divider'
@@ -128,8 +128,6 @@ export class MdListItem implements AfterContentInit {
private _disableRipple: boolean = false;
private _isNavList: boolean = false;
_hasFocus: boolean = false;
/**
* Whether the ripple effect on click should be disabled. This applies only to list items that are
* part of a nav list. The value of `disableRipple` on the `md-nav-list` overrides this flag.
@@ -166,11 +164,11 @@ export class MdListItem implements AfterContentInit {
}
_handleFocus() {
this._hasFocus = true;
this._renderer.addClass(this._element.nativeElement, 'mat-list-item-focus');
}
_handleBlur() {
this._hasFocus = false;
this._renderer.removeClass(this._element.nativeElement, 'mat-list-item-focus');
}
/** Retrieves the DOM element of the component host. */
+1 -1
View File
@@ -131,7 +131,7 @@ export class MdSidenav implements AfterContentInit, OnDestroy {
this._elementFocusedBeforeSidenavWasOpened = document.activeElement as HTMLElement;
if (this.isFocusTrapEnabled && this._focusTrap) {
this._focusTrap.focusFirstTabbableElementWhenReady();
this._focusTrap.focusInitialElementWhenReady();
}
});