Skip to content

Commit

Permalink
feat(typeahead): add accessibility support
Browse files Browse the repository at this point in the history
Closes #1321
  • Loading branch information
Thibaut Roy authored and pkozlowski-opensource committed Apr 7, 2017
1 parent 4960533 commit e1fa7a4
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 23 deletions.
28 changes: 27 additions & 1 deletion src/typeahead/typeahead-window.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {TestBed, ComponentFixture} from '@angular/core/testing';
import {createGenericTestComponent} from '../test/common';

import {Component} from '@angular/core';
import {Component, ViewChild} from '@angular/core';

import {NgbTypeaheadWindow} from './typeahead-window';
import {expectResults, getWindowLinks} from '../test/typeahead/common';
Expand Down Expand Up @@ -177,6 +177,30 @@ describe('ngb-typeahead-window', () => {
});
});

describe('accessibility', () => {

function getWindow(element): HTMLDivElement {
return <HTMLDivElement>element.querySelector('ngb-typeahead-window.dropdown-menu');
}

it('should add correct ARIA attributes', () => {
const fixture = createTestComponent(
'<ngb-typeahead-window id="test-typeahead" [results]="results" [term]="term"></ngb-typeahead-window>');
const compiled = fixture.nativeElement.querySelector('ngb-typeahead-window.dropdown-menu');

expect(compiled.getAttribute('role')).toBe('listbox');
expect(compiled.getAttribute('id')).toBe('test-typeahead');

const buttons = fixture.nativeElement.querySelectorAll('button');
expect(buttons.length).toBeGreaterThan(0);
for (let i = 0; i < buttons.length; i++) {
expect(buttons[i].getAttribute('id')).toBe('test-typeahead-' + i);
expect(buttons[i].getAttribute('role')).toBe('option');
}
});

});

});

@Component({selector: 'test-cmp', template: ''})
Expand All @@ -186,5 +210,7 @@ class TestComponent {
term = 'ba';
selected: string;

@ViewChild(NgbTypeaheadWindow) popup: NgbTypeaheadWindow;

formatterFn = (result) => { return result.toUpperCase(); };
}
40 changes: 33 additions & 7 deletions src/typeahead/typeahead-window.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Component, Input, Output, EventEmitter, TemplateRef, OnInit} from '@angular/core';
import {Component, Input, Output, EventEmitter, TemplateRef, HostBinding, OnInit} from '@angular/core';

import {toString} from '../util/util';

