From 5167225cf3318f5200a3bdad45f676dd62eb6363 Mon Sep 17 00:00:00 2001 From: Oleg Pimenov Date: Wed, 6 Mar 2019 23:24:21 +0300 Subject: [PATCH] feat(cdk): accordion item --- src/cdk/accordion/accordion-item.spec.ts | 275 +++++++++++++++++++++++ src/cdk/accordion/accordion-item.ts | 162 +++++++++++++ src/cdk/accordion/accordion-module.ts | 15 ++ src/cdk/accordion/accordion.spec.ts | 88 ++++++++ src/cdk/accordion/accordion.ts | 61 +++++ src/cdk/accordion/index.ts | 1 + src/cdk/accordion/public-api.ts | 4 + src/cdk/accordion/tsconfig.build.json | 13 ++ tests/karma-system-config.js | 1 + 9 files changed, 620 insertions(+) create mode 100644 src/cdk/accordion/accordion-item.spec.ts create mode 100644 src/cdk/accordion/accordion-item.ts create mode 100644 src/cdk/accordion/accordion-module.ts create mode 100644 src/cdk/accordion/accordion.spec.ts create mode 100644 src/cdk/accordion/accordion.ts create mode 100644 src/cdk/accordion/index.ts create mode 100644 src/cdk/accordion/public-api.ts create mode 100644 src/cdk/accordion/tsconfig.build.json diff --git a/src/cdk/accordion/accordion-item.spec.ts b/src/cdk/accordion/accordion-item.spec.ts new file mode 100644 index 000000000..2668c03bf --- /dev/null +++ b/src/cdk/accordion/accordion-item.spec.ts @@ -0,0 +1,275 @@ +import { Component } from '@angular/core'; +import { async, TestBed, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { CdkAccordionModule, CdkAccordionItem } from './public-api'; + + +// tslint:disable:no-empty +// tslint:disable:no-unbound-method +describe('CdkAccordionItem', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + CdkAccordionModule + ], + declarations: [ + SingleItem, + ItemGroupWithoutAccordion, + ItemGroupWithAccordion + ] + }); + TestBed.compileComponents(); + })); + + describe('single item', () => { + let fixture: ComponentFixture; + let item: CdkAccordionItem; + + beforeEach(() => { + fixture = TestBed.createComponent(SingleItem); + item = fixture.debugElement + .query(By.directive(CdkAccordionItem)) + .injector.get(CdkAccordionItem); + }); + + describe('that is not disabled', () => { + beforeEach(() => { + item.disabled = false; + }); + + it('should toggle its expanded state', () => { + expect(item.expanded).toBe(false); + item.toggle(); + expect(item.expanded).toBe(true); + item.toggle(); + expect(item.expanded).toBe(false); + }); + + it('should set its expanded state to expanded', () => { + item.expanded = false; + item.open(); + expect(item.expanded).toBe(true); + }); + + it('should set its expanded state to closed', () => { + item.expanded = true; + item.close(); + expect(item.expanded).toBe(false); + }); + + it('should emit a closed event', () => { + item.open(); + fixture.detectChanges(); + spyOn(item.closed, 'emit'); + item.close(); + fixture.detectChanges(); + expect(item.closed.emit).toHaveBeenCalled(); + }); + + it('should not emit a closed event when the item is closed already', () => { + expect(item.expanded).toBe(false); + spyOn(item.closed, 'emit'); + item.close(); + fixture.detectChanges(); + expect(item.closed.emit).not.toHaveBeenCalled(); + }); + + it('should emit an opened event', () => { + spyOn(item.opened, 'emit'); + item.open(); + fixture.detectChanges(); + expect(item.opened.emit).toHaveBeenCalled(); + }); + + it('should emit a destroyed event', () => { + spyOn(item.destroyed, 'emit'); + item.ngOnDestroy(); + fixture.detectChanges(); + expect(item.destroyed.emit).toHaveBeenCalled(); + }); + }); + + describe('that is disabled', () => { + beforeEach(() => { + item.disabled = true; + }); + + it('should not toggle its expanded state', () => { + expect(item.expanded).toBe(false); + item.toggle(); + expect(item.expanded).toBe(false); + }); + + it('should not set its expanded state to expanded', () => { + item.expanded = false; + item.open(); + expect(item.expanded).toBe(false); + }); + + it('should not set its expanded state to closed', () => { + item.expanded = true; + item.close(); + expect(item.expanded).toBe(true); + }); + + it('should not emit a closed event', () => { + spyOn(item.closed, 'emit'); + item.close(); + fixture.detectChanges(); + expect(item.closed.emit).not.toHaveBeenCalled(); + }); + + it('should not emit an opened event', () => { + spyOn(item.opened, 'emit'); + item.open(); + fixture.detectChanges(); + expect(item.opened.emit).not.toHaveBeenCalled(); + }); + + it('should emit a destroyed event', () => { + spyOn(item.destroyed, 'emit'); + item.ngOnDestroy(); + fixture.detectChanges(); + expect(item.destroyed.emit).toHaveBeenCalled(); + }); + }); + + it('should emit to and complete the `destroyed` stream on destroy', () => { + const emitSpy = jasmine.createSpy('emit spy'); + const completeSpy = jasmine.createSpy('complete spy'); + const subscription = item.destroyed.subscribe(emitSpy, undefined, completeSpy); + + fixture.detectChanges(); + fixture.destroy(); + + expect(emitSpy).toHaveBeenCalled(); + expect(completeSpy).toHaveBeenCalled(); + + subscription.unsubscribe(); + }); + + it('should complete the `opened` stream on destroy', () => { + const completeSpy = jasmine.createSpy('complete spy'); + const subscription = item.opened.subscribe(() => { + }, undefined, completeSpy); + + fixture.detectChanges(); + fixture.destroy(); + + expect(completeSpy).toHaveBeenCalled(); + + subscription.unsubscribe(); + }); + + it('should complete the `closed` stream on destroy', () => { + const completeSpy = jasmine.createSpy('complete spy'); + const subscription = item.closed.subscribe(() => { + }, undefined, completeSpy); + + fixture.detectChanges(); + fixture.destroy(); + + expect(completeSpy).toHaveBeenCalled(); + + subscription.unsubscribe(); + }); + + }); + + describe('items without accordion', () => { + let fixture: ComponentFixture; + let firstItem: CdkAccordionItem; + let secondItem: CdkAccordionItem; + + beforeEach(() => { + fixture = TestBed.createComponent(ItemGroupWithoutAccordion); + [firstItem, secondItem] = fixture.debugElement + .queryAll(By.directive(CdkAccordionItem)) + .map((el) => el.injector.get(CdkAccordionItem)); + }); + + it('should not change expanded state based on unrelated items', () => { + expect(firstItem.expanded).toBe(false); + expect(secondItem.expanded).toBe(false); + firstItem.open(); + fixture.detectChanges(); + expect(firstItem.expanded).toBe(true); + expect(secondItem.expanded).toBe(false); + secondItem.open(); + fixture.detectChanges(); + expect(firstItem.expanded).toBe(true); + expect(secondItem.expanded).toBe(true); + }); + + it('should not change expanded state for disabled items', () => { + firstItem.disabled = true; + expect(firstItem.expanded).toBe(false); + expect(secondItem.expanded).toBe(false); + firstItem.open(); + fixture.detectChanges(); + expect(firstItem.expanded).toBe(false); + expect(secondItem.expanded).toBe(false); + secondItem.open(); + fixture.detectChanges(); + expect(firstItem.expanded).toBe(false); + expect(secondItem.expanded).toBe(true); + }); + }); + + + describe('items in accordion', () => { + let fixture: ComponentFixture; + let firstItem: CdkAccordionItem; + let secondItem: CdkAccordionItem; + + beforeEach(() => { + fixture = TestBed.createComponent(ItemGroupWithAccordion); + [firstItem, secondItem] = fixture.debugElement + .queryAll(By.directive(CdkAccordionItem)) + .map((el) => el.injector.get(CdkAccordionItem)); + }); + + it('should change expanded state based on related items', () => { + expect(firstItem.expanded).toBe(false); + expect(secondItem.expanded).toBe(false); + firstItem.open(); + fixture.detectChanges(); + expect(firstItem.expanded).toBe(true); + expect(secondItem.expanded).toBe(false); + secondItem.open(); + fixture.detectChanges(); + expect(firstItem.expanded).toBe(false); + expect(secondItem.expanded).toBe(true); + }); + }); +}); + +@Component({ + template: ` + ` +}) +class SingleItem { +} + +@Component({ + template: ` + + + ` +}) +class ItemGroupWithoutAccordion { +} + +@Component({ + template: ` + + + + + ` +}) +class ItemGroupWithAccordion { +} diff --git a/src/cdk/accordion/accordion-item.ts b/src/cdk/accordion/accordion-item.ts new file mode 100644 index 000000000..a3c8902df --- /dev/null +++ b/src/cdk/accordion/accordion-item.ts @@ -0,0 +1,162 @@ +import { + Output, + Directive, + EventEmitter, + Input, + OnDestroy, + Optional, + ChangeDetectorRef, + SkipSelf +} from '@angular/core'; +import { coerceBooleanProperty } from '@ptsecurity/cdk/coercion'; +import { UniqueSelectionDispatcher } from '@ptsecurity/cdk/collections'; +import { Subscription } from 'rxjs'; + +import { CdkAccordion } from './accordion'; + + +/** Used to generate unique ID for each accordion item. */ +let nextId = 0; + +/** + * An basic directive expected to be extended and decorated as a component. Sets up all + * events and attributes needed to be managed by a CdkAccordion parent. + */ +@Directive({ + selector: 'cdk-accordion-item, [cdkAccordionItem]', + exportAs: 'cdkAccordionItem', + providers: [ + // Provide CdkAccordion as undefined to prevent nested accordion items from registering + // to the same accordion. + {provide: CdkAccordion, useValue: undefined} + ] +}) +export class CdkAccordionItem implements OnDestroy { + + /** Whether the AccordionItem is expanded. */ + @Input() + get expanded(): any { + return this._expanded; + } + + set expanded(expanded: any) { + // tslint:disable:no-parameter-reassignment + expanded = coerceBooleanProperty(expanded); + + // Only emit events and update the internal value if the value changes. + if (this._expanded !== expanded) { + this._expanded = expanded; + this.expandedChange.emit(expanded); + + if (expanded) { + this.opened.emit(); + /** + * In the unique selection dispatcher, the id parameter is the id of the CdkAccordionItem, + * the name value is the id of the accordion. + */ + const accordionId = this.accordion ? this.accordion.id : this.id; + this._expansionDispatcher.notify(this.id, accordionId); + } else { + this.closed.emit(); + } + + // Ensures that the animation will run when the value is set outside of an `@Input`. + // This includes cases like the open, close and toggle methods. + this._changeDetectorRef.markForCheck(); + } + } + + /** Whether the AccordionItem is disabled. */ + @Input() + get disabled() { + return this._disabled; + } + + set disabled(disabled: any) { + this._disabled = coerceBooleanProperty(disabled); + } + /** Event emitted every time the AccordionItem is closed. */ + @Output() closed: EventEmitter = new EventEmitter(); + /** Event emitted every time the AccordionItem is opened. */ + @Output() opened: EventEmitter = new EventEmitter(); + /** Event emitted when the AccordionItem is destroyed. */ + @Output() destroyed: EventEmitter = new EventEmitter(); + + /** + * Emits whenever the expanded state of the accordion changes. + * Primarily used to facilitate two-way binding. + * @docs-private + */ + @Output() expandedChange: EventEmitter = new EventEmitter(); + + /** The unique AccordionItem id. */ + readonly id: string = `cdk-accordion-child-${nextId++}`; + /** Subscription to openAll/closeAll events. */ + private openCloseAllSubscription = Subscription.EMPTY; + + private _expanded = false; + + private _disabled: boolean = false; + + constructor(@Optional() @SkipSelf() public accordion: CdkAccordion, + private _changeDetectorRef: ChangeDetectorRef, + protected _expansionDispatcher: UniqueSelectionDispatcher) { + + this.removeUniqueSelectionListener = + _expansionDispatcher.listen((id: string, accordionId: string) => { + if (this.accordion && !this.accordion.multi && + this.accordion.id === accordionId && this.id !== id) { + 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.opened.complete(); + this.closed.complete(); + this.destroyed.emit(); + this.destroyed.complete(); + this.removeUniqueSelectionListener(); + this.openCloseAllSubscription.unsubscribe(); + } + + /** Toggles the expanded state of the accordion item. */ + toggle(): void { + if (!this.disabled) { + this.expanded = !this.expanded; + } + } + + /** Sets the expanded state of the accordion item to false. */ + close(): void { + if (!this.disabled) { + this.expanded = false; + } + } + + /** Sets the expanded state of the accordion item to true. */ + open(): void { + if (!this.disabled) { + this.expanded = true; + } + } + + /** Unregister function for _expansionDispatcher. */ + // tslint:disable:no-empty + private removeUniqueSelectionListener: () => void = () => {}; + + private subscribeToOpenCloseAllActions(): Subscription { + return this.accordion.openCloseAllActions.subscribe((expanded) => { + // Only change expanded state if item is enabled + if (!this.disabled) { + this.expanded = expanded; + } + }); + } +} diff --git a/src/cdk/accordion/accordion-module.ts b/src/cdk/accordion/accordion-module.ts new file mode 100644 index 000000000..62de4a924 --- /dev/null +++ b/src/cdk/accordion/accordion-module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; + +import { CdkAccordion } from './accordion'; +import { CdkAccordionItem } from './accordion-item'; + + +@NgModule({ + exports: [ + CdkAccordion, CdkAccordionItem + ], + declarations: [ + CdkAccordion, CdkAccordionItem + ] +}) +export class CdkAccordionModule {} diff --git a/src/cdk/accordion/accordion.spec.ts b/src/cdk/accordion/accordion.spec.ts new file mode 100644 index 000000000..649df29df --- /dev/null +++ b/src/cdk/accordion/accordion.spec.ts @@ -0,0 +1,88 @@ +import { Component, ViewChild } from '@angular/core'; +import { async, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { CdkAccordionModule, CdkAccordionItem } from './public-api'; + + +describe('CdkAccordion', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserAnimationsModule, + CdkAccordionModule + ], + declarations: [ + SetOfItems, + NestedItems + ] + }); + TestBed.compileComponents(); + })); + + it('should ensure only one item is expanded at a time', () => { + const fixture = TestBed.createComponent(SetOfItems); + const [firstPanel, secondPanel] = fixture.debugElement + .queryAll(By.directive(CdkAccordionItem)) + .map((el) => el.injector.get(CdkAccordionItem)); + + firstPanel.open(); + fixture.detectChanges(); + expect(firstPanel.expanded).toBeTruthy(); + expect(secondPanel.expanded).toBeFalsy(); + + secondPanel.open(); + fixture.detectChanges(); + expect(firstPanel.expanded).toBeFalsy(); + expect(secondPanel.expanded).toBeTruthy(); + }); + + it('should allow multiple items to be expanded simultaneously', () => { + const fixture = TestBed.createComponent(SetOfItems); + const [firstPanel, secondPanel] = fixture.debugElement + .queryAll(By.directive(CdkAccordionItem)) + .map((el) => el.injector.get(CdkAccordionItem)); + + fixture.componentInstance.multi = true; + fixture.detectChanges(); + firstPanel.expanded = true; + secondPanel.expanded = true; + fixture.detectChanges(); + expect(firstPanel.expanded).toBeTruthy(); + expect(secondPanel.expanded).toBeTruthy(); + }); + + it('should not register nested items to the same accordion', () => { + const fixture = TestBed.createComponent(NestedItems); + const innerItem = fixture.componentInstance.innerItem; + const outerItem = fixture.componentInstance.outerItem; + + expect(innerItem.accordion).not.toBe(outerItem.accordion); + }); +}); + +@Component({ + template: ` + + + + ` +}) +class SetOfItems { + multi: boolean = false; +} + + +@Component({ + template: ` + + + + + ` +}) +class NestedItems { + @ViewChild('outerItem') outerItem: CdkAccordionItem; + @ViewChild('innerItem') innerItem: CdkAccordionItem; +} diff --git a/src/cdk/accordion/accordion.ts b/src/cdk/accordion/accordion.ts new file mode 100644 index 000000000..7f86dc7ef --- /dev/null +++ b/src/cdk/accordion/accordion.ts @@ -0,0 +1,61 @@ +import { Directive, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import { coerceBooleanProperty } from '@ptsecurity/cdk/coercion'; +import { Subject } from 'rxjs'; + + +/** Used to generate unique ID for each accordion. */ +let nextId = 0; + +/** + * Directive whose purpose is to manage the expanded state of CdkAccordionItem children. + */ +@Directive({ + selector: 'cdk-accordion, [cdkAccordion]', + exportAs: 'cdkAccordion' +}) +export class CdkAccordion implements OnDestroy, OnChanges { + /** Emits when the state of the accordion changes */ + readonly stateChanges = new Subject(); + + /** Stream that emits true/false when openAll/closeAll is triggered. */ + readonly openCloseAllActions: Subject = new Subject(); + + /** A readonly id value to use for unique selection coordination. */ + readonly id = `cdk-accordion-${nextId++}`; + + /** Whether the accordion should allow multiple expanded accordion items simultaneously. */ + @Input() + 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); + } + + ngOnChanges(changes: SimpleChanges) { + this.stateChanges.next(changes); + } + + ngOnDestroy() { + this.stateChanges.complete(); + } + + private openCloseAll(expanded: boolean): void { + if (this.multi) { + this.openCloseAllActions.next(expanded); + } + } +} diff --git a/src/cdk/accordion/index.ts b/src/cdk/accordion/index.ts new file mode 100644 index 000000000..1c48ad0a9 --- /dev/null +++ b/src/cdk/accordion/index.ts @@ -0,0 +1 @@ + export * from './public-api'; diff --git a/src/cdk/accordion/public-api.ts b/src/cdk/accordion/public-api.ts new file mode 100644 index 000000000..f0ad17203 --- /dev/null +++ b/src/cdk/accordion/public-api.ts @@ -0,0 +1,4 @@ +export {CdkAccordionItem} from './accordion-item'; +export {CdkAccordion} from './accordion'; + +export * from './accordion-module'; diff --git a/src/cdk/accordion/tsconfig.build.json b/src/cdk/accordion/tsconfig.build.json new file mode 100644 index 000000000..eabaf6224 --- /dev/null +++ b/src/cdk/accordion/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.build", + "files": [ + "public-api.ts" + ], + "angularCompilerOptions": { + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@ptsecurity/cdk/accordion", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} diff --git a/tests/karma-system-config.js b/tests/karma-system-config.js index 231a25b9a..5d121857b 100644 --- a/tests/karma-system-config.js +++ b/tests/karma-system-config.js @@ -39,6 +39,7 @@ System.config({ '@ptsecurity/cdk': 'dist/packages/cdk/index.js', '@ptsecurity/cdk/a11y': 'dist/packages/cdk/a11y/index.js', + '@ptsecurity/cdk/accordion': 'dist/packages/cdk/accordion/index.js', '@ptsecurity/cdk/bidi': 'dist/packages/cdk/bidi/index.js', '@ptsecurity/cdk/datetime': 'dist/packages/cdk/datetime/index.js', '@ptsecurity/cdk/coercion': 'dist/packages/cdk/coercion/index.js',