Skip to content

Commit

Permalink
feat(typeahead): enable override of the container for the live element
Browse files Browse the repository at this point in the history
Enable the override of the container for the live element, through a DI token,
meaning that the Live service is not provided in root anymore and needs to be
provided when used in a component.
Fixes ng-bootstrap#4560
  • Loading branch information
Jeremy CHOISY committed Jan 22, 2024
1 parent b5d5feb commit b5203bd
Show file tree
Hide file tree
Showing 4 changed files with 71 additions and 33 deletions.
9 changes: 7 additions & 2 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
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: 11 additions & 9 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 @@ -16,29 +19,28 @@ function getLiveElement(document: any, lazyCreate = false): HTMLElement | null {
element.setAttribute('aria-live', 'polite');
element.setAttribute('aria-atomic', 'true');

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>
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

0 comments on commit b5203bd

Please sign in to comment.