Expand All @@ -20,16 +20,18 @@ export interface ResultTemplateContext {
@Component({
selector: 'ngb-typeahead-window',
exportAs: 'ngbTypeaheadWindow',
host: {'class': 'dropdown-menu', 'style': 'display: block'},
host: {'class': 'dropdown-menu', 'style': 'display: block', 'role': 'listbox', '[id]': 'id'},
template: `
<template #rt let-result="result" let-term="term" let-formatter="formatter">
<ngb-highlight [result]="formatter(result)" [term]="term"></ngb-highlight>
</template>
<template ngFor [ngForOf]="results" let-result let-idx="index">
<button type="button" class="dropdown-item" [class.active]="idx === activeIdx"
(mouseenter)="markActive(idx)"
<button type="button" class="dropdown-item" role="option"
[id]="id + '-' + idx"
[class.active]="idx === activeIdx"
(mouseenter)="markActive(idx)"
(click)="select(result)">
<template [ngTemplateOutlet]="resultTemplate || rt"
<template [ngTemplateOutlet]="resultTemplate || rt"
[ngOutletContext]="{result: result, term: term, formatter: formatter}"></template>
</button>
</template>
Expand All @@ -38,6 +40,12 @@ export interface ResultTemplateContext {
export class NgbTypeaheadWindow implements OnInit {
activeIdx = 0;

/**
* An optional id for the typeahead. The id should be unique.
* If not provided, it will be auto-generated.
*/
@Input() id: string;

/**
* Flag indicating if the first row should be active initially
*/
Expand Down Expand Up @@ -69,16 +77,22 @@ export class NgbTypeaheadWindow implements OnInit {
*/
@Output('select') selectEvent = new EventEmitter();

@Output('activeChanged') activeChangedEvent = new EventEmitter();

getActive() { return this.results[this.activeIdx]; }

markActive(activeIdx: number) { this.activeIdx = activeIdx; }
markActive(activeIdx: number) {
this.activeIdx = activeIdx;
this._activeChanged();
}

next() {
if (this.activeIdx === this.results.length - 1) {
this.activeIdx = this.focusFirst ? (this.activeIdx + 1) % this.results.length : -1;
} else {
this.activeIdx++;
}
this._activeChanged();
}

prev() {
Expand All @@ -89,9 +103,21 @@ export class NgbTypeaheadWindow implements OnInit {
} else {
this.activeIdx--;
}
this._activeChanged();
}

select(item) { this.selectEvent.emit(item); }

ngOnInit() { this.activeIdx = this.focusFirst ? 0 : -1; }
ngOnInit() {
this.activeIdx = this.focusFirst ? 0 : -1;
this._activeChanged();
}

private _activeChanged() {
if (this.activeIdx >= 0) {
this.activeChangedEvent.emit(this.id + '-' + this.activeIdx);
} else {
this.activeChangedEvent.emit(undefined);
}
}
}
49 changes: 49 additions & 0 deletions src/typeahead/typeahead.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ describe('ngb-typeahead', () => {
const fixture = createTestComponent(`<input type="text" [ngbTypeahead]="find"/>`);
const compiled = fixture.nativeElement;
expect(getWindow(compiled)).toBeNull();
expect(fixture.componentInstance.typeahead.isOpen).toBeFalsy();
});

it('should not be opened when the model changes', async(() => {
Expand All @@ -139,6 +140,7 @@ describe('ngb-typeahead', () => {
fixture.componentInstance.model = 'one';
fixture.detectChanges();
fixture.whenStable().then(() => { expect(getWindow(compiled)).toBeNull(); });
expect(fixture.componentInstance.typeahead.isOpen).toBeFalsy();
}));

it('should be opened when there are results', async(() => {
Expand All @@ -150,6 +152,7 @@ describe('ngb-typeahead', () => {
fixture.detectChanges();
expectWindowResults(compiled, ['+one', 'one more']);
expect(fixture.componentInstance.model).toBe('one');
expect(fixture.componentInstance.typeahead.isOpen).toBeTruthy();
});
}));

Expand All @@ -158,6 +161,7 @@ describe('ngb-typeahead', () => {
const compiled = fixture.nativeElement;

expect(getWindow(compiled)).toBeNull();
expect(fixture.componentInstance.typeahead.isOpen).toBeFalsy();
});

it('should be closed on document click', () => {
Expand All @@ -167,9 +171,11 @@ describe('ngb-typeahead', () => {
changeInput(compiled, 'one');
fixture.detectChanges();
expect(getWindow(compiled)).not.toBeNull();
expect(fixture.componentInstance.typeahead.isOpen).toBeTruthy();

fixture.nativeElement.click();
expect(getWindow(compiled)).toBeNull();
expect(fixture.componentInstance.typeahead.isOpen).toBeFalsy();
});

it('should be closed when ESC is pressed', () => {
Expand All @@ -179,11 +185,13 @@ describe('ngb-typeahead', () => {
changeInput(compiled, 'one');
fixture.detectChanges();
expect(getWindow(compiled)).not.toBeNull();
expect(fixture.componentInstance.typeahead.isOpen).toBeTruthy();

const event = createKeyDownEvent(Key.Escape);
getDebugInput(fixture.debugElement).triggerEventHandler('keydown', event);
fixture.detectChanges();
expect(getWindow(compiled)).toBeNull();
expect(fixture.componentInstance.typeahead.isOpen).toBeFalsy();
expect(event.preventDefault).toHaveBeenCalled();
});

Expand Down Expand Up @@ -577,6 +585,47 @@ describe('ngb-typeahead', () => {
});
});

describe('accessibility', () => {

it('should have correct role, aria-autocomplete, aria-expanded by default', () => {
const fixture = createTestComponent('<input type="text" [ngbTypeahead]="findObjects" />');
const input = getNativeInput(fixture.nativeElement);

fixture.componentInstance.typeahead.popupId = 'test-typeahead';
fixture.detectChanges();

expect(input.getAttribute('role')).toBe('combobox');
expect(input.getAttribute('aria-autocomplete')).toBe('list');
expect(input.getAttribute('aria-expanded')).toBe('false');
expect(input.getAttribute('aria-owns')).toBe('test-typeahead');
});

it('should have the correct ARIA attributes when interacting with input', async(() => {
const fixture = createTestComponent(`<input type="text" [(ngModel)]="model" [ngbTypeahead]="find"/>`);
const compiled = fixture.nativeElement;
const input = getNativeInput(compiled);
fixture.componentInstance.typeahead.popupId = 'test-typeahead';
fixture.detectChanges();
expect(input.getAttribute('aria-owns')).toBe('test-typeahead');
expect(input.getAttribute('aria-expanded')).toBe('false');
expect(input.getAttribute('aria-activedescendant')).toBeFalsy();

fixture.whenStable().then(() => {
changeInput(compiled, 'o');
fixture.detectChanges();
expectWindowResults(compiled, ['+one', 'one more']);
expect(input.getAttribute('aria-expanded')).toBe('true');
expect(input.getAttribute('aria-activedescendant')).toBe('test-typeahead-0');

const event = createKeyDownEvent(Key.ArrowDown);
getDebugInput(fixture.debugElement).triggerEventHandler('keydown', event);
fixture.detectChanges();

expect(input.getAttribute('aria-activedescendant')).toBe('test-typeahead-1');
});
}));
});

if (!isBrowser(['ie', 'edge'])) {
describe('hint', () => {

Expand Down
53 changes: 38 additions & 15 deletions src/typeahead/typeahead.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import {
ComponentFactoryResolver,
ComponentRef,
Directive,
OnInit,
Input,
Output,
ElementRef,
EventEmitter,
ComponentRef,
ComponentFactoryResolver,
ViewContainerRef,
forwardRef,
HostBinding,
Injector,
Input,
NgZone,
OnDestroy,
OnInit,
Output,
Renderer,
ElementRef,
TemplateRef,
forwardRef,
OnDestroy,
NgZone
ViewContainerRef
} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {Observable} from 'rxjs/Observable';
Expand Down Expand Up @@ -56,6 +57,8 @@ export interface NgbTypeaheadSelectItemEvent {
preventDefault: () => void;
}

let nextId = 0;

/**
* NgbTypeahead directive provides a simple way of creating powerful typeaheads from any text input
*/
Expand All @@ -68,7 +71,12 @@ export interface NgbTypeaheadSelectItemEvent {
'(keydown)': 'handleKeyDown($event)',
'autocomplete': 'off',
'autocapitalize': 'off',
'autocorrect': 'off'
'autocorrect': 'off',
'role': 'combobox',
'aria-autocomplete': 'list',
'[attr.aria-activedescendant]': 'activeDescendant',
'[attr.aria-owns]': 'popupId',
'[attr.aria-expanded]': 'isOpen'
},
providers: [NGB_TYPEAHEAD_VALUE_ACCESSOR]
})
Expand Down Expand Up @@ -124,6 +132,10 @@ export class NgbTypeahead implements ControlValueAccessor,
*/
@Output() selectItem = new EventEmitter<NgbTypeaheadSelectItemEvent>();

activeDescendant: string;
popupId = `ngb-typeahead-${nextId++}`;
isOpen = false;

private _onTouched = () => {};
private _onChange = (_: any) => {};

Expand All @@ -141,7 +153,7 @@ export class NgbTypeahead implements ControlValueAccessor,
NgbTypeaheadWindow, _injector, _viewContainerRef, _renderer, componentFactoryResolver);

this._zoneSubscription = ngZone.onStable.subscribe(() => {
if (this._windowRef) {
if (this.isOpen) {
positionElements(this._elementRef.nativeElement, this._windowRef.location.nativeElement, 'bottom-left');
}
});
Expand Down Expand Up @@ -185,12 +197,12 @@ export class NgbTypeahead implements ControlValueAccessor,
}
}

isPopupOpen() { return this._windowRef != null; }
isPopupOpen() { return this.isOpen; }

handleBlur() { this._onTouched(); }

handleKeyDown(event: KeyboardEvent) {
if (!this._windowRef) {
if (!this.isOpen) {
return;
}

Expand Down Expand Up @@ -225,15 +237,26 @@ export class NgbTypeahead implements ControlValueAccessor,
}

private _openPopup() {
if (!this._windowRef) {
if (!this.isOpen) {
this._windowRef = this._popupService.open();

if (!this._windowRef.instance.id) {
this._windowRef.instance.id = this.popupId;
} else {
this.popupId = this._windowRef.instance.id;
}

this._windowRef.instance.selectEvent.subscribe((result: any) => this._selectResultClosePopup(result));
this._windowRef.instance.activeChangedEvent.subscribe((activeId: string) => this.activeDescendant = activeId);
this.isOpen = true;
}
}

private _closePopup() {
this._popupService.close();
this._windowRef = null;
this.isOpen = false;
this.activeDescendant = undefined;
}

private _selectResult(result: any) {
Expand Down

0 comments on commit e1fa7a4

Please sign in to comment.