diff --git a/golden/clr-angular.d.ts b/golden/clr-angular.d.ts index b7b0b841e2..4ea4ca3c4c 100644 --- a/golden/clr-angular.d.ts +++ b/golden/clr-angular.d.ts @@ -1614,9 +1614,12 @@ export declare class ClrStackBlock implements OnInit { expandedChange: EventEmitter; focused: boolean; get getChangedValue(): boolean; + get headingLevel(): string; + get labelledById(): any; get onStackLabelFocus(): boolean; get role(): string; set setChangedValue(value: boolean); + stackBlockTitle: any; get tabIndex(): string; uniqueId: string; constructor(parent: ClrStackBlock, uniqueId: string, commonStrings: ClrCommonStringsService); @@ -1658,6 +1661,13 @@ export declare class ClrStackView { export declare class ClrStackViewCustomTags { } +export declare class ClrStackViewLabel implements OnInit { + set id(val: string); + get id(): string; + constructor(uniqueId: string); + ngOnInit(): void; +} + export declare class ClrStackViewModule { constructor(); } diff --git a/projects/angular/src/data/stack-view/all.spec.ts b/projects/angular/src/data/stack-view/all.spec.ts index aa6ebce111..0bc05cb72c 100644 --- a/projects/angular/src/data/stack-view/all.spec.ts +++ b/projects/angular/src/data/stack-view/all.spec.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved. * This software is released under MIT license. * The full license information can be found in LICENSE in the root directory of this project. */ @@ -17,10 +17,12 @@ import StackBlockSpecs from './stack-block.spec'; import StackHeaderSpecs from './stack-header.spec'; import StackViewSpecs from './stack-view.spec'; import StackContentInputSpecs from './stack-content-input.spec'; +import StackViewCustomTagSpecs from './stack-view-custom-tags.spec'; describe('Stack View directives', () => { StackViewSpecs(); StackHeaderSpecs(); StackContentInputSpecs(); StackBlockSpecs(); + StackViewCustomTagSpecs(); }); diff --git a/projects/angular/src/data/stack-view/stack-block.spec.ts b/projects/angular/src/data/stack-view/stack-block.spec.ts index b6237e67da..be22402e46 100644 --- a/projects/angular/src/data/stack-view/stack-block.spec.ts +++ b/projects/angular/src/data/stack-view/stack-block.spec.ts @@ -1,10 +1,10 @@ /* - * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved. * This software is released under MIT license. * The full license information can be found in LICENSE in the root directory of this project. */ import { Component, ViewChild } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -109,26 +109,10 @@ export default function (): void { } describe('Accessibility', () => { - let label: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(BasicBlock); fixture.componentInstance.ariaLevel = 42; - fixture.componentInstance.ariaPosinset = 32; - fixture.componentInstance.ariaSetsize = 100; fixture.detectChanges(); - label = fixture.nativeElement.querySelector('.stack-block-label'); - }); - - it('should attach aria-level to dd.stack-block-content', () => { - expect(label.getAttribute('aria-level')).toBe(fixture.componentInstance.ariaLevel.toString()); - }); - - it('should attach aria-posinset to dd.stack-block-content', () => { - expect(label.getAttribute('aria-posinset')).toBe(fixture.componentInstance.ariaPosinset.toString()); - }); - - it('should attach aria-setsize to dd.stack-block-content', () => { - expect(label.getAttribute('aria-setsize')).toBe(fixture.componentInstance.ariaSetsize.toString()); }); }); @@ -338,8 +322,54 @@ export default function (): void { fixture.detectChanges(); const controlsId = stackLabel.getAttribute('aria-controls'); expect(controlsId).not.toBeNull(); - const childrenId = fixture.nativeElement.querySelector('.stack-children').getAttribute('id'); + const childrenId = fixture.nativeElement.querySelector('.stack-children > [role="region"]').getAttribute('id'); expect(childrenId).toBe(controlsId); }); + + it('expandable child block is aria-labelledby the stack block title', () => { + fixture = TestBed.createComponent(DynamicBlock); + fixture.detectChanges(); + const component: ClrStackBlock = fixture.componentInstance; + component.expanded = true; + fixture.detectChanges(); + const stackLabel = fixture.nativeElement.querySelector('clr-stack-label'); + const stackLabelId = stackLabel.getAttribute('id'); + expect(stackLabelId).not.toBeNull(); + const blockLabelledBy = fixture.nativeElement + .querySelector('.stack-children > [role="region"]') + .getAttribute('aria-labelledby'); + expect(blockLabelledBy).not.toBeNull(); + expect(blockLabelledBy).toBe(stackLabelId); + + // but still works if id is removed + stackLabel.setAttribute('id', null); + fixture.detectChanges(); + const defaultStackLabelId = stackLabel.getAttribute('id'); + expect(defaultStackLabelId).not.toBeNull(); + const defaultBlockLabelledBy = fixture.nativeElement + .querySelector('.stack-children > [role="region"]') + .getAttribute('aria-labelledby'); + expect(defaultBlockLabelledBy).not.toBeNull(); + expect(defaultBlockLabelledBy).toBe(stackLabelId); + }); + + it('should have expected heading roles and aria heading levels', fakeAsync(() => { + fixture = TestBed.createComponent(NestedBlocks); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.blockInstance.expanded = true; + tick(); + fixture.detectChanges(); + + const topLevelBlock = fixture.nativeElement.querySelector('.stack-block-expandable'); + expect(topLevelBlock.getAttribute('role')).toBe('heading'); + expect(topLevelBlock.getAttribute('aria-level')).toBe('3'); + + const childBlocks = fixture.nativeElement.querySelectorAll('.stack-children .stack-block'); + childBlocks.forEach(blok => { + expect(blok.getAttribute('role')).toBe('heading'); + expect(blok.getAttribute('aria-level')).toBe('4'); + }); + })); }); } diff --git a/projects/angular/src/data/stack-view/stack-block.ts b/projects/angular/src/data/stack-view/stack-block.ts index 6d226cd7a5..6dfd6800ab 100644 --- a/projects/angular/src/data/stack-view/stack-block.ts +++ b/projects/angular/src/data/stack-view/stack-block.ts @@ -1,11 +1,23 @@ /* - * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved. * This software is released under MIT license. * The full license information can be found in LICENSE in the root directory of this project. */ -import { Component, EventEmitter, HostBinding, Inject, Input, OnInit, Optional, Output, SkipSelf } from '@angular/core'; +import { + Component, + ContentChild, + EventEmitter, + HostBinding, + Inject, + Input, + OnInit, + Optional, + Output, + SkipSelf, +} from '@angular/core'; import { ClrCommonStringsService } from '../../utils/i18n/common-strings.service'; import { UNIQUE_ID, UNIQUE_ID_PROVIDER } from '../../utils/id-generator/id-generator.service'; +import { ClrStackViewLabel } from './stack-view-custom-tags'; @Component({ selector: 'clr-stack-block', @@ -22,9 +34,6 @@ import { UNIQUE_ID, UNIQUE_ID_PROVIDER } from '../../utils/id-generator/id-gener [attr.tabindex]="tabIndex" [attr.aria-expanded]="ariaExpanded" [attr.aria-controls]="getStackChildrenId()" - [attr.aria-posinset]="ariaPosinset" - [attr.aria-level]="ariaLevel" - [attr.aria-setsize]="ariaSetsize" > {{ commonStrings.keys.stackViewChanged }} @@ -38,8 +47,14 @@ import { UNIQUE_ID, UNIQUE_ID_PROVIDER } from '../../utils/id-generator/id-gener - -
+ +
@@ -53,7 +68,11 @@ import { UNIQUE_ID, UNIQUE_ID_PROVIDER } from '../../utils/id-generator/id-gener `, ], // Make sure the host has the proper class for styling purposes - host: { '[class.stack-block]': 'true' }, + host: { + '[class.stack-block]': 'true', + '[attr.role]': '"heading"', + '[attr.aria-level]': 'headingLevel', + }, providers: [UNIQUE_ID_PROVIDER], }) export class ClrStackBlock implements OnInit { @@ -65,6 +84,9 @@ export class ClrStackBlock implements OnInit { @Input('clrSbExpandable') expandable = false; + @ContentChild(ClrStackViewLabel) + stackBlockTitle: any; + focused = false; private _changedChildren = 0; private _fullyInitialized = false; @@ -88,18 +110,38 @@ export class ClrStackBlock implements OnInit { } } + get labelledById() { + return this.stackBlockTitle.id; + } + + get headingLevel() { + if (this.ariaLevel) { + return this.ariaLevel + ''; + } + + return this.parent ? '4' : '3'; + } + /** * Depth of the stack view starting from 1 for first level */ @Input('clrStackViewLevel') ariaLevel: number; /** + * @deprecated * Total number of rows in a given group + * - removed per a11y (see: VPAT-592) + * - remains here and unused to avoid breaking change to the public API + * - remove in v14 */ @Input('clrStackViewSetsize') ariaSetsize: number; /** + * @deprecated * The position of the row inside the grouped by level rows + * - removed per a11y (see: VPAT-592) + * - remains here and unused to avoid breaking change to the public API + * - remove in v14 */ @Input('clrStackViewPosinset') ariaPosinset: number; diff --git a/projects/angular/src/data/stack-view/stack-view-custom-tags.spec.ts b/projects/angular/src/data/stack-view/stack-view-custom-tags.spec.ts new file mode 100644 index 0000000000..b88c960083 --- /dev/null +++ b/projects/angular/src/data/stack-view/stack-view-custom-tags.spec.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ClrStackViewModule } from './stack-view.module'; + +@Component({ + template: ` + Title + Title + `, +}) +class TestComponent {} + +export default function (): void { + 'use strict'; + describe('StackView Label', () => { + let fixture: ComponentFixture; + let compiled: any; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ClrStackViewModule], + declarations: [TestComponent], + }); + fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + compiled = fixture.nativeElement; + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('projects content', () => { + expect(compiled.textContent).toMatch(/Title/); + }); + + it('auto assigns an id if none is given', () => { + const testme = compiled.querySelector('clr-stack-label.one'); + expect(testme.hasAttribute('id')).toBe(true); + expect(testme.getAttribute('id').indexOf('clr-stack-label-clr-id-') > -1).toBe(true); + }); + + it('keeps the id if the stack-view-label already has one', () => { + const testme = compiled.querySelector('clr-stack-label.two'); + expect(testme.hasAttribute('id')).toBe(true); + expect(testme.getAttribute('id')).toBe('ohai'); + }); + }); +} diff --git a/projects/angular/src/data/stack-view/stack-view-custom-tags.ts b/projects/angular/src/data/stack-view/stack-view-custom-tags.ts index fc554bacb3..5bdb0a4eb9 100644 --- a/projects/angular/src/data/stack-view/stack-view-custom-tags.ts +++ b/projects/angular/src/data/stack-view/stack-view-custom-tags.ts @@ -1,12 +1,49 @@ /* - * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved. * This software is released under MIT license. * The full license information can be found in LICENSE in the root directory of this project. */ -import { Directive } from '@angular/core'; +import { Directive, Component, Inject, Input, OnInit } from '@angular/core'; +import { UNIQUE_ID, UNIQUE_ID_PROVIDER } from '../../utils/id-generator/id-generator.service'; -@Directive({ selector: 'clr-stack-label, clr-stack-content' }) +@Directive({ selector: 'clr-stack-content' }) export class ClrStackViewCustomTags { // No behavior // The only purpose is to "declare" the tag in Angular } + +@Component({ + selector: 'clr-stack-label', + template: '', + providers: [UNIQUE_ID_PROVIDER], + host: { + '[attr.id]': 'id', + }, +}) +export class ClrStackViewLabel implements OnInit { + constructor(@Inject(UNIQUE_ID) private uniqueId: string) {} + + private _generatedId: string = null; + + private _id: string = null; + + @Input() + set id(val: string) { + if (typeof val === 'string' && val !== '') { + this._id = val; + } else { + this._id = this._generatedId + ''; + } + } + get id() { + return this._id; + } + + ngOnInit() { + this._generatedId = 'clr-stack-label-' + this.uniqueId; + + if (!this.id) { + this._id = this._generatedId + ''; + } + } +} diff --git a/projects/angular/src/data/stack-view/stack-view.module.ts b/projects/angular/src/data/stack-view/stack-view.module.ts index 9d2a58526c..df4d2258e6 100644 --- a/projects/angular/src/data/stack-view/stack-view.module.ts +++ b/projects/angular/src/data/stack-view/stack-view.module.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016-2021 VMware, Inc. All Rights Reserved. + * Copyright (c) 2016-2022 VMware, Inc. All Rights Reserved. * This software is released under MIT license. * The full license information can be found in LICENSE in the root directory of this project. */ @@ -13,7 +13,7 @@ import { ClrStackHeader } from './stack-header'; import { ClrStackInput } from './stack-input'; import { ClrStackSelect } from './stack-select'; import { ClrStackView } from './stack-view'; -import { ClrStackViewCustomTags } from './stack-view-custom-tags'; +import { ClrStackViewCustomTags, ClrStackViewLabel } from './stack-view-custom-tags'; import { ClrIconModule } from '../../icon/icon.module'; import { ClrExpandableAnimationModule } from '../../utils/animations/expandable-animation/expandable-animation.module'; import { ClrStackContentInput } from './stack-content-input'; @@ -24,6 +24,7 @@ export const CLR_STACK_VIEW_DIRECTIVES: Type[] = [ ClrStackHeader, ClrStackBlock, ClrStackContentInput, + ClrStackViewLabel, ClrStackViewCustomTags, /** * Undocumented experimental feature: inline editing.