Skip to content

Commit

Permalink
fix(stack-view): improve a11y of stackview component
Browse files Browse the repository at this point in the history
• backport of #6733
• addresses vpat-592

Signed-off-by: Scott Mathis <[email protected]>
  • Loading branch information
Scott Mathis authored and steve-haar committed Mar 7, 2022
1 parent f8006a1 commit 76463e3
Show file tree
Hide file tree
Showing 7 changed files with 209 additions and 33 deletions.
10 changes: 10 additions & 0 deletions golden/clr-angular.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1614,9 +1614,12 @@ export declare class ClrStackBlock implements OnInit {
expandedChange: EventEmitter<boolean>;
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);
Expand Down Expand Up @@ -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();
}
Expand Down
4 changes: 3 additions & 1 deletion projects/angular/src/data/stack-view/all.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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();
});
68 changes: 49 additions & 19 deletions projects/angular/src/data/stack-view/stack-block.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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());
});
});

Expand Down Expand Up @@ -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');
});
}));
});
}
58 changes: 50 additions & 8 deletions projects/angular/src/data/stack-view/stack-block.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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"
>
<cds-icon shape="angle" class="stack-block-caret" *ngIf="expandable" [attr.direction]="caretDirection"></cds-icon>
<span class="clr-sr-only" *ngIf="getChangedValue">{{ commonStrings.keys.stackViewChanged }}</span>
Expand All @@ -38,8 +47,14 @@ import { UNIQUE_ID, UNIQUE_ID_PROVIDER } from '../../utils/id-generator/id-gener
</div>
</div>
<clr-expandable-animation [clrExpandTrigger]="expanded" class="stack-children" [attr.id]="getStackChildrenId()">
<div [style.height]="expanded ? 'auto' : 0" role="region" *ngIf="expanded">
<clr-expandable-animation [clrExpandTrigger]="expanded" class="stack-children">
<div
[style.height]="expanded ? 'auto' : 0"
role="region"
*ngIf="expanded"
[attr.id]="getStackChildrenId()"
[attr.aria-labelledby]="labelledById"
>
<ng-content select="clr-stack-block"></ng-content>
</div>
</clr-expandable-animation>
Expand All @@ -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 {
Expand All @@ -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;
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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: `
<clr-stack-label class="one">Title</clr-stack-label>
<clr-stack-label class="two" id="ohai">Title</clr-stack-label>
`,
})
class TestComponent {}

export default function (): void {
'use strict';
describe('StackView Label', () => {
let fixture: ComponentFixture<any>;
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');
});
});
}
43 changes: 40 additions & 3 deletions projects/angular/src/data/stack-view/stack-view-custom-tags.ts
Original file line number Diff line number Diff line change
@@ -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: '<ng-content></ng-content>',
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 + '';
}
}
}
5 changes: 3 additions & 2 deletions projects/angular/src/data/stack-view/stack-view.module.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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';
Expand All @@ -24,6 +24,7 @@ export const CLR_STACK_VIEW_DIRECTIVES: Type<any>[] = [
ClrStackHeader,
ClrStackBlock,
ClrStackContentInput,
ClrStackViewLabel,
ClrStackViewCustomTags,
/**
* Undocumented experimental feature: inline editing.
Expand Down

0 comments on commit 76463e3

Please sign in to comment.