Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(typeahead): use inline style for live element and localize message #4605

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import '@angular/localize/init';

import { NgModule } from '@angular/core';

import { NgbAccordionModule } from './accordion/accordion.module';
Expand Down
48 changes: 46 additions & 2 deletions src/typeahead/typeahead.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { debounceTime, filter, map } from 'rxjs/operators';

import { createGenericTestComponent, triggerEvent } from '../test/common';
import { expectResults, getWindowLinks } from '../test/typeahead/common';
import { ARIA_LIVE_DELAY } from '../util/accessibility/live';
import { ARIA_LIVE_DELAY, Live } from '../util/accessibility/live';
import { NgbTypeahead } from './typeahead';
import { NgbTypeaheadConfig } from './typeahead-config';
import { NgbHighlight } from './highlight';
Expand Down Expand Up @@ -63,9 +63,18 @@ function expectWindowResults(element, expectedResults: string[]) {
}

describe('ngb-typeahead', () => {
let mockLiveService: Partial<Live>;

beforeEach(() => {
mockLiveService = {
say: createSpy('say'),
};

TestBed.configureTestingModule({
providers: [{ provide: ARIA_LIVE_DELAY, useValue: null }],
providers: [
{ provide: ARIA_LIVE_DELAY, useValue: null },
{ provide: Live, useValue: mockLiveService },
],
});
});

Expand Down Expand Up @@ -838,6 +847,41 @@ describe('ngb-typeahead', () => {
expect(input.getAttribute('aria-owns')).toBeNull();
expect(input.getAttribute('aria-activedescendant')).toBeNull();
}));

describe('live', () => {
let fixture: ComponentFixture<TestComponent>;
let compiled: any;

beforeEach(() => {
fixture = createTestComponent(`<input type="text" [(ngModel)]="model" [ngbTypeahead]="find"/>`);
compiled = fixture.nativeElement;
});

it('should call the method with the correct message when there is more than one result', fakeAsync(() => {
tick();
changeInput(compiled, 'o');
fixture.detectChanges();

expectWindowResults(compiled, ['+one', 'one more']);
expect(mockLiveService.say).toHaveBeenCalledWith('2 results available');
}));

it('should call the method with the correct message when there is not any result available', fakeAsync(() => {
tick();
changeInput(compiled, 'a');
fixture.detectChanges();

expect(mockLiveService.say).toHaveBeenCalledWith('No results available');
}));

it('should call the method with the correct message when there is one single result available', fakeAsync(() => {
tick();
changeInput(compiled, 'one more');
fixture.detectChanges();

expect(mockLiveService.say).toHaveBeenCalledWith('1 result available');
}));
});
});

describe('hint', () => {
Expand Down
23 changes: 20 additions & 3 deletions src/typeahead/typeahead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { DOCUMENT } from '@angular/common';
import { BehaviorSubject, fromEvent, of, OperatorFunction, Subject, Subscription } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators';

import { Live } from '../util/accessibility/live';
import { Live, LIVE_CONTAINER } from '../util/accessibility/live';
import { getShadowRootContainerIfAny } from '../util/accessibility/live.helper';
import { ngbAutoClose } from '../util/autoclose';
import { PopupService } from '../util/popup';
import { ngbPositioning } from '../util/positioning';
Expand Down Expand Up @@ -67,7 +68,11 @@ let nextWindowId = 0;
'[attr.aria-owns]': 'isPopupOpen() ? popupId : null',
'[attr.aria-expanded]': 'isPopupOpen()',
},
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => NgbTypeahead), multi: true }],
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => NgbTypeahead), multi: true },
{ provide: LIVE_CONTAINER, useFactory: getShadowRootContainerIfAny, deps: [ElementRef] },
Live,
],
})
export class NgbTypeahead implements ControlValueAccessor, OnInit, OnChanges, OnDestroy {
private _nativeElement = inject(ElementRef).nativeElement as HTMLInputElement;
Expand Down Expand Up @@ -394,6 +399,18 @@ export class NgbTypeahead implements ControlValueAccessor, OnInit, OnChanges, On
this._nativeElement.value = toString(value);
}

private _getAnnounceLocalizedMessage(count: number): string {
if (count === 0) {
return $localize`:@@ngb.typeahead.no-results:No results available`;
}

if (count === 1) {
return $localize`:@@ngb.typeahead.one-result:1 result available`;
}

return $localize`:@@ngb.typeahead.many-results:${count}:count: results available`;
}

private _subscribeToUserInput(): void {
const results$ = this._valueChanges$.pipe(
tap((value) => {
Expand Down Expand Up @@ -440,7 +457,7 @@ export class NgbTypeahead implements ControlValueAccessor, OnInit, OnChanges, On

// live announcer
const count = results ? results.length : 0;
this._live.say(count === 0 ? 'No results available' : `${count} result${count === 1 ? '' : 's'} available`);
this._live.say(this._getAnnounceLocalizedMessage(count));
});
}

Expand Down
7 changes: 7 additions & 0 deletions src/util/accessibility/live.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ElementRef } from '@angular/core';

