diff --git a/libs/barista-components/core/src/common-behaviours/id.spec.ts b/libs/barista-components/core/src/common-behaviours/id.spec.ts new file mode 100644 index 0000000000..0d5647cdc8 --- /dev/null +++ b/libs/barista-components/core/src/common-behaviours/id.spec.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mixinId } from './id'; + +describe('MixinId', () => { + it('should augment an existing class with an property', () => { + class EmptyClass {} + + const classWithDisabled = mixinId(EmptyClass, 'dt-mixin-test'); + const instance = new classWithDisabled(); + + // Expected the mixed-into class to have an id property + expect(instance.id).toMatch(/dt-mixin-test-\d/); + + instance.id = 'my-id'; + // Expected the mixed-into class to have an updated id property + expect(instance.id).toBe('my-id'); + }); +}); diff --git a/libs/barista-components/core/src/common-behaviours/id.ts b/libs/barista-components/core/src/common-behaviours/id.ts new file mode 100644 index 0000000000..378d1d2382 --- /dev/null +++ b/libs/barista-components/core/src/common-behaviours/id.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2020 Dynatrace LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Constructor } from './constructor'; +import { isDefined } from '../util'; + +/** + * UniqueId counter, will be incremented with every + * instatiation of the ExpandablePanel class + */ +let uniqueId = 0; + +export interface HasId { + /** Represents the unique id of the component. */ + id: string; +} + +/** Mixin to augment a directive with a `id` property. */ +export function mixinId>( + base: T, + idPreset: string, +): Constructor & T { + return class extends base { + /** Sets a unique id for the expandable section. */ + get id(): string { + return this._id; + } + set id(value: string) { + if (isDefined(value)) { + this._id = value; + } else { + this._id = `${idPreset}-${uniqueId++}`; + } + } + private _id = `${idPreset}-${uniqueId++}`; + + // tslint:disable-next-line + constructor(...args: any[]) { + super(...args); // tslint:disable-line:no-inferred-empty-object-type + } + }; +} diff --git a/libs/barista-components/core/src/common-behaviours/index.ts b/libs/barista-components/core/src/common-behaviours/index.ts index 95072618bd..be03067719 100644 --- a/libs/barista-components/core/src/common-behaviours/index.ts +++ b/libs/barista-components/core/src/common-behaviours/index.ts @@ -21,3 +21,4 @@ export * from './error-state'; export * from './progress'; export * from './tabindex'; export * from './dom-exit'; +export * from './id'; diff --git a/libs/barista-components/expandable-panel/README.md b/libs/barista-components/expandable-panel/README.md index a6f742e3a7..43de68c894 100644 --- a/libs/barista-components/expandable-panel/README.md +++ b/libs/barista-components/expandable-panel/README.md @@ -34,10 +34,11 @@ element. ## Inputs -| Name | Type | Default | Description | -| ---------- | --------- | ------- | ---------------------------------------- | -| `expanded` | `boolean` | `false` | Sets or gets the panel's expanded state. | -| `disabled` | `boolean` | `false` | Sets or gets the panel's disabled state. | +| Name | Type | Default | Description | +| ---------- | --------- | ------------------------------------- | ---------------------------------------- | +| `expanded` | `boolean` | `false` | Sets or gets the panel's expanded state. | +| `disabled` | `boolean` | `false` | Sets or gets the panel's disabled state. | +| `id` | `string` | `dt-expandable-panel-{rollingNumber}` | Sets the id of the expandable panel. | ## Outputs diff --git a/libs/barista-components/expandable-panel/src/expandable-panel-trigger.ts b/libs/barista-components/expandable-panel/src/expandable-panel-trigger.ts index 0cbf868d67..56527da4ad 100644 --- a/libs/barista-components/expandable-panel/src/expandable-panel-trigger.ts +++ b/libs/barista-components/expandable-panel/src/expandable-panel-trigger.ts @@ -16,7 +16,7 @@ import { DOWN_ARROW, UP_ARROW } from '@angular/cdk/keycodes'; import { ChangeDetectorRef, Directive, Input, OnDestroy } from '@angular/core'; -import { Subscription } from 'rxjs'; +import { Subscription, merge } from 'rxjs'; import { _readKeyCode } from '@dynatrace/barista-components/core'; @@ -33,6 +33,8 @@ import { DtExpandablePanel } from './expandable-panel'; '[attr.disabled]': 'dtExpandablePanel && dtExpandablePanel.disabled ? true: null', '[attr.aria-disabled]': 'dtExpandablePanel && dtExpandablePanel.disabled', + '[attr.aria-expanded]': 'dtExpandablePanel && dtExpandablePanel.expanded', + '[attr.aria-controls]': 'dtExpandablePanel && dtExpandablePanel.id', '[tabindex]': 'dtExpandablePanel && dtExpandablePanel.disabled ? -1 : 0', '(click)': '_handleClick()', '(keydown)': '_handleKeydown($event)', @@ -47,11 +49,12 @@ export class DtExpandablePanelTrigger implements OnDestroy { set dtExpandablePanel(value: DtExpandablePanel) { this._panel = value; this._expandedSubscription.unsubscribe(); - this._expandedSubscription = this.dtExpandablePanel.expandChange.subscribe( - () => { - this._changeDetectorRef.markForCheck(); - }, - ); + this._expandedSubscription = merge( + this.dtExpandablePanel.expandChange, + this.dtExpandablePanel._id, + ).subscribe(() => { + this._changeDetectorRef.markForCheck(); + }); } private _panel: DtExpandablePanel; private _expandedSubscription: Subscription = Subscription.EMPTY; diff --git a/libs/barista-components/expandable-panel/src/expandable-panel.spec.ts b/libs/barista-components/expandable-panel/src/expandable-panel.spec.ts index a98051db65..06b3b802e5 100644 --- a/libs/barista-components/expandable-panel/src/expandable-panel.spec.ts +++ b/libs/barista-components/expandable-panel/src/expandable-panel.spec.ts @@ -19,7 +19,7 @@ // tslint:disable deprecation import { Component, DebugElement } from '@angular/core'; -import { TestBed, async } from '@angular/core/testing'; +import { TestBed, async, ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -121,19 +121,69 @@ describe('DtExpandablePanel', () => { expect(instanceElement.classList).toContain('dt-expandable-panel-opened'); }); - // check CSS class of trigger when expanded - it('should have correctly styled trigger button when expanded', () => { - const panelFixture = createComponent(ExpandablePanelWithTriggerComponent); - const panelDebugElement = panelFixture.debugElement.query( - By.directive(DtExpandablePanel), + // check expanded and expandChange outputs + it('should fire expanded and expandChange events on open', () => { + const expandedSpy = jest.fn(); + const changedSpy = jest.fn(); + const instance = instanceDebugElement.componentInstance; + const expandedSubscription = instance._panelExpanded.subscribe( + expandedSpy, ); - const triggerInstanceElement = panelFixture.debugElement.query( + const changedSubscription = instance.expandChange.subscribe(changedSpy); + + expandablePanelInstance.open(); + fixture.detectChanges(); + expect(expandedSpy).toHaveBeenCalled(); + expect(changedSpy).toHaveBeenCalled(); + + expandedSubscription.unsubscribe(); + changedSubscription.unsubscribe(); + }); + + // check collapsed and expandChange outputs + it('should fire collapsed and expandChange events on close', () => { + expandablePanelInstance.expanded = true; + const collapsedSpy = jest.fn(); + const changedSpy = jest.fn(); + const instance = instanceDebugElement.componentInstance; + const collapsedSubscription = instance._panelCollapsed.subscribe( + collapsedSpy, + ); + const changedSubscription = instance.expandChange.subscribe(changedSpy); + + expandablePanelInstance.close(); + fixture.detectChanges(); + expect(collapsedSpy).toHaveBeenCalled(); + expect(changedSpy).toHaveBeenCalled(); + + collapsedSubscription.unsubscribe(); + changedSubscription.unsubscribe(); + }); + }); + + describe('dt-expandable-panel with trigger', () => { + let fixture: ComponentFixture; + let triggerInstanceElement: HTMLElement; + let panelDebugElement: DebugElement; + let panelInstance: DtExpandablePanel; + let panelInstanceElement: HTMLElement; + + beforeEach(() => { + fixture = createComponent(ExpandablePanelWithTriggerComponent); + triggerInstanceElement = fixture.debugElement.query( By.css('.dt-expandable-panel-trigger'), ).nativeElement; - const panelInstance = panelDebugElement.injector.get( - DtExpandablePanel, + panelInstance = fixture.debugElement.query( + By.directive(DtExpandablePanel), + ).componentInstance; + panelDebugElement = fixture.debugElement.query( + By.directive(DtExpandablePanel), ); + panelInstanceElement = panelDebugElement.nativeElement; + }); + // check CSS class of trigger when expanded + it('should have correctly styled trigger button when expanded', () => { expect(triggerInstanceElement.classList).toContain( 'dt-expandable-panel-trigger', ); @@ -141,7 +191,7 @@ describe('DtExpandablePanel', () => { 'dt-expandable-panel-trigger-open', ); panelInstance.expanded = true; - panelFixture.detectChanges(); + fixture.detectChanges(); expect(triggerInstanceElement.classList).toContain( 'dt-expandable-panel-trigger-open', ); @@ -149,66 +199,72 @@ describe('DtExpandablePanel', () => { // check attributes of panel and trigger when disabled it('should have correct attributes when disabled', () => { - const panelFixture = createComponent(ExpandablePanelWithTriggerComponent); - const panelDebugElement = panelFixture.debugElement.query( - By.directive(DtExpandablePanel), - ); - const triggerInstanceElement = panelFixture.debugElement.query( - By.css('.dt-expandable-panel-trigger'), - ).nativeElement; - const panelInstanceElement = panelDebugElement.nativeElement; - const panelInstance = panelDebugElement.injector.get( - DtExpandablePanel, - ); - expect(panelInstanceElement.getAttribute('aria-disabled')).toBe('false'); expect(triggerInstanceElement.getAttribute('tabindex')).toBe('0'); expect(triggerInstanceElement.getAttribute('disabled')).toBe(null); panelInstance.disabled = true; - panelFixture.detectChanges(); + fixture.detectChanges(); expect(panelInstanceElement.getAttribute('aria-disabled')).toBe('true'); expect(triggerInstanceElement.getAttribute('tabindex')).toBe('-1'); expect(triggerInstanceElement.getAttribute('disabled')).toBe('true'); }); - // check expanded and expandChange outputs - it('should fire expanded and expandChange events on open', () => { - const expandedSpy = jest.fn(); - const changedSpy = jest.fn(); - const instance = instanceDebugElement.componentInstance; - const expandedSubscription = instance._panelExpanded.subscribe( - expandedSpy, + // check aria-controls attribute + it('should have the correct aria-controls attribute', () => { + expect(triggerInstanceElement.getAttribute('aria-controls')).toMatch( + /dt-expandable-panel-\d/, ); - const changedSubscription = instance.expandChange.subscribe(changedSpy); + }); - expandablePanelInstance.open(); + // check aria-controls attribute + it('should have the correct aria-controls attribute when using ID input', () => { + fixture.componentInstance.id = 'my-panel'; fixture.detectChanges(); - expect(expandedSpy).toHaveBeenCalled(); - expect(changedSpy).toHaveBeenCalled(); - expandedSubscription.unsubscribe(); - changedSubscription.unsubscribe(); + triggerInstanceElement = fixture.debugElement.query( + By.css('.dt-expandable-panel-trigger'), + ).nativeElement; + panelInstanceElement = fixture.debugElement.query( + By.css('.dt-expandable-panel'), + ).nativeElement; + + expect(panelInstanceElement.getAttribute('id')).toBe('my-panel'); + expect(triggerInstanceElement.getAttribute('aria-controls')).toBe( + 'my-panel', + ); }); - // check collapsed and expandChange outputs - it('should fire collapsed and expandChange events on close', () => { - expandablePanelInstance.expanded = true; - const collapsedSpy = jest.fn(); - const changedSpy = jest.fn(); - const instance = instanceDebugElement.componentInstance; - const collapsedSubscription = instance._panelCollapsed.subscribe( - collapsedSpy, + // check aria-controls attribute + it('should fall back to the default id when ID is unset', () => { + fixture.componentInstance.id = 'my-panel'; + fixture.detectChanges(); + + fixture.componentInstance.id = null; + fixture.detectChanges(); + + triggerInstanceElement = fixture.debugElement.query( + By.css('.dt-expandable-panel-trigger'), + ).nativeElement; + + expect(triggerInstanceElement.getAttribute('aria-controls')).toMatch( + /dt-expandable-panel-\d/, ); - const changedSubscription = instance.expandChange.subscribe(changedSpy); + }); - expandablePanelInstance.close(); + // check if it has the correct aria-expanded attribute + it('should have the correct aria-expanded attribute', () => { + expect(triggerInstanceElement.getAttribute('aria-expanded')).toBe( + 'false', + ); + }); + + // check if it has the correct aria-expanded attribute is set after expanding + it('should have the correct aria-expanded attribute after opening the expandable', () => { + panelInstance.expanded = true; fixture.detectChanges(); - expect(collapsedSpy).toHaveBeenCalled(); - expect(changedSpy).toHaveBeenCalled(); - collapsedSubscription.unsubscribe(); - changedSubscription.unsubscribe(); + expect(triggerInstanceElement.getAttribute('aria-expanded')).toBe('true'); }); }); }); @@ -224,8 +280,10 @@ class ExpandablePanelComponent {} @Component({ selector: 'dt-test-app', template: ` - text + text `, }) -class ExpandablePanelWithTriggerComponent {} +class ExpandablePanelWithTriggerComponent { + id: string | null; +} diff --git a/libs/barista-components/expandable-panel/src/expandable-panel.ts b/libs/barista-components/expandable-panel/src/expandable-panel.ts index 2c75b8e553..89def8b224 100644 --- a/libs/barista-components/expandable-panel/src/expandable-panel.ts +++ b/libs/barista-components/expandable-panel/src/expandable-panel.ts @@ -32,7 +32,14 @@ import { ViewEncapsulation, } from '@angular/core'; import { filter } from 'rxjs/operators'; -import { Observable } from 'rxjs'; +import { Observable, BehaviorSubject } from 'rxjs'; +import { isDefined } from '@dynatrace/barista-components/core'; + +/** + * UniqueId counter, will be incremented with every + * instatiation of the ExpandablePanel class + */ +let uniqueId = 0; @Component({ selector: 'dt-expandable-panel', @@ -43,6 +50,7 @@ import { Observable } from 'rxjs'; class: 'dt-expandable-panel', '[class.dt-expandable-panel-opened]': 'expanded', '[attr.aria-disabled]': 'disabled', + '[attr.id]': 'id', }, animations: [ trigger('animationState', [ @@ -71,6 +79,20 @@ import { Observable } from 'rxjs'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class DtExpandablePanel { + /** Assigns an id to the expandable panel. */ + @Input() + get id(): string { + return this._id.value; + } + set id(value: string) { + if (isDefined(value)) { + this._id.next(value); + } else { + this._id.next(`dt-expandable-panel-${uniqueId++}`); + } + } + _id = new BehaviorSubject(`dt-expandable-panel-${uniqueId++}`); + /** Whether the panel is expanded. */ @Input() get expanded(): boolean { diff --git a/libs/barista-components/expandable-section/README.md b/libs/barista-components/expandable-section/README.md index fc583f4933..1e5e97edc8 100644 --- a/libs/barista-components/expandable-section/README.md +++ b/libs/barista-components/expandable-section/README.md @@ -30,10 +30,11 @@ To apply the expandable panel, use the `` element. ## Inputs -| Name | Type | Default | Description | -| ---------- | --------- | ------- | ------------------------------------------ | -| `expanded` | `boolean` | `false` | Sets or gets the section's expanded state. | -| `disabled` | `boolean` | `false` | Sets or gets the section's disabled state. | +| Name | Type | Default | Description | +| ---------- | --------- | ---------------------------------------- | -------------------------------------------- | +| `expanded` | `boolean` | `false` | Sets or gets the section's expanded state. | +| `disabled` | `boolean` | `false` | Sets or gets the section's disabled state. | +| `id` | `string` | `dt-expandable-section-{rolling-number}` | Sets a unique id for the expandable section. | In most cases the expandable section is closed by default, but it can also be set to `expanded`. diff --git a/libs/barista-components/expandable-section/src/expandable-section.html b/libs/barista-components/expandable-section/src/expandable-section.html index 76e1dce4c6..099e916c75 100644 --- a/libs/barista-components/expandable-section/src/expandable-section.html +++ b/libs/barista-components/expandable-section/src/expandable-section.html @@ -2,6 +2,8 @@ diff --git a/libs/barista-components/expandable-text/src/expandable-text.spec.ts b/libs/barista-components/expandable-text/src/expandable-text.spec.ts index dcb6f62d27..59a1d3b00b 100644 --- a/libs/barista-components/expandable-text/src/expandable-text.spec.ts +++ b/libs/barista-components/expandable-text/src/expandable-text.spec.ts @@ -156,6 +156,67 @@ describe('dt-expandable-text', () => { collapsedSubscription.unsubscribe(); changedSubscription.unsubscribe(); }); + + // check aria-controls attribute + it('should have the correct aria-controls attribute', () => { + const triggerInstanceElement = fixture.debugElement.query( + By.css('.dt-expandable-text-trigger'), + ).nativeElement; + + expect(triggerInstanceElement.getAttribute('aria-controls')).toMatch( + /dt-expandable-text-\d/, + ); + }); + + it('should have the correct aria-controls attribute when using ID input', () => { + fixture.componentInstance.id = 'my-text'; + fixture.detectChanges(); + + const triggerInstanceElement = fixture.debugElement.query( + By.css('.dt-expandable-text-trigger'), + ).nativeElement; + + expect(triggerInstanceElement.getAttribute('aria-controls')).toBe( + 'my-text', + ); + }); + + it('should fall back to the default id when ID is unset', () => { + fixture.componentInstance.id = 'my-text'; + fixture.detectChanges(); + + fixture.componentInstance.id = null; + fixture.detectChanges(); + + const triggerInstanceElement = fixture.debugElement.query( + By.css('.dt-expandable-text-trigger'), + ).nativeElement; + + expect(triggerInstanceElement.getAttribute('aria-controls')).toMatch( + /dt-expandable-text-\d/, + ); + }); + + // check if it has the correct aria-expanded attribute + it('should have the correct aria-expanded attribute', () => { + const triggerInstanceElement = fixture.debugElement.query( + By.css('.dt-expandable-text-trigger'), + ).nativeElement; + + expect(triggerInstanceElement.getAttribute('aria-expanded')).toBe('false'); + }); + + // check if it has the correct aria-expanded attribute is set after expanding + it('should have the correct aria-expanded attribute after opening the expandable', () => { + const triggerInstanceElement = fixture.debugElement.query( + By.css('.dt-expandable-text-trigger'), + ).nativeElement; + + expandableTextInstance.open(); + fixture.detectChanges(); + + expect(triggerInstanceElement.getAttribute('aria-expanded')).toBe('true'); + }); }); function checkTextVisibility( @@ -174,7 +235,11 @@ function checkTextVisibility( @Component({ selector: 'dt-test-app', template: ` - text + text `, }) -class ExpandableTextComponent {} +class ExpandableTextComponent { + id: string | null; +} diff --git a/libs/barista-components/expandable-text/src/expandable-text.ts b/libs/barista-components/expandable-text/src/expandable-text.ts index ab29be539e..44b1d5f358 100644 --- a/libs/barista-components/expandable-text/src/expandable-text.ts +++ b/libs/barista-components/expandable-text/src/expandable-text.ts @@ -26,6 +26,18 @@ import { } from '@angular/core'; import { filter } from 'rxjs/operators'; import { Observable } from 'rxjs'; +import { HasId, mixinId } from '@dynatrace/barista-components/core'; + +/** + * Boilerplate for mixin extension of ExpandableText + */ +export class DtExpandableTextBase { + constructor() {} +} +export const _ExpandableTextBase = mixinId( + DtExpandableTextBase, + 'dt-expandable-text', +); /** * Provides basic expand/collaps functionality for @@ -40,11 +52,12 @@ import { Observable } from 'rxjs'; class: 'dt-expandable-text', '[class.dt-expandable-text-expanded]': 'expanded', }, + inputs: ['id'], preserveWhitespaces: false, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.Emulated, }) -export class DtExpandableText { +export class DtExpandableText extends _ExpandableTextBase implements HasId { /** Label for the expand button */ @Input() label: string; /** Label for the collapse button */ @@ -80,7 +93,9 @@ export class DtExpandableText { boolean > = this.expandChanged.pipe(filter(v => !v)); - constructor(private _changeDetectorRef: ChangeDetectorRef) {} + constructor(private _changeDetectorRef: ChangeDetectorRef) { + super(); + } /** Toggles the expandable text state */ toggle(): void {