Skip to content

Commit

Permalink
fix(docs-infra): improve accessibility of aio-select component
Browse files Browse the repository at this point in the history
improve the accessibility of the aio-select component so that it is
clear for screen reader users its functionality (currently it is
presented as a simple button), following the WAI-ARIA authoring
practices (see: https://www.w3.org/TR/wai-aria-practices/#combobox)

A first attempt in improving the accessibility of the component has been
tried in PR angular#45937 by replacing it with the material select component,
such implementation has however been scrapped since the increase of
payload sizes has proven prohibitively large

(also note that given native select elements haven't been used given the lack
of syling options for such elements)
  • Loading branch information
dario-piotrowicz committed May 15, 2022
1 parent 3e873b5 commit 0852228
Show file tree
Hide file tree
Showing 4 changed files with 68 additions and 21 deletions.
31 changes: 22 additions & 9 deletions aio/src/app/shared/select/select.component.html
@@ -1,16 +1,29 @@
<div class="form-select-menu">
<button class="form-select-button" (click)="toggleOptions()" [disabled]="disabled">
<span><strong>{{label}}</strong></span><span *ngIf="showSymbol" class="symbol {{selected?.value}}"></span><span>{{selected?.title}}</span>
<button class="form-select-button"
role="combobox"
[attr.aria-owns]="listBoxId"
aria-haspopup="listbox"
(click)="toggleOptions()"
(keydown)="handleKeydown($event)"
[disabled]="disabled"
[attr.aria-activedescendant]="currentOptionIdx > -1 && showOptions ? listBoxId + '-option-' + currentOptionIdx : null"
>
<ng-container *ngTemplateOutlet="optionTemplate; context: { showLabel: true, value: selected?.value, title: selected?.title }"></ng-container>
</button>
<ul class="form-select-dropdown" *ngIf="showOptions">
<ul class="form-select-dropdown" *ngIf="showOptions" [id]="listBoxId" role="listbox" tabIndex="-1" #listBox aria-expanded="true">
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -- the key events are handled in the ts class -->
<li *ngFor="let option of options; index as i"
role="option"
[class.selected]="option === selected"
role="button"
tabindex="0"
(click)="select(option, i)"
(keydown.enter)="select(option, i)"
(keydown.space)="select(option, i); $event.preventDefault()">
<span *ngIf="showSymbol" class="symbol {{option.value}}"></span><span>{{option.title}}</span>
[attr.aria-selected]="option === selected"
[class.current]="currentOptionIdx === i"
[attr.aria-activedescendant]="currentOptionIdx === i"
[id]="listBoxId + '-option-' + i"
(click)="select(i)">
<ng-container *ngTemplateOutlet="optionTemplate; context: { showLabel: false, value: option.value, title: option.title }"></ng-container>
</li>
</ul>
</div>
<ng-template #optionTemplate let-showLabel="showLabel" let-value="value" let-title="title">
<span *ngIf="showLabel"><strong>{{label}}</strong></span><span *ngIf="showSymbol" class="symbol {{value}}"></span><span>{{title}}</span>
</ng-template>
14 changes: 8 additions & 6 deletions aio/src/app/shared/select/select.component.spec.ts
Expand Up @@ -112,17 +112,19 @@ describe('SelectComponent', () => {
});

it('should select the current option when enter is pressed', () => {
const e = new KeyboardEvent('keydown', {bubbles: true, cancelable: true, key: 'Enter'});
getOptions()[0].dispatchEvent(e);
element.componentInstance.currentOptionIdx = 0;
const debugBtnElement = fixture.debugElement.query(By.css('.form-select-button'));
debugBtnElement.triggerEventHandler('keydown', { bubbles: true, cancelable: true, key: ' ', preventDefault(){} });
fixture.detectChanges();
expect(host.onChange).toHaveBeenCalledWith({ option: options[0], index: 0 });
expect(getButton().textContent).toContain(options[0].title);
expect(getButtonSymbol()?.className).toContain(options[0].value);
});

it('should select the current option when space is pressed', () => {
const e = new KeyboardEvent('keydown', {bubbles: true, cancelable: true, key: ' '});
getOptions()[0].dispatchEvent(e);
element.componentInstance.currentOptionIdx = 0;
const debugBtnElement = fixture.debugElement.query(By.css('.form-select-button'));
debugBtnElement.triggerEventHandler('keydown', { bubbles: true, cancelable: true, key: ' ', preventDefault(){} });
fixture.detectChanges();
expect(host.onChange).toHaveBeenCalledWith({ option: options[0], index: 0 });
expect(getButton().textContent).toContain(options[0].title);
Expand All @@ -142,8 +144,8 @@ describe('SelectComponent', () => {
});

it('should hide if the escape button is pressed', () => {
const e = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' });
document.dispatchEvent(e);
const debugBtnElement = fixture.debugElement.query(By.css('.form-select-button'));
debugBtnElement.triggerEventHandler('keydown', { bubbles: true, cancelable: true, key: 'Escape', preventDefault(){} });
fixture.detectChanges();
expect(getOptionContainer()).toEqual(null);
});
Expand Down
42 changes: 37 additions & 5 deletions aio/src/app/shared/select/select.component.ts
@@ -1,4 +1,4 @@
import { Component, ElementRef, EventEmitter, HostListener, Input, Output, OnInit } from '@angular/core';
import { Component, ElementRef, EventEmitter, HostListener, Input, Output, OnInit, ViewChild } from '@angular/core';

export interface Option {
title: string;
Expand All @@ -23,8 +23,14 @@ export class SelectComponent implements OnInit {

@Input() disabled: boolean;

@ViewChild('listBox', { read: ElementRef }) listBox: ElementRef;

showOptions = false;

listBoxId = `aio-select-listbox-${Math.floor(Math.random() * 10000)}`;

currentOptionIdx = 0;

constructor(private hostElement: ElementRef) {}

ngOnInit() {
Expand All @@ -39,7 +45,8 @@ export class SelectComponent implements OnInit {
this.showOptions = false;
}

select(option: Option, index: number) {
select(index: number) {
const option = this.options[index];
this.selected = option;
this.change.emit({option, index});
this.hideOptions();
Expand All @@ -53,8 +60,33 @@ export class SelectComponent implements OnInit {
}
}

@HostListener('document:keydown.escape')
onKeyDown() {
this.hideOptions();
handleKeydown(event: KeyboardEvent) {
const runOrOpenOptions = (fn: () => void): void => {
if(!this.showOptions) {
this.showOptions = true;
} else {
fn();
}
}
switch(event.key) {
case 'ArrowDown':
runOrOpenOptions(() => this.currentOptionIdx = Math.min(this.currentOptionIdx + 1, (this.options?.length ?? 0) - 1));
break;
case 'ArrowUp':
runOrOpenOptions(() => this.currentOptionIdx = Math.max(this.currentOptionIdx - 1, 0));
break;
case 'Escape':
case 'Tab':
this.hideOptions();
break;
case 'Enter':
case 'Space':
case ' ':
runOrOpenOptions(() => this.select(this.currentOptionIdx));
break;
}
if(event.code !== 'Tab') {
event.preventDefault();
}
}
}
Expand Up @@ -28,7 +28,7 @@
box-shadow: 0 16px 16px rgba(constants.$black, 0.24), 0 0 16px rgba(constants.$black, 0.12);

li {
&:hover {
&:hover, &.current {
background-color: if($is-dark-theme, constants.$darkgray, constants.$blue-grey-50);
color: f($is-dark-theme, constants.$blue-grey-400, constants.$blue-grey-500);
}
Expand Down

0 comments on commit 0852228

Please sign in to comment.