export function getShadowRootContainerIfAny(elementRef: ElementRef): Node | null {
const _nativeElement = elementRef.nativeElement as HTMLInputElement;
const rootNode = _nativeElement.getRootNode();
return rootNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE ? rootNode : null;
}
68 changes: 46 additions & 22 deletions src/util/accessibility/live.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { TestBed, ComponentFixture, inject } from '@angular/core/testing';
import { Component } from '@angular/core';
import { By } from '@angular/platform-browser';
import { Live, ARIA_LIVE_DELAY } from './live';
import { Live, ARIA_LIVE_DELAY, LIVE_CONTAINER } from './live';

function getLiveElement(): HTMLElement {
return document.body.querySelector('#ngb-live')! as HTMLElement;
Expand All @@ -15,31 +15,55 @@ describe('LiveAnnouncer', () => {
fixture.debugElement.query(By.css('button')).nativeElement.click();
};

const configureTestingModule = (additionalProviders?: any[]) => {
TestBed.configureTestingModule({
providers: [
Live,
{ provide: ARIA_LIVE_DELAY, useValue: null },
...(additionalProviders ? additionalProviders : []),
],
declarations: [TestComponent],
});
};

describe('live announcer', () => {
beforeEach(() =>
TestBed.configureTestingModule({
providers: [Live, { provide: ARIA_LIVE_DELAY, useValue: null }],
declarations: [TestComponent],
}),
);

beforeEach(inject([Live], (_live: Live) => {
live = _live;
fixture = TestBed.createComponent(TestComponent);
}));

it('should correctly update the text message', () => {
say();
const liveElement = getLiveElement();
expect(liveElement.textContent).toBe('test');
expect(liveElement.id).toBe('ngb-live');
describe('with the default container', () => {
beforeEach(() => configureTestingModule());

beforeEach(inject([Live], (_live: Live) => {
live = _live;
fixture = TestBed.createComponent(TestComponent);
}));

it('should correctly update the text message and be appended as a child of the default container', () => {
say();
const liveElement = getLiveElement();
expect(liveElement.parentElement?.tagName).toBe('BODY');
expect(liveElement.textContent).toBe('test');
expect(liveElement.id).toBe('ngb-live');
});

it('should remove the used element from the DOM on destroy', () => {
say();
live.ngOnDestroy();

expect(getLiveElement()).toBeFalsy();
});
});

it('should remove the used element from the DOM on destroy', () => {
say();
live.ngOnDestroy();
describe('with an overriding container', () => {
it('should be correctly appended as a child of the overriding container when provided', () => {
const containerId = 'overriding-container';
const container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
configureTestingModule([{ provide: LIVE_CONTAINER, useValue: container }]);
fixture = TestBed.createComponent(TestComponent);

expect(getLiveElement()).toBeFalsy();
say();
const liveElement = getLiveElement();
expect(liveElement.parentElement?.id).toBe(containerId);
});
});
});
});
Expand Down
20 changes: 12 additions & 8 deletions src/util/accessibility/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { inject, Injectable, InjectionToken, OnDestroy } from '@angular/core';
import { DOCUMENT } from '@angular/common';

export const ARIA_LIVE_DELAY = new InjectionToken<number | null>('live announcer delay', {
providedIn: 'root',
factory: () => 100,
});

function getLiveElement(document: any, lazyCreate = false): HTMLElement | null {
let element = document.body.querySelector('#ngb-live') as HTMLElement;
export const LIVE_CONTAINER = new InjectionToken<HTMLElement | null>('live container', {
factory: () => null,
});

function getLiveElement(document: any, container: HTMLElement, lazyCreate = false): HTMLElement | null {
let element = container.querySelector('#ngb-live') as HTMLElement;

if (element == null && lazyCreate) {
element = document.createElement('div');
Expand All @@ -18,27 +21,28 @@ function getLiveElement(document: any, lazyCreate = false): HTMLElement | null {

element.classList.add('visually-hidden');

document.body.appendChild(element);
container.appendChild(element);
}

return element;
}

@Injectable({ providedIn: 'root' })
@Injectable()
export class Live implements OnDestroy {
private _document = inject(DOCUMENT);
private _delay = inject(ARIA_LIVE_DELAY);
private _container = inject(LIVE_CONTAINER) || this._document.body;

ngOnDestroy() {
const element = getLiveElement(this._document);
const element = getLiveElement(this._document, this._container);
if (element) {
// if exists, it will always be attached to the <body>
// if exists, it will always be attached to either the <body> or the overriding container provided through the LIVE_CONTAINER DI token
element.parentElement!.removeChild(element);
}
}

say(message: string) {
const element = getLiveElement(this._document, true);
const element = getLiveElement(this._document, this._container, true);
const delay = this._delay;

if (element != null) {
Expand Down