From af50bd368be9b0585da727bad8765a8a025e6b86 Mon Sep 17 00:00:00 2001 From: Jeremy CHOISY Date: Fri, 10 Nov 2023 14:56:10 +0100 Subject: [PATCH] fix(typeahead): use inline style for live element 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 --- src/typeahead/typeahead.ts | 9 +++- src/util/accessibility/live.helper.ts | 7 +++ src/util/accessibility/live.spec.ts | 68 ++++++++++++++++++--------- src/util/accessibility/live.ts | 20 ++++---- 4 files changed, 71 insertions(+), 33 deletions(-) create mode 100644 src/util/accessibility/live.helper.ts diff --git a/src/typeahead/typeahead.ts b/src/typeahead/typeahead.ts index 365af81b6b..38fb23a4fb 100644 --- a/src/typeahead/typeahead.ts +++ b/src/typeahead/typeahead.ts @@ -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'; @@ -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; diff --git a/src/util/accessibility/live.helper.ts b/src/util/accessibility/live.helper.ts new file mode 100644 index 0000000000..3825f95cc4 --- /dev/null +++ b/src/util/accessibility/live.helper.ts @@ -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; +} diff --git a/src/util/accessibility/live.spec.ts b/src/util/accessibility/live.spec.ts index 43de81e783..4ed09a32f8 100644 --- a/src/util/accessibility/live.spec.ts +++ b/src/util/accessibility/live.spec.ts @@ -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; @@ -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); + }); }); }); }); diff --git a/src/util/accessibility/live.ts b/src/util/accessibility/live.ts index 342364beca..b1c7a222a7 100644 --- a/src/util/accessibility/live.ts +++ b/src/util/accessibility/live.ts @@ -2,12 +2,15 @@ import { inject, Injectable, InjectionToken, OnDestroy } from '@angular/core'; import { DOCUMENT } from '@angular/common'; export const ARIA_LIVE_DELAY = new InjectionToken('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('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'); @@ -16,21 +19,20 @@ 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 element.parentElement!.removeChild(element); @@ -38,7 +40,7 @@ export class Live implements OnDestroy { } say(message: string) { - const element = getLiveElement(this._document, true); + const element = getLiveElement(this._document, this._container, true); const delay = this._delay; if (element != null) {