diff --git a/src/demo-app/sidenav/sidenav-demo.html b/src/demo-app/sidenav/sidenav-demo.html index 3b362b3006d6..10da18a0fa31 100644 --- a/src/demo-app/sidenav/sidenav-demo.html +++ b/src/demo-app/sidenav/sidenav-demo.html @@ -61,3 +61,27 @@

Dynamic Alignment Sidenav

+ +

Sidenav with focus attributes

+ + + + + Link + Focus region start + Link + Initially focused + Focus region end + Link + + + +
+

My Content

+ +
+
Sidenav
+ +
+
+
diff --git a/src/lib/core/a11y/focus-trap.spec.ts b/src/lib/core/a11y/focus-trap.spec.ts index 49900604f4ff..66e42601212b 100644 --- a/src/lib/core/a11y/focus-trap.spec.ts +++ b/src/lib/core/a11y/focus-trap.spec.ts @@ -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: `
- - + + + + +
` diff --git a/src/lib/core/a11y/focus-trap.ts b/src/lib/core/a11y/focus-trap.ts index 8a993da72e6b..1d853ae21ed1 100644 --- a/src/lib/core/a11y/focus-trap.ts +++ b/src/lib/core/a11y/focus-trap.ts @@ -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(); } diff --git a/src/lib/dialog/dialog-container.ts b/src/lib/dialog/dialog-container.ts index acb5c1459b99..9dcc9d7686c7 100644 --- a/src/lib/dialog/dialog-container.ts +++ b/src/lib/dialog/dialog-container.ts @@ -118,7 +118,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. */ diff --git a/src/lib/list/_list-theme.scss b/src/lib/list/_list-theme.scss index 1b630dcbc523..d839c1912f72 100644 --- a/src/lib/list/_list-theme.scss +++ b/src/lib/list/_list-theme.scss @@ -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'); } diff --git a/src/lib/list/list-item.html b/src/lib/list/list-item.html index 16c46abd0a10..9eb276ea7304 100644 --- a/src/lib/list/list-item.html +++ b/src/lib/list/list-item.html @@ -1,4 +1,4 @@ -
+
diff --git a/src/lib/list/list.spec.ts b/src/lib/list/list.spec.ts index b266b38517b1..5bc594c0b38d 100644 --- a/src/lib/list/list.spec.ts +++ b/src/lib/list/list.spec.ts @@ -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', () => { diff --git a/src/lib/list/list.ts b/src/lib/list/list.ts index 11fb7c4c288b..a8e7501be1d5 100644 --- a/src/lib/list/list.ts +++ b/src/lib/list/list.ts @@ -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. */ diff --git a/src/lib/sidenav/sidenav.ts b/src/lib/sidenav/sidenav.ts index fa1acb75f05d..bb86c2311307 100644 --- a/src/lib/sidenav/sidenav.ts +++ b/src/lib/sidenav/sidenav.ts @@ -133,7 +133,7 @@ export class MdSidenav implements AfterContentInit, OnDestroy { this._elementFocusedBeforeSidenavWasOpened = document.activeElement as HTMLElement; if (this.isFocusTrapEnabled && this._focusTrap) { - this._focusTrap.focusFirstTabbableElementWhenReady(); + this._focusTrap.focusInitialElementWhenReady(); } });