Skip to content

fix(expansion-panel): implement keyboard controls #12427

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 27, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/lib/expansion/accordion-base.ts
Original file line number Diff line number Diff line change
@@ -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<MatAccordionBase>('MAT_ACCORDION');
142 changes: 123 additions & 19 deletions src/lib/expansion/accordion.spec.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand All @@ -19,41 +29,53 @@ 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();
});

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();
});

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();
Expand All @@ -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();
Expand Down Expand Up @@ -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: `
<mat-accordion [multi]="multi">
<mat-expansion-panel [expanded]="firstPanelExpanded">
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
<p>Content</p>
</mat-expansion-panel>
<mat-expansion-panel [expanded]="secondPanelExpanded" [disabled]="secondPanelDisabled">
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
<mat-expansion-panel *ngFor="let i of [0, 1, 2, 3]">
<mat-expansion-panel-header>Summary {{i}}</mat-expansion-panel-header>
<p>Content</p>
</mat-expansion-panel>
</mat-accordion>`})
class SetOfItems {
@ViewChild(MatAccordion) accordion: MatAccordion;
@ViewChildren(MatExpansionPanel) panels: QueryList<MatExpansionPanel>;
@ViewChildren(MatExpansionPanelHeader) headers: QueryList<MatExpansionPanelHeader>;

multi: boolean = false;
firstPanelExpanded: boolean = false;
secondPanelExpanded: boolean = false;
secondPanelDisabled: boolean = false;
}

@Component({template: `
Expand Down
46 changes: 40 additions & 6 deletions src/lib/expansion/accordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,24 +21,57 @@ 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<MatExpansionPanelHeader>;

@ContentChildren(MatExpansionPanelHeader, {descendants: true})
_headers: QueryList<MatExpansionPanelHeader>;

/** Whether the expansion indicator should be hidden. */
@Input()
get hideToggle(): boolean { return this._hideToggle; }
set hideToggle(show: boolean) { this._hideToggle = coerceBooleanProperty(show); }
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.
* flat - no spacing is placed around expanded panels, showing all panels at the same
* 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);
}
}
Loading