Skip to content

Commit

Permalink
fix(typeahead): use inline style for live element
Browse files Browse the repository at this point in the history
Use inline style instead of the bootstrap class for the live element
to support the usage of the component in the shadow DOM.
Fixes #4560
  • Loading branch information
Jeremy CHOISY committed Jan 22, 2024
1 parent b5d5feb commit af50bd3
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 af50bd3

Please sign in to comment.