Skip to content

feat(expansion): add accordion expand/collapse all (#6929) #7461

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
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
18 changes: 18 additions & 0 deletions src/cdk/accordion/accordion-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import {UniqueSelectionDispatcher} from '@angular/cdk/collections';
import {CdkAccordion} from './accordion';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {Subscription} from 'rxjs/Subscription';

/** Used to generate unique ID for each accordion item. */
let nextId = 0;
Expand All @@ -31,6 +32,8 @@ let nextId = 0;
exportAs: 'cdkAccordionItem',
})
export class CdkAccordionItem implements OnDestroy {
/** Subscription to openAll/closeAll events. */
private _openCloseAllSubscription = Subscription.EMPTY;
/** Event emitted every time the AccordionItem is closed. */
@Output() closed: EventEmitter<void> = new EventEmitter<void>();
/** Event emitted every time the AccordionItem is opened. */
Expand Down Expand Up @@ -97,12 +100,18 @@ export class CdkAccordionItem implements OnDestroy {
this.expanded = false;
}
});

// When an accordion item is hosted in an accordion, subscribe to open/close events.
if (this.accordion) {
this._openCloseAllSubscription = this._subscribeToOpenCloseAllActions();
}
}

/** Emits an event for the accordion item being destroyed. */
ngOnDestroy() {
this.destroyed.emit();
this._removeUniqueSelectionListener();
this._openCloseAllSubscription.unsubscribe();
}

/** Toggles the expanded state of the accordion item. */
Expand All @@ -125,4 +134,13 @@ export class CdkAccordionItem implements OnDestroy {
this.expanded = true;
}
}

private _subscribeToOpenCloseAllActions(): Subscription {
return this.accordion._openCloseAllActions.subscribe(expanded => {
// Only change expanded state if item is enabled
if (!this.disabled) {
this.expanded = expanded;
}
});
}
}
20 changes: 20 additions & 0 deletions src/cdk/accordion/accordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import {Directive, Input} from '@angular/core';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {Subject} from 'rxjs/Subject';

/** Used to generate unique ID for each accordion. */
let nextId = 0;
Expand All @@ -20,6 +21,9 @@ let nextId = 0;
exportAs: 'cdkAccordion',
})
export class CdkAccordion {
/** Stream that emits true/false when openAll/closeAll is triggered. */
readonly _openCloseAllActions: Subject<boolean> = new Subject<boolean>();

/** A readonly id value to use for unique selection coordination. */
readonly id = `cdk-accordion-${nextId++}`;

Expand All @@ -28,4 +32,20 @@ export class CdkAccordion {
get multi(): boolean { return this._multi; }
set multi(multi: boolean) { this._multi = coerceBooleanProperty(multi); }
private _multi: boolean = false;

/** Opens all enabled accordion items in an accordion where multi is enabled. */
openAll(): void {
this._openCloseAll(true);
}

/** Closes all enabled accordion items in an accordion where multi is enabled. */
closeAll(): void {
this._openCloseAll(false);
}

private _openCloseAll(expanded: boolean): void {
if (this.multi) {
Copy link
Contributor

@SchnWalter SchnWalter Jan 30, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some cases, even for [multi]="false", where you don't care which CdkAccordionItem is open, you just want to make sure that all are closed and it would be nice to also cover that use case, but I'm not sure how this could be done without confusing developers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you could do this.multi || !expanded? Will have to experiment.

Copy link
Contributor

@SchnWalter SchnWalter Jan 31, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be a solution, but we could have a "cleaner" solution, if I may use this word.

This would be a good solution for this particular case. But maybe we could find a more generic solution that could be used in other cases and not confuse people to much, openAll() won't work in single mode, but we could have open() that works for single mode, opening the first item or in multi mode.

If you look at something like the SelectionModel for tables, select and chips, the selection has a getMultipleValuesInSingleSelectionError, we could use something like that. And MatSelectionList.selectedOptions exposes the model for anyone to use. But probably a model would be an overkill for this situation, I'm not sure in how many places we could reuse it.

Also, MatDrawerContainer container has a similar behavior but it doesn't add the All suffix and it uses @ContentChildren to keep a list of children and then call open/close on each children, instead of emitting events, here's a snippet from it:

  @ContentChildren(MatDrawer) _drawers: QueryList<MatDrawer>;

  /** Calls `open` of both start and end drawers */
  open(): void {
    this._drawers.forEach(drawer => drawer.open());
  }

  /** Calls `close` of both start and end drawers */
  close(): void {
    this._drawers.forEach(drawer => drawer.close());
  }

Using ContentChildren instead of emitting an event and rename to open/close in order to support "multi/single" mode, would be great.

P.S. Thanks for the work you did on this PR!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This maybe warrants a separate issue to investigate?

this._openCloseAllActions.next(expanded);
}
}
}
5 changes: 5 additions & 0 deletions src/demo-app/expansion/expansion-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ <h1>matAccordion</h1>
<mat-radio-button value="default">Default</mat-radio-button>
<mat-radio-button value="flat">Flat</mat-radio-button>
</mat-radio-group>
<p>Accordion Actions <sup>('Multi Expansion' mode only)</sup></p>
<div>
<button mat-button (click)="accordion.openAll()" [disabled]="!multi">Expand All</button>
<button mat-button (click)="accordion.closeAll()" [disabled]="!multi">Collapse All</button>
</div>
<p>Accordion Panel(s)</p>
<div>
<mat-checkbox [(ngModel)]="panel1.expanded">Panel 1</mat-checkbox>
Expand Down
5 changes: 4 additions & 1 deletion src/demo-app/expansion/expansion-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Component, ViewEncapsulation} from '@angular/core';
import {Component, ViewChild, ViewEncapsulation} from '@angular/core';
import {MatAccordion} from '@angular/material';

@Component({
moduleId: module.id,
Expand All @@ -17,6 +18,8 @@ import {Component, ViewEncapsulation} from '@angular/core';
preserveWhitespaces: false,
})
export class ExpansionDemo {
@ViewChild(MatAccordion) accordion: MatAccordion;

displayMode: string = 'default';
multi = false;
hideToggle = false;
Expand Down
48 changes: 45 additions & 3 deletions src/lib/expansion/accordion.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {async, TestBed} from '@angular/core/testing';
import {Component} from '@angular/core';
import {Component, ViewChild} from '@angular/core';
import {By} from '@angular/platform-browser';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {MatExpansionModule} from './index';
import {MatExpansionModule, MatAccordion} from './index';


describe('CdkAccordion', () => {
Expand Down Expand Up @@ -45,6 +45,45 @@ describe('CdkAccordion', () => {
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);
const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));

fixture.componentInstance.multi = true;
fixture.componentInstance.secondPanelExpanded = true;
fixture.detectChanges();
expect(panels[0].classes['mat-expanded']).toBeFalsy();
expect(panels[1].classes['mat-expanded']).toBeTruthy();

fixture.componentInstance.accordion.openAll();
fixture.detectChanges();
expect(panels[0].classes['mat-expanded']).toBeTruthy();
expect(panels[1].classes['mat-expanded']).toBeTruthy();

fixture.componentInstance.accordion.closeAll();
fixture.detectChanges();
expect(panels[0].classes['mat-expanded']).toBeFalsy();
expect(panels[1].classes['mat-expanded']).toBeFalsy();
});

it('should not expand or collapse disabled items', () => {
const fixture = TestBed.createComponent(SetOfItems);
const panels = fixture.debugElement.queryAll(By.css('.mat-expansion-panel'));

fixture.componentInstance.multi = true;
fixture.componentInstance.secondPanelDisabled = true;
fixture.detectChanges();
fixture.componentInstance.accordion.openAll();
fixture.detectChanges();
expect(panels[0].classes['mat-expanded']).toBeTruthy();
expect(panels[1].classes['mat-expanded']).toBeFalsy();

fixture.componentInstance.accordion.closeAll();
fixture.detectChanges();
expect(panels[0].classes['mat-expanded']).toBeFalsy();
expect(panels[1].classes['mat-expanded']).toBeFalsy();
});
});


Expand All @@ -54,13 +93,16 @@ describe('CdkAccordion', () => {
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
<p>Content</p>
</mat-expansion-panel>
<mat-expansion-panel [expanded]="secondPanelExpanded">
<mat-expansion-panel [expanded]="secondPanelExpanded" [disabled]="secondPanelDisabled">
<mat-expansion-panel-header>Summary</mat-expansion-panel-header>
<p>Content</p>
</mat-expansion-panel>
</mat-accordion>`})
class SetOfItems {
@ViewChild(MatAccordion) accordion: MatAccordion;

multi: boolean = false;
firstPanelExpanded: boolean = false;
secondPanelExpanded: boolean = false;
secondPanelDisabled: boolean = false;
}
2 changes: 1 addition & 1 deletion src/lib/expansion/accordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class MatAccordion extends CdkAccordion {
/**
* The 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
* default - a gutter-like spacing is placed around any expanded panel, placing the expanded
* panel at a different elevation from the reset of the accordion.
* flat - no spacing is placed around expanded panels, showing all panels at the same
* elevation.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.example-action-buttons {
padding-bottom: 20px;
}

.example-headers-align .mat-expansion-panel-header-title,
.example-headers-align .mat-expansion-panel-header-description {
flex-basis: 0;
}

.example-headers-align .mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<div class="example-action-buttons">
<button mat-button (click)="accordion.openAll()">Expand All</button>
<button mat-button (click)="accordion.closeAll()">Collapse All</button>
</div>
<mat-accordion class="example-headers-align" [multi]="true">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Personal data
</mat-panel-title>
<mat-panel-description>
Type your name and age
<mat-icon>account_circle</mat-icon>
</mat-panel-description>
</mat-expansion-panel-header>

<mat-form-field>
<input matInput placeholder="First name">
</mat-form-field>

<mat-form-field>
<input matInput type="number" min="1" placeholder="Age">
</mat-form-field>

</mat-expansion-panel>

<mat-expansion-panel [disabled]="true">
<mat-expansion-panel-header>
<mat-panel-title>
Destination
</mat-panel-title>
<mat-panel-description>
Type the country name
<mat-icon>map</mat-icon>
</mat-panel-description>
</mat-expansion-panel-header>

<mat-form-field>
<input matInput placeholder="Country">
</mat-form-field>
</mat-expansion-panel>

<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Day of the trip
</mat-panel-title>
<mat-panel-description>
Inform the date you wish to travel
<mat-icon>date_range</mat-icon>
</mat-panel-description>
</mat-expansion-panel-header>

<mat-form-field>
<input matInput placeholder="Date" [matDatepicker]="picker" (focus)="picker.open()" readonly>
</mat-form-field>
<mat-datepicker #picker></mat-datepicker>
</mat-expansion-panel>

</mat-accordion>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {Component, ViewChild} from '@angular/core';
import {MatAccordion} from '@angular/material';

/**
* @title Accordion with expand/collapse all toggles
*/
@Component({
selector: 'expansion-toggle-all-example',
templateUrl: 'expansion-expand-collapse-all-example.html',
styleUrls: ['expansion-expand-collapse-all-example.css']
})
export class ExpansionExpandCollapseAllExample {
@ViewChild(MatAccordion) accordion: MatAccordion;
}