From 04d5955e649651051d445376dcac5dc804c50d29 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 27 Aug 2018 18:57:18 +0200 Subject: [PATCH] fix(expansion-panel): implement keyboard controls (#12427) Based on the [accessibility guidelines](https://www.w3.org/TR/wai-aria-practices-1.1/#accordion), accordions should be able to support moving focus using the keyboard. These changes implement the keyboard support and move some things around to avoid circular imports. --- src/lib/expansion/accordion-base.ts | 38 ++++++ src/lib/expansion/accordion.spec.ts | 142 +++++++++++++++++--- src/lib/expansion/accordion.ts | 46 ++++++- src/lib/expansion/expansion-panel-header.ts | 37 ++++- src/lib/expansion/expansion-panel.ts | 8 +- src/lib/expansion/public-api.ts | 1 + 6 files changed, 237 insertions(+), 35 deletions(-) create mode 100644 src/lib/expansion/accordion-base.ts diff --git a/src/lib/expansion/accordion-base.ts b/src/lib/expansion/accordion-base.ts new file mode 100644 index 000000000000..159cde29dcc6 --- /dev/null +++ b/src/lib/expansion/accordion-base.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {InjectionToken} from '@angular/core'; +import {CdkAccordion} from '@angular/cdk/accordion'; + +/** MatAccordion's display modes. */ +export type MatAccordionDisplayMode = 'default' | 'flat'; + +/** + * Base interface for a `MatAccordion`. + * @docs-private + */ +export interface MatAccordionBase extends CdkAccordion { + /** Whether the expansion indicator should be hidden. */ + hideToggle: boolean; + + /** Display mode used for all expansion panels in the accordion. */ + displayMode: MatAccordionDisplayMode; + + /** Handles keyboard events coming in from the panel headers. */ + _handleHeaderKeydown: (event: KeyboardEvent) => void; + + /** Handles focus events on the panel headers. */ + _handleHeaderFocus: (header: any) => void; +} + + +/** + * Token used to provide a `MatAccordion` to `MatExpansionPanel`. + * Used primarily to avoid circular imports between `MatAccordion` and `MatExpansionPanel`. + */ +export const MAT_ACCORDION = new InjectionToken('MAT_ACCORDION'); diff --git a/src/lib/expansion/accordion.spec.ts b/src/lib/expansion/accordion.spec.ts index ebb1b89143c1..8ba854ab1bc2 100644 --- a/src/lib/expansion/accordion.spec.ts +++ b/src/lib/expansion/accordion.spec.ts @@ -1,11 +1,21 @@ -import {async, TestBed} from '@angular/core/testing'; -import {Component, ViewChild} from '@angular/core'; +import {async, TestBed, inject} from '@angular/core/testing'; +import {Component, ViewChild, QueryList, ViewChildren} from '@angular/core'; import {By} from '@angular/platform-browser'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; -import {MatExpansionModule, MatAccordion, MatExpansionPanel} from './index'; +import { + MatExpansionModule, + MatAccordion, + MatExpansionPanel, + MatExpansionPanelHeader, +} from './index'; +import {dispatchKeyboardEvent} from '@angular/cdk/testing'; +import {DOWN_ARROW, UP_ARROW, HOME, END} from '@angular/cdk/keycodes'; +import {FocusMonitor} from '@angular/cdk/a11y'; describe('MatAccordion', () => { + let focusMonitor: FocusMonitor; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ @@ -19,18 +29,25 @@ describe('MatAccordion', () => { ], }); TestBed.compileComponents(); + + inject([FocusMonitor], (fm: FocusMonitor) => { + focusMonitor = fm; + })(); })); it('should ensure only one item is expanded at a time', () => { const fixture = TestBed.createComponent(SetOfItems); + fixture.detectChanges(); + const items = fixture.debugElement.queryAll(By.css('.mat-expansion-panel')); + const panelInstances = fixture.componentInstance.panels.toArray(); - fixture.componentInstance.firstPanelExpanded = true; + panelInstances[0].expanded = true; fixture.detectChanges(); expect(items[0].classes['mat-expanded']).toBeTruthy(); expect(items[1].classes['mat-expanded']).toBeFalsy(); - fixture.componentInstance.secondPanelExpanded = true; + panelInstances[1].expanded = true; fixture.detectChanges(); expect(items[0].classes['mat-expanded']).toBeFalsy(); expect(items[1].classes['mat-expanded']).toBeTruthy(); @@ -38,11 +55,14 @@ describe('MatAccordion', () => { it('should allow multiple items to be expanded simultaneously', () => { const fixture = TestBed.createComponent(SetOfItems); + fixture.componentInstance.multi = true; + fixture.detectChanges(); + const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel')); + const panelInstances = fixture.componentInstance.panels.toArray(); - fixture.componentInstance.multi = true; - fixture.componentInstance.firstPanelExpanded = true; - fixture.componentInstance.secondPanelExpanded = true; + panelInstances[0].expanded = true; + panelInstances[1].expanded = true; fixture.detectChanges(); expect(panels[0].classes['mat-expanded']).toBeTruthy(); expect(panels[1].classes['mat-expanded']).toBeTruthy(); @@ -50,10 +70,12 @@ describe('MatAccordion', () => { it('should expand or collapse all enabled items', () => { const fixture = TestBed.createComponent(SetOfItems); + fixture.detectChanges(); + const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel')); fixture.componentInstance.multi = true; - fixture.componentInstance.secondPanelExpanded = true; + fixture.componentInstance.panels.toArray()[1].expanded = true; fixture.detectChanges(); expect(panels[0].classes['mat-expanded']).toBeFalsy(); expect(panels[1].classes['mat-expanded']).toBeTruthy(); @@ -71,10 +93,12 @@ describe('MatAccordion', () => { it('should not expand or collapse disabled items', () => { const fixture = TestBed.createComponent(SetOfItems); + fixture.detectChanges(); + const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel')); fixture.componentInstance.multi = true; - fixture.componentInstance.secondPanelDisabled = true; + fixture.componentInstance.panels.toArray()[1].disabled = true; fixture.detectChanges(); fixture.componentInstance.accordion.openAll(); fixture.detectChanges(); @@ -110,27 +134,107 @@ describe('MatAccordion', () => { expect(panel.nativeElement.querySelector('.mat-expansion-indicator')) .toBeFalsy('Expected the expansion indicator to be removed.'); }); + + it('should move focus to the next header when pressing the down arrow', () => { + const fixture = TestBed.createComponent(SetOfItems); + fixture.detectChanges(); + + const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header')); + const headers = fixture.componentInstance.headers.toArray(); + + focusMonitor.focusVia(headerElements[0].nativeElement, 'keyboard'); + headers.forEach(header => spyOn(header, 'focus')); + + // Stop at the second-last header so focus doesn't wrap around. + for (let i = 0; i < headerElements.length - 1; i++) { + dispatchKeyboardEvent(headerElements[i].nativeElement, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + expect(headers[i + 1].focus).toHaveBeenCalledTimes(1); + } + }); + + it('should move focus to the next header when pressing the up arrow', () => { + const fixture = TestBed.createComponent(SetOfItems); + fixture.detectChanges(); + + const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header')); + const headers = fixture.componentInstance.headers.toArray(); + + focusMonitor.focusVia(headerElements[headerElements.length - 1].nativeElement, 'keyboard'); + headers.forEach(header => spyOn(header, 'focus')); + + // Stop before the first header + for (let i = headers.length - 1; i > 0; i--) { + dispatchKeyboardEvent(headerElements[i].nativeElement, 'keydown', UP_ARROW); + fixture.detectChanges(); + expect(headers[i - 1].focus).toHaveBeenCalledTimes(1); + } + }); + + it('should skip disabled items when moving focus with the keyboard', () => { + const fixture = TestBed.createComponent(SetOfItems); + fixture.detectChanges(); + + const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header')); + const panels = fixture.componentInstance.panels.toArray(); + const headers = fixture.componentInstance.headers.toArray(); + + focusMonitor.focusVia(headerElements[0].nativeElement, 'keyboard'); + headers.forEach(header => spyOn(header, 'focus')); + panels[1].disabled = true; + fixture.detectChanges(); + + dispatchKeyboardEvent(headerElements[0].nativeElement, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(headers[1].focus).not.toHaveBeenCalled(); + expect(headers[2].focus).toHaveBeenCalledTimes(1); + }); + + it('should focus the first header when pressing the home key', () => { + const fixture = TestBed.createComponent(SetOfItems); + fixture.detectChanges(); + + const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header')); + const headers = fixture.componentInstance.headers.toArray(); + + headers.forEach(header => spyOn(header, 'focus')); + dispatchKeyboardEvent(headerElements[headerElements.length - 1].nativeElement, 'keydown', HOME); + fixture.detectChanges(); + + expect(headers[0].focus).toHaveBeenCalledTimes(1); + }); + + it('should focus the last header when pressing the end key', () => { + const fixture = TestBed.createComponent(SetOfItems); + fixture.detectChanges(); + + const headerElements = fixture.debugElement.queryAll(By.css('mat-expansion-panel-header')); + const headers = fixture.componentInstance.headers.toArray(); + + headers.forEach(header => spyOn(header, 'focus')); + dispatchKeyboardEvent(headerElements[0].nativeElement, 'keydown', END); + fixture.detectChanges(); + + expect(headers[headers.length - 1].focus).toHaveBeenCalledTimes(1); + }); + }); @Component({template: ` - - Summary -

Content

-
- - Summary + + Summary {{i}}

Content

`}) class SetOfItems { @ViewChild(MatAccordion) accordion: MatAccordion; + @ViewChildren(MatExpansionPanel) panels: QueryList; + @ViewChildren(MatExpansionPanelHeader) headers: QueryList; multi: boolean = false; - firstPanelExpanded: boolean = false; - secondPanelExpanded: boolean = false; - secondPanelDisabled: boolean = false; } @Component({template: ` diff --git a/src/lib/expansion/accordion.ts b/src/lib/expansion/accordion.ts index b366279e7bbc..8db83ca5b972 100644 --- a/src/lib/expansion/accordion.ts +++ b/src/lib/expansion/accordion.ts @@ -6,12 +6,13 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, Input} from '@angular/core'; +import {Directive, Input, ContentChildren, QueryList, AfterContentInit} from '@angular/core'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {CdkAccordion} from '@angular/cdk/accordion'; - -/** MatAccordion's display modes. */ -export type MatAccordionDisplayMode = 'default' | 'flat'; +import {FocusKeyManager} from '@angular/cdk/a11y'; +import {HOME, END} from '@angular/cdk/keycodes'; +import {MAT_ACCORDION, MatAccordionBase, MatAccordionDisplayMode} from './accordion-base'; +import {MatExpansionPanelHeader} from './expansion-panel-header'; /** * Directive for a Material Design Accordion. @@ -20,11 +21,20 @@ export type MatAccordionDisplayMode = 'default' | 'flat'; selector: 'mat-accordion', exportAs: 'matAccordion', inputs: ['multi'], + providers: [{ + provide: MAT_ACCORDION, + useExisting: MatAccordion + }], host: { class: 'mat-accordion' } }) -export class MatAccordion extends CdkAccordion { +export class MatAccordion extends CdkAccordion implements MatAccordionBase, AfterContentInit { + private _keyManager: FocusKeyManager; + + @ContentChildren(MatExpansionPanelHeader, {descendants: true}) + _headers: QueryList; + /** Whether the expansion indicator should be hidden. */ @Input() get hideToggle(): boolean { return this._hideToggle; } @@ -32,7 +42,7 @@ export class MatAccordion extends CdkAccordion { private _hideToggle: boolean = false; /** - * The display mode used for all expansion panels in the accordion. Currently two display + * Display mode used for all expansion panels in the accordion. Currently two display * modes exist: * default - a gutter-like spacing is placed around any expanded panel, placing the expanded * panel at a different elevation from the rest of the accordion. @@ -40,4 +50,28 @@ export class MatAccordion extends CdkAccordion { * elevation. */ @Input() displayMode: MatAccordionDisplayMode = 'default'; + + ngAfterContentInit() { + this._keyManager = new FocusKeyManager(this._headers).withWrap(); + } + + /** Handles keyboard events coming in from the panel headers. */ + _handleHeaderKeydown(event: KeyboardEvent) { + const {keyCode} = event; + const manager = this._keyManager; + + if (keyCode === HOME) { + manager.setFirstItemActive(); + event.preventDefault(); + } else if (keyCode === END) { + manager.setLastItemActive(); + event.preventDefault(); + } else { + this._keyManager.onKeydown(event); + } + } + + _handleHeaderFocus(header: MatExpansionPanelHeader) { + this._keyManager.updateActiveItem(header); + } } diff --git a/src/lib/expansion/expansion-panel-header.ts b/src/lib/expansion/expansion-panel-header.ts index 37cf3ef4f665..793ae41dccda 100644 --- a/src/lib/expansion/expansion-panel-header.ts +++ b/src/lib/expansion/expansion-panel-header.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusMonitor} from '@angular/cdk/a11y'; +import {FocusMonitor, FocusableOption, FocusOrigin} from '@angular/cdk/a11y'; import {ENTER, SPACE} from '@angular/cdk/keycodes'; import { ChangeDetectionStrategy, @@ -45,7 +45,7 @@ import {MatExpansionPanel} from './expansion-panel'; 'class': 'mat-expansion-panel-header', 'role': 'button', '[attr.id]': 'panel._headerId', - '[attr.tabindex]': 'panel.disabled ? -1 : 0', + '[attr.tabindex]': 'disabled ? -1 : 0', '[attr.aria-controls]': '_getPanelId()', '[attr.aria-expanded]': '_isExpanded()', '[attr.aria-disabled]': 'panel.disabled', @@ -61,7 +61,7 @@ import {MatExpansionPanel} from './expansion-panel'; }`, }, }) -export class MatExpansionPanelHeader implements OnDestroy { +export class MatExpansionPanelHeader implements OnDestroy, FocusableOption { private _parentChangeSubscription = Subscription.EMPTY; constructor( @@ -88,7 +88,11 @@ export class MatExpansionPanelHeader implements OnDestroy { .pipe(filter(() => panel._containsFocus())) .subscribe(() => _focusMonitor.focusVia(_element.nativeElement, 'program')); - _focusMonitor.monitor(_element.nativeElement); + _focusMonitor.monitor(_element.nativeElement).subscribe(origin => { + if (origin && panel.accordion) { + panel.accordion._handleHeaderFocus(this); + } + }); } /** Height of the header while the panel is expanded. */ @@ -97,6 +101,14 @@ export class MatExpansionPanelHeader implements OnDestroy { /** Height of the header while the panel is collapsed. */ @Input() collapsedHeight: string; + /** + * Whether the associated panel is disabled. Implemented as a part of `FocusableOption`. + * @docs-private + */ + get disabled() { + return this.panel.disabled; + } + /** Toggles the expanded state of the panel. */ _toggle(): void { this.panel.toggle(); @@ -132,10 +144,23 @@ export class MatExpansionPanelHeader implements OnDestroy { this._toggle(); break; default: + if (this.panel.accordion) { + this.panel.accordion._handleHeaderKeydown(event); + } + return; } } + /** + * Focuses the panel header. Implemented as a part of `FocusableOption`. + * @param origin Origin of the action that triggered the focus. + * @docs-private + */ + focus(origin: FocusOrigin = 'program') { + this._focusMonitor.focusVia(this._element.nativeElement, origin); + } + ngOnDestroy() { this._parentChangeSubscription.unsubscribe(); this._focusMonitor.stopMonitoring(this._element.nativeElement); @@ -149,7 +174,7 @@ export class MatExpansionPanelHeader implements OnDestroy { */ @Directive({ selector: 'mat-panel-description', - host : { + host: { class: 'mat-expansion-panel-header-description' } }) @@ -162,7 +187,7 @@ export class MatExpansionPanelDescription {} */ @Directive({ selector: 'mat-panel-title', - host : { + host: { class: 'mat-expansion-panel-header-title' } }) diff --git a/src/lib/expansion/expansion-panel.ts b/src/lib/expansion/expansion-panel.ts index 289e3ab620da..bd8683af27c1 100644 --- a/src/lib/expansion/expansion-panel.ts +++ b/src/lib/expansion/expansion-panel.ts @@ -33,9 +33,9 @@ import { } from '@angular/core'; import {Subject} from 'rxjs'; import {filter, startWith, take} from 'rxjs/operators'; -import {MatAccordion} from './accordion'; import {matExpansionAnimations} from './expansion-animations'; import {MatExpansionPanelContent} from './expansion-panel-content'; +import {MAT_ACCORDION, MatAccordionBase} from './accordion-base'; // TODO(devversion): workaround for https://github.com/angular/material2/issues/12760 export const _CdkAccordionItem = CdkAccordionItem; @@ -66,7 +66,7 @@ let uniqueId = 0; providers: [ // Provide MatAccordion as undefined to prevent nested expansion panels from registering // to the same accordion. - {provide: MatAccordion, useValue: undefined}, + {provide: MAT_ACCORDION, useValue: undefined}, ], host: { 'class': 'mat-expansion-panel', @@ -95,7 +95,7 @@ export class MatExpansionPanel extends CdkAccordionItem implements AfterContentI readonly _inputChanges = new Subject(); /** Optionally defined accordion the expansion panel belongs to. */ - accordion: MatAccordion; + accordion: MatAccordionBase; /** Content that will be rendered lazily. */ @ContentChild(MatExpansionPanelContent) _lazyContent: MatExpansionPanelContent; @@ -109,7 +109,7 @@ export class MatExpansionPanel extends CdkAccordionItem implements AfterContentI /** ID for the associated header element. Used for a11y labelling. */ _headerId = `mat-expansion-panel-header-${uniqueId++}`; - constructor(@Optional() @SkipSelf() accordion: MatAccordion, + constructor(@Optional() @SkipSelf() @Inject(MAT_ACCORDION) accordion: MatAccordionBase, _changeDetectorRef: ChangeDetectorRef, _uniqueSelectionDispatcher: UniqueSelectionDispatcher, private _viewContainerRef: ViewContainerRef, diff --git a/src/lib/expansion/public-api.ts b/src/lib/expansion/public-api.ts index 7f4e568aa9bd..0a2b10559fb2 100644 --- a/src/lib/expansion/public-api.ts +++ b/src/lib/expansion/public-api.ts @@ -8,6 +8,7 @@ export * from './expansion-module'; export * from './accordion'; +export * from './accordion-base'; export * from './expansion-panel'; export * from './expansion-panel-header'; export * from './expansion-panel-content';