From c8538d57c41769a3ecb1701ded7e0c61c296d34c Mon Sep 17 00:00:00 2001 From: xidedix Date: Thu, 22 Oct 2026 16:56:46 +0200 Subject: [PATCH] fix(Backdrop, Modal, Offcanvas): scroll lock and modal-open per owner, backdrop after its own fade, hide ends with the transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BackdropService: lockScroll(owner)/unlockScroll(owner); hideScrollbar()/resetScrollbar() deprecated, lock on behalf of the service; clearBackdrop() no longer touches the lock; scrollbarWidth measured at the first lock; unlock removes only the padding side it set. - Backdrop: one element per type, removed after its own fade, revived while fading, no 0.5 → 0 → 0.5 flash; setBackdrop(type, fade); no flash when released or revived before its delayed show. - Modal: lock and modal-open (ModalService.addOpenClass/removeOpenClass) held until the host and dialog transitions end, released at once on destroy; reduced motion and transition=false hide at once; a close during a server render leaves a clean body; a never-opened modal keeps its display. - Offcanvas: lock follows visible and scroll, not the backdrop; rtl padding removed without a backdrop; content inert at close, the host takes clicks until the hide ends and focus only while its content is active. - shared/transition.utils: afterTransitions(element), the running CSS transitions, no timers. - OffcanvasModule, ModalModule: no services in providers, root instances. - Offcanvas specs run in a test host. --- .../src/lib/backdrop/backdrop.service.spec.ts | 313 +++++++++- .../src/lib/backdrop/backdrop.service.ts | 194 ++++-- .../src/lib/modal/modal.module.ts | 4 +- .../src/lib/modal/modal.service.spec.ts | 28 + .../src/lib/modal/modal.service.ts | 26 +- .../lib/modal/modal/modal.component.spec.ts | 383 +++++++++++- .../src/lib/modal/modal/modal.component.ts | 79 ++- .../src/lib/offcanvas/offcanvas.module.ts | 6 - .../lib/offcanvas/offcanvas.service.spec.ts | 14 +- .../offcanvas/offcanvas.component.html | 2 +- .../offcanvas/offcanvas.component.spec.ts | 587 ++++++++++++++---- .../offcanvas/offcanvas.component.ts | 101 ++- .../src/lib/shared/transition.utils.spec.ts | 127 ++++ .../src/lib/shared/transition.utils.ts | 15 + 14 files changed, 1584 insertions(+), 295 deletions(-) create mode 100644 projects/coreui-angular/src/lib/shared/transition.utils.spec.ts create mode 100644 projects/coreui-angular/src/lib/shared/transition.utils.ts diff --git a/projects/coreui-angular/src/lib/backdrop/backdrop.service.spec.ts b/projects/coreui-angular/src/lib/backdrop/backdrop.service.spec.ts index e145af94..1f64be14 100644 --- a/projects/coreui-angular/src/lib/backdrop/backdrop.service.spec.ts +++ b/projects/coreui-angular/src/lib/backdrop/backdrop.service.spec.ts @@ -3,6 +3,27 @@ import { TestBed } from '@angular/core/testing'; import { BackdropService } from './backdrop.service'; +const holdTransition = (element: Element) => { + let end!: () => void; + let cancel!: () => void; + let running = true; + const finished = new Promise((resolve, reject) => { + end = () => { + running = false; + resolve(); + }; + cancel = () => { + running = false; + reject(new DOMException('cancelled', 'AbortError')); + }; + }); + Object.defineProperty(element, 'getAnimations', { + configurable: true, + value: () => (running ? [{ transitionProperty: 'opacity', finished }] : []) + }); + return { end, cancel }; +}; + describe('BackdropService', () => { let service: BackdropService; let document: Document; @@ -20,6 +41,7 @@ describe('BackdropService', () => { afterEach(async () => { if (backdrop) { + Reflect.deleteProperty(backdrop, 'getAnimations'); service.clearBackdrop(backdrop); await vi.runAllTimersAsync(); } @@ -75,17 +97,292 @@ describe('BackdropService', () => { backdrop = null; }); - it('should not reset the scrollbar once a cleared backdrop is replaced', async () => { - const cleared = backdrop; - service.clearBackdrop(cleared); - backdrop = service.setBackdrop('modal'); - service.hideScrollbar(); + describe('backdrop element', () => { + it('should share one backdrop per type between its users, with the fade of the latest call', async () => { + expect(service.setBackdrop('modal', false)).toBe(backdrop); + expect(backdrop.classList.contains('fade')).toBe(false); + + service.clearBackdrop(backdrop); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(true); + expect(backdrop.classList.contains('show')).toBe(true); + }); + + it('should keep a backdrop until its fade transition ends', async () => { + const { end } = holdTransition(backdrop); + service.clearBackdrop(backdrop); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(true); + expect(backdrop.classList.contains('show')).toBe(false); + + end(); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(false); + backdrop = null; + }); + + it('should remove a backdrop whose fade is cancelled', async () => { + const { cancel } = holdTransition(backdrop); + service.clearBackdrop(backdrop); + await Promise.resolve(); + expect(backdrop.classList.contains('show')).toBe(false); + expect(backdrop.isConnected).toBe(true); + + cancel(); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(false); + backdrop = null; + }); + + it('should bring a fading backdrop of the same type back', async () => { + const { end } = holdTransition(backdrop); + service.clearBackdrop(backdrop); + await vi.runAllTimersAsync(); + + expect(service.setBackdrop('modal')).toBe(backdrop); + expect(backdrop.classList.contains('show')).toBe(true); + expect(service.activeBackdrop).toBe(backdrop); + end(); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(true); + }); + + it('should answer clicks on a backdrop that came back, not on a fading one', async () => { + const clicks: boolean[] = []; + service.backdropClick$.subscribe((clicked) => clicks.push(clicked)); + holdTransition(backdrop); + service.clearBackdrop(backdrop); + await vi.runAllTimersAsync(); + + backdrop.dispatchEvent(new MouseEvent('click')); + expect(clicks).toEqual([]); + + service.setBackdrop('modal'); + backdrop.dispatchEvent(new MouseEvent('click')); + expect(clicks).toEqual([true]); + }); + + it('should finish only the latest fade of a backdrop that faded again after it came back', async () => { + const first = holdTransition(backdrop); + service.clearBackdrop(backdrop); + await vi.runAllTimersAsync(); + service.setBackdrop('modal'); + const second = holdTransition(backdrop); + service.clearBackdrop(backdrop); + await vi.runAllTimersAsync(); + + first.end(); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(true); + + second.end(); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(false); + backdrop = null; + }); + + it('should replace a backdrop that was removed from the page', async () => { + backdrop.remove(); + const replaced = service.setBackdrop('modal'); + expect(replaced).not.toBe(backdrop); + expect(replaced.isConnected).toBe(true); + + service.clearBackdrop(replaced); + await vi.runAllTimersAsync(); + expect(replaced.isConnected).toBe(false); + backdrop = null; + }); + + it('should keep a replacement when the fade of a removed backdrop ends', async () => { + const { end } = holdTransition(backdrop); + service.clearBackdrop(backdrop); + backdrop.remove(); + const replaced = service.setBackdrop('modal'); + + end(); + await vi.runAllTimersAsync(); + expect(replaced.isConnected).toBe(true); + expect(service.activeBackdrop).toBe(replaced); + + service.clearBackdrop(replaced); + await vi.runAllTimersAsync(); + expect(replaced.isConnected).toBe(false); + backdrop = null; + }); + + it('should not start the fade of a backdrop set again in the same synchronous run', async () => { + const remove = vi.spyOn(backdrop.classList, 'remove'); + service.clearBackdrop(backdrop); + service.setBackdrop('modal', false); + await vi.runAllTimersAsync(); + expect(remove).not.toHaveBeenCalledWith('show'); + expect(backdrop.classList.contains('show')).toBe(true); + }); + + it('should remove a modal and an offcanvas backdrop that fade together', async () => { + const offcanvas = service.setBackdrop('offcanvas'); + await vi.runAllTimersAsync(); + + service.clearBackdrop(backdrop); + service.clearBackdrop(offcanvas); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(false); + expect(offcanvas.isConnected).toBe(false); + backdrop = null; + }); + + it('should ignore clearBackdrop for an element it does not hold in use', async () => { + service.clearBackdrop(undefined as unknown as HTMLElement); + service.clearBackdrop(document.createElement('div')); + expect(backdrop.classList.contains('show')).toBe(true); + + holdTransition(backdrop); + service.clearBackdrop(backdrop); + service.clearBackdrop(backdrop); + service.setBackdrop('modal'); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(true); + expect(backdrop.classList.contains('show')).toBe(true); + + Reflect.deleteProperty(backdrop, 'getAnimations'); + service.clearBackdrop(backdrop); + await vi.runAllTimersAsync(); + expect(backdrop.isConnected).toBe(false); + backdrop = null; + }); + + it('should not show a backdrop released before its delayed show', async () => { + const offcanvas = service.setBackdrop('offcanvas'); + service.clearBackdrop(offcanvas); + await vi.runAllTimersAsync(); + expect(offcanvas.classList.contains('show')).toBe(false); + expect(offcanvas.isConnected).toBe(false); + }); + + it('should leave the delayed show to a backdrop released and set again before it showed', async () => { + const offcanvas = service.setBackdrop('offcanvas'); + service.clearBackdrop(offcanvas); + expect(service.setBackdrop('offcanvas')).toBe(offcanvas); + expect(offcanvas.classList.contains('show')).toBe(false); + + await vi.runAllTimersAsync(); + expect(offcanvas.classList.contains('show')).toBe(true); + service.clearBackdrop(offcanvas); + await vi.runAllTimersAsync(); + expect(offcanvas.isConnected).toBe(false); + }); + + it('should show at once a backdrop set without fade before its delayed show', () => { + const offcanvas = service.setBackdrop('offcanvas'); + service.setBackdrop('offcanvas', false); + expect(offcanvas.classList.contains('fade')).toBe(false); + expect(offcanvas.classList.contains('show')).toBe(true); + service.clearBackdrop(offcanvas); + service.clearBackdrop(offcanvas); + }); + + it('should show a backdrop without fade at once and remove it once released', async () => { + const offcanvas = service.setBackdrop('offcanvas', false); + expect(offcanvas.classList.contains('fade')).toBe(false); + expect(offcanvas.classList.contains('show')).toBe(true); + + service.clearBackdrop(offcanvas); + await vi.runAllTimersAsync(); + expect(offcanvas.isConnected).toBe(false); + }); + }); + + it('should leave the scroll lock alone when a backdrop is cleared', async () => { + const owner = {}; + service.lockScroll(owner); + service.clearBackdrop(backdrop); await vi.runAllTimersAsync(); + backdrop = null; expect(document.body.style.overflow).toBe('hidden'); - expect(cleared.parentElement).toBeNull(); - expect(backdrop.parentElement).toBe(document.body); - service.resetScrollbar(); + service.unlockScroll(owner); + }); + + describe('scroll lock', () => { + const first = {}; + const second = {}; + + afterEach(() => { + service.unlockScroll(first); + service.unlockScroll(second); + document.body.removeAttribute('dir'); + document.body.style.removeProperty('overflow'); + Reflect.deleteProperty(document.documentElement, 'clientWidth'); + }); + + it('should measure the scrollbar before it hides the overflow', () => { + Object.defineProperty(document.documentElement, 'clientWidth', { + configurable: true, + get: () => (document.body.style.overflow === 'hidden' ? window.innerWidth : window.innerWidth - 15) + }); + service.lockScroll(first); + + expect(document.body.style.overflow).toBe('hidden'); + expect(document.body.style.paddingRight).toBe('15px'); + expect(service.scrollbarWidth).toBe('15px'); + }); + + it('should keep scrolling locked until the last owner unlocks', () => { + service.lockScroll(first); + service.lockScroll(second); + service.unlockScroll(first); + expect(document.body.style.overflow).toBe('hidden'); + + service.unlockScroll(second); + expect(document.body.style.overflow).toBe(''); + expect(document.body.style.paddingRight).toBe(''); + }); + + it('should count a second lock by the same owner once', () => { + service.lockScroll(first); + service.lockScroll(first); + service.unlockScroll(first); + expect(document.body.style.overflow).toBe(''); + }); + + it('should not touch the body when an owner without a lock unlocks', () => { + document.body.style.overflow = 'hidden'; + service.unlockScroll(first); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('should remove only the padding it set, also when the direction changes while locked', () => { + document.body.style.paddingLeft = '7px'; + service.lockScroll(first); + document.body.setAttribute('dir', 'rtl'); + service.unlockScroll(first); + expect(document.body.style.paddingRight).toBe(''); + expect(document.body.style.paddingLeft).toBe('7px'); + document.body.style.removeProperty('padding-left'); + }); + + it('should not restore the page padding the lock overwrote', () => { + document.body.style.paddingRight = '20px'; + service.lockScroll(first); + service.unlockScroll(first); + expect(document.body.style.paddingRight).toBe(''); + }); + + it('should keep a component lock when resetScrollbar is called from outside', () => { + service.lockScroll(first); + service.resetScrollbar(); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('should lock and unlock through the deprecated methods on behalf of the service', () => { + service.hideScrollbar(); + service.lockScroll(first); + service.resetScrollbar(); + expect(document.body.style.overflow).toBe('hidden'); + + service.unlockScroll(first); + expect(document.body.style.overflow).toBe(''); + }); }); it('should react to backdrop click', async () => { diff --git a/projects/coreui-angular/src/lib/backdrop/backdrop.service.ts b/projects/coreui-angular/src/lib/backdrop/backdrop.service.ts index afcafef0..9e3386a4 100644 --- a/projects/coreui-angular/src/lib/backdrop/backdrop.service.ts +++ b/projects/coreui-angular/src/lib/backdrop/backdrop.service.ts @@ -2,6 +2,16 @@ import { DOCUMENT, inject, Injectable } from '@angular/core'; import { Subject } from 'rxjs'; +import { afterTransitions } from '../shared/transition.utils'; + +interface Backdrop { + type: string; + element: HTMLDivElement; + users: number; + shown?: boolean; + fading?: object; +} + @Injectable({ providedIn: 'root' }) @@ -13,8 +23,8 @@ export class BackdropService { activeBackdrop: any; - #clearBackdropId?: ReturnType; - #pendingBackdrop?: HTMLElement; + readonly #backdrops = new Map(); + readonly #clickHandler = () => this.onClickHandler(); get #scrollbarWidth() { // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes @@ -25,69 +35,87 @@ export class BackdropService { scrollbarWidth = this.#scrollbarWidth; - setBackdrop(type: string = 'modal'): HTMLDivElement { - this.#flushClearBackdrop(); - - const backdropElement = this.#document.createElement('div'); - backdropElement.classList.add(`${type}-backdrop`); - backdropElement.classList.add('fade'); - this.#document.body.appendChild(backdropElement); - - const clickHandler = () => { - this.onClickHandler(); - }; - (backdropElement as any).__backdropClickHandler = clickHandler; - backdropElement.addEventListener('click', clickHandler); - - this.scrollbarWidth = this.#scrollbarWidth; - setTimeout(() => { - backdropElement.classList.add('show'); - // this.hideScrollbar(); - }); - this.activeBackdrop = backdropElement; - return backdropElement; + /** + * Returns the backdrop element of a type, shared by everyone who sets it; a fading backdrop of the type comes back. + * Pair every call with `clearBackdrop()`. + * @param type the backdrop type, rendered as the `-backdrop` class + * @param fade fade the backdrop in and out, as the latest call asks; without it the backdrop shows at once and goes once released (since 5.7.38) + * @returns the backdrop element + */ + setBackdrop(type: string = 'modal', fade: boolean = true): HTMLDivElement { + const existing = this.#backdrops.get(type); + const backdrop = existing?.element.isConnected ? existing : this.#createBackdrop(type); + const fading = backdrop.fading; + backdrop.users++; + backdrop.element.classList.toggle('fade', fade); + if (fading) { + backdrop.fading = undefined; + backdrop.element.addEventListener('click', this.#clickHandler); + } + if ((fading && backdrop.shown) || !fade) { + this.#show(backdrop); + } + this.activeBackdrop = backdrop.element; + return backdrop.element; } + /** + * Releases one use of a backdrop; the last release fades it out and removes it once its transition ends. + * A backdrop set again before its fade starts (a microtask after the last release) comes back without fading. + * Does nothing for an element the service does not hold in use. + * @param backdropElement the element `setBackdrop()` returned + * @returns `undefined`, to clear the caller's reference + */ clearBackdrop(backdropElement: HTMLElement): any { - if (backdropElement) { - const storedHandler = (backdropElement as any).__backdropClickHandler; - if (storedHandler) { - backdropElement.removeEventListener('click', storedHandler); - delete (backdropElement as any).__backdropClickHandler; - } - backdropElement.classList.remove('show'); - this.#flushClearBackdrop(); - this.#pendingBackdrop = backdropElement; - this.#clearBackdropId = setTimeout(() => { - this.#clearBackdropId = undefined; - this.#pendingBackdrop = undefined; - if (this.activeBackdrop === backdropElement) { - this.resetScrollbar(); - this.activeBackdrop = undefined; - } - this.#removeBackdrop(backdropElement); - }, 300); + const backdrop = [...this.#backdrops.values()].find(({ element }) => element === backdropElement); + if (backdrop?.users && --backdrop.users === 0) { + this.#fadeOut(backdrop); } return undefined; } - #flushClearBackdrop(): void { - if (this.#clearBackdropId === undefined) { - return; - } - clearTimeout(this.#clearBackdropId); - this.#clearBackdropId = undefined; - const pending = this.#pendingBackdrop; - this.#pendingBackdrop = undefined; - if (pending) { - this.#removeBackdrop(pending); - } + #createBackdrop(type: string): Backdrop { + const element = this.#document.createElement('div'); + const backdrop: Backdrop = { type, element, users: 0 }; + element.classList.add(`${type}-backdrop`); + this.#document.body.appendChild(element); + element.addEventListener('click', this.#clickHandler); + this.#backdrops.set(type, backdrop); + setTimeout(() => { + if (backdrop.users && !backdrop.fading) { + this.#show(backdrop); + } + }); + return backdrop; } - #removeBackdrop(backdropElement: HTMLElement): void { - if (backdropElement.parentElement === this.#document.body) { - this.#document.body.removeChild(backdropElement); - } + #show(backdrop: Backdrop): void { + backdrop.element.classList.add('show'); + backdrop.shown = true; + } + + #fadeOut(backdrop: Backdrop): void { + const fading = {}; + backdrop.fading = fading; + backdrop.element.removeEventListener('click', this.#clickHandler); + queueMicrotask(() => { + if (backdrop.fading !== fading) { + return; + } + backdrop.element.classList.remove('show'); + afterTransitions(backdrop.element).then(() => { + if (backdrop.fading !== fading) { + return; + } + if (this.#backdrops.get(backdrop.type) === backdrop) { + this.#backdrops.delete(backdrop.type); + } + backdrop.element.remove(); + if (this.activeBackdrop === backdrop.element) { + this.activeBackdrop = undefined; + } + }); + }); } get #isRTL() { @@ -98,20 +126,56 @@ export class BackdropService { return `padding-${this.#isRTL ? 'left' : 'right'}`; } - #scrollBarVisible = true; - - hideScrollbar(): void { - if (this.#scrollBarVisible) { + readonly #scrollLockOwners = new Set(); + #lockedPadding = ''; + + /** + * Locks page scrolling for an owner: hides the body overflow and pads the body by the scrollbar width. + * The lock stays while any owner holds it; locking twice for the same owner counts once. + * @since 5.7.38 + * @param owner the component or object holding the lock + */ + lockScroll(owner: object): void { + if (this.#scrollLockOwners.has(owner)) { + return; + } + this.#scrollLockOwners.add(owner); + if (this.#scrollLockOwners.size === 1) { + this.scrollbarWidth = this.#scrollbarWidth; + this.#lockedPadding = this.#padding; this.#document.body.style.setProperty('overflow', 'hidden'); - this.#document.body.style.setProperty(this.#padding, this.scrollbarWidth); - this.#scrollBarVisible = false; + this.#document.body.style.setProperty(this.#lockedPadding, this.scrollbarWidth); } } + /** + * Releases the scroll lock of an owner; once the last owner releases it, the overflow and the padding property the lock set + * are removed (a page's own inline padding on that side, overwritten by the lock, is not restored). + * Does nothing for an owner that holds no lock. + * @since 5.7.38 + * @param owner the component or object that locked scrolling + */ + unlockScroll(owner: object): void { + if (this.#scrollLockOwners.delete(owner) && this.#scrollLockOwners.size === 0) { + this.#document.body.style.removeProperty('overflow'); + this.#document.body.style.removeProperty(this.#lockedPadding); + } + } + + /** + * Locks page scrolling on behalf of the service itself. + * @deprecated 5.7.38. Use `lockScroll(owner)` instead. + */ + hideScrollbar(): void { + this.lockScroll(this); + } + + /** + * Releases the lock taken by `hideScrollbar()`; locks of other owners stay. + * @deprecated 5.7.38. Use `unlockScroll(owner)` instead. + */ resetScrollbar(): void { - this.#document.body.style.removeProperty('overflow'); - this.#document.body.style.removeProperty(this.#padding); - this.#scrollBarVisible = true; + this.unlockScroll(this); } onClickHandler(): void { diff --git a/projects/coreui-angular/src/lib/modal/modal.module.ts b/projects/coreui-angular/src/lib/modal/modal.module.ts index 34b93216..0d4cc61d 100644 --- a/projects/coreui-angular/src/lib/modal/modal.module.ts +++ b/projects/coreui-angular/src/lib/modal/modal.module.ts @@ -8,7 +8,6 @@ import { ModalFooterComponent } from './modal-footer/modal-footer.component'; import { ModalHeaderComponent } from './modal-header/modal-header.component'; import { ModalTitleDirective } from './modal-title/modal-title.directive'; import { ModalComponent } from './modal/modal.component'; -import { ModalService } from './modal.service'; @NgModule({ exports: [ @@ -30,7 +29,6 @@ import { ModalService } from './modal.service'; ModalHeaderComponent, ModalTitleDirective, ModalToggleDirective - ], - providers: [ModalService] + ] }) export class ModalModule {} diff --git a/projects/coreui-angular/src/lib/modal/modal.service.spec.ts b/projects/coreui-angular/src/lib/modal/modal.service.spec.ts index 957317ff..b0140641 100644 --- a/projects/coreui-angular/src/lib/modal/modal.service.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal.service.spec.ts @@ -1,5 +1,8 @@ +import { createEnvironmentInjector, EnvironmentInjector, importProvidersFrom } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { ModalComponent } from './modal/modal.component'; +import { ModalModule } from './modal.module'; import { ModalService } from './modal.service'; describe('ModalService', () => { @@ -28,6 +31,31 @@ describe('ModalService', () => { expect(receivedToo[0]).toBe(received[0]); }); + it('should keep modal-open on the body while another modal holds it', () => { + const first = {} as ModalComponent; + const second = {} as ModalComponent; + service.addOpenClass(first); + service.addOpenClass(second); + service.removeOpenClass(first); + expect(document.body.classList.contains('modal-open')).toBe(true); + + service.removeOpenClass(second); + expect(document.body.classList.contains('modal-open')).toBe(false); + }); + + it('should not remove modal-open for a modal that does not hold it', () => { + document.body.classList.add('modal-open'); + service.removeOpenClass({} as ModalComponent); + expect(document.body.classList.contains('modal-open')).toBe(true); + document.body.classList.remove('modal-open'); + }); + + it('should be the root instance in an injector that imports ModalModule', () => { + const injector = createEnvironmentInjector([importProvidersFrom(ModalModule)], TestBed.inject(EnvironmentInjector)); + expect(injector.get(ModalService)).toBe(service); + injector.destroy(); + }); + it('should not extend the focus fallback array of the caller', () => { const focusFallback = [document.createElement('button')]; let received: { focusFallback?: (HTMLElement | null)[] } = {}; diff --git a/projects/coreui-angular/src/lib/modal/modal.service.ts b/projects/coreui-angular/src/lib/modal/modal.service.ts index 2742c57d..d3a72ea5 100644 --- a/projects/coreui-angular/src/lib/modal/modal.service.ts +++ b/projects/coreui-angular/src/lib/modal/modal.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { DOCUMENT, inject, Injectable } from '@angular/core'; import { Subject } from 'rxjs'; import { ModalComponent } from './modal/modal.component'; @@ -14,11 +14,13 @@ export interface IModalAction { providedIn: 'root' }) export class ModalService { + readonly #document = inject(DOCUMENT); readonly #modalState = new Subject(); readonly modalState$ = this.#modalState.asObservable(); readonly #opened = new Subject<{ modal: ModalComponent; focusFallback: (HTMLElement | null)[] }>(); /** Emits once a modal has opened through `toggle()`; the other visible modals close on it. */ readonly opened$ = this.#opened.asObservable(); + readonly #openClassHolders = new Set(); toggle(action: IModalAction): void { this.#modalState.next({ ...action, focusFallback: [...(action.focusFallback ?? [])] }); @@ -27,4 +29,26 @@ export class ModalService { announceOpened(modal: ModalComponent, focusFallback: (HTMLElement | null)[]): void { this.#opened.next({ modal, focusFallback }); } + + /** + * Adds the `modal-open` class to the body on behalf of a modal; the class stays while any modal holds it. + * @since 5.7.38 + * @param modal the open modal + */ + addOpenClass(modal: ModalComponent): void { + this.#openClassHolders.add(modal); + this.#document.body.classList.add('modal-open'); + } + + /** + * Releases the `modal-open` class held by a modal; the body loses it once no modal holds it. + * Does nothing for a modal that holds no class. + * @since 5.7.38 + * @param modal the modal that added the class + */ + removeOpenClass(modal: ModalComponent): void { + if (this.#openClassHolders.delete(modal) && this.#openClassHolders.size === 0) { + this.#document.body.classList.remove('modal-open'); + } + } } diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts index ecbedeb9..8b10774d 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.spec.ts @@ -1,13 +1,31 @@ /// import { CdkTrapFocus } from '@angular/cdk/a11y'; -import { Component, ComponentRef, DOCUMENT, viewChild, ViewContainerRef } from '@angular/core'; +import { Component, ComponentRef, DOCUMENT, PLATFORM_ID, viewChild, ViewContainerRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { BackdropService } from '../../backdrop/backdrop.service'; import { ModalComponent } from './modal.component'; import { ModalService } from '../modal.service'; import { ModalToggleDirective } from '../modal-dismiss/modal-toggle.directive'; +const holdTransitions = (...elements: Element[]) => + elements.map((element) => { + let end!: () => void; + let running = true; + const finished = new Promise((resolve) => { + end = () => { + running = false; + resolve(); + }; + }); + Object.defineProperty(element, 'getAnimations', { + configurable: true, + value: () => (running ? [{ transitionProperty: 'opacity', finished }] : []) + }); + return end; + }); + @Component({ template: '' }) @@ -249,6 +267,279 @@ describe('ModalComponent', () => { expect(component.visible()).toBe(true); }); + describe('scroll lock and modal-open', () => { + let backdropService: BackdropService; + + const settle = async () => { + fixture.detectChanges(); + await vi.runAllTimersAsync(); + }; + const locked = () => document.body.style.overflow === 'hidden'; + const hasOpenClass = () => document.body.classList.contains('modal-open'); + const dialog = () => element.querySelector('.modal-dialog')!; + + const microtasks = async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + }; + + beforeEach(() => { + backdropService = TestBed.inject(BackdropService); + expect(locked()).toBe(false); + expect(hasOpenClass()).toBe(false); + }); + + it('should leave the body alone when a closed modal is created and destroyed', async () => { + const holder = {} as ModalComponent; + TestBed.inject(ModalService).addOpenClass(holder); + document.body.style.overflow = 'hidden'; + const closed = fixture.componentInstance.outlet().createComponent(ModalComponent); + await settle(); + closed.destroy(); + await settle(); + + expect(document.body.style.overflow).toBe('hidden'); + expect(hasOpenClass()).toBe(true); + document.body.style.removeProperty('overflow'); + TestBed.inject(ModalService).removeOpenClass(holder); + }); + + it('should leave the inline display of a modal that never opened alone', () => { + expect(element.style.display).toBe(''); + }); + + it('should lock scrolling and add modal-open when it opens', async () => { + component.visible.set(true); + await settle(); + expect(locked()).toBe(true); + expect(hasOpenClass()).toBe(true); + }); + + it('should add modal-open whenever it shows a backdrop', async () => { + componentRef.setInput('backdrop', ''); + component.visible.set(true); + await settle(); + expect(backdropService.activeBackdrop?.isConnected).toBe(true); + expect(hasOpenClass()).toBe(true); + }); + + it('should lock scrolling without modal-open when the backdrop is off', async () => { + componentRef.setInput('backdrop', false); + component.visible.set(true); + await settle(); + expect(locked()).toBe(true); + expect(hasOpenClass()).toBe(false); + + component.visible.set(false); + await settle(); + expect(locked()).toBe(false); + }); + + it.each(['dialog', 'host'])( + 'should release the lock and modal-open only once the host and dialog transitions end (%s first)', + async (first) => { + component.visible.set(true); + await settle(); + const [hostEnd, dialogEnd] = holdTransitions(element, dialog()); + const [firstEnd, lastEnd] = first === 'host' ? [hostEnd, dialogEnd] : [dialogEnd, hostEnd]; + + component.visible.set(false); + await settle(); + expect(locked()).toBe(true); + expect(hasOpenClass()).toBe(true); + expect(element.style.display).toBe('block'); + + firstEnd(); + await vi.runAllTimersAsync(); + expect(locked()).toBe(true); + expect(element.style.display).toBe('block'); + + lastEnd(); + await vi.runAllTimersAsync(); + expect(locked()).toBe(false); + expect(hasOpenClass()).toBe(false); + expect(element.style.display).toBe('none'); + } + ); + + it.each([true, false])( + 'should hide and release at once when nothing transitions (transition %s)', + async (transition) => { + componentRef.setInput('transition', transition); + component.visible.set(true); + await settle(); + + component.visible.set(false); + fixture.detectChanges(); + await microtasks(); + expect(element.style.display).toBe('none'); + expect(locked()).toBe(false); + expect(hasOpenClass()).toBe(false); + } + ); + + it('should release the lock and modal-open when destroyed while open', async () => { + component.visible.set(true); + await settle(); + + componentRef.destroy(); + await settle(); + expect(locked()).toBe(false); + expect(hasOpenClass()).toBe(false); + }); + + it('should release the lock and modal-open when closed and destroyed in the same tick', async () => { + component.visible.set(true); + await settle(); + + component.visible.set(false); + componentRef.destroy(); + expect(locked()).toBe(false); + expect(hasOpenClass()).toBe(false); + }); + + it('should not finish a hide that ends after it was destroyed', async () => { + component.visible.set(true); + await settle(); + const ends = holdTransitions(element, dialog()); + component.visible.set(false); + await settle(); + + componentRef.destroy(); + ends.forEach((end) => end()); + await vi.runAllTimersAsync(); + expect(element.style.display).toBe('block'); + }); + + it('should be inert from close while its hide transitions run', async () => { + component.visible.set(true); + await settle(); + const ends = holdTransitions(element, dialog()); + component.visible.set(false); + await settle(); + expect(element.inert).toBe(true); + expect(element.getAttribute('role')).toBeNull(); + expect(element.getAttribute('aria-modal')).toBeNull(); + expect(element.style.display).toBe('block'); + ends.forEach((end) => end()); + }); + + it('should keep the lock and modal-open when reopened while hiding', async () => { + component.visible.set(true); + await settle(); + const ends = holdTransitions(element, dialog()); + component.visible.set(false); + await settle(); + + component.visible.set(true); + await settle(); + ends.forEach((end) => end()); + await vi.runAllTimersAsync(); + expect(locked()).toBe(true); + expect(hasOpenClass()).toBe(true); + expect(element.style.display).toBe('block'); + }); + + it('should drop modal-open when reopened without a backdrop while hiding', async () => { + component.visible.set(true); + await settle(); + const ends = holdTransitions(element, dialog()); + component.visible.set(false); + await settle(); + + componentRef.setInput('backdrop', false); + component.visible.set(true); + await settle(); + expect(hasOpenClass()).toBe(false); + expect(locked()).toBe(true); + ends.forEach((end) => end()); + }); + + it('should not finish a hide when reopened before the next render', async () => { + component.visible.set(true); + await settle(); + const ends = holdTransitions(element, dialog()); + component.visible.set(false); + await settle(); + + component.visible.set(true); + ends.forEach((end) => end()); + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + expect(locked()).toBe(true); + expect(element.style.display).toBe('block'); + }); + + it('should finish only the latest hide after closing, reopening and closing again', async () => { + component.visible.set(true); + await settle(); + const [firstHost, firstDialog] = holdTransitions(element, dialog()); + component.visible.set(false); + await settle(); + component.visible.set(true); + await settle(); + const [secondHost, secondDialog] = holdTransitions(element, dialog()); + component.visible.set(false); + await settle(); + + firstHost(); + firstDialog(); + await vi.runAllTimersAsync(); + expect(element.style.display).toBe('block'); + expect(locked()).toBe(true); + + secondDialog(); + secondHost(); + await vi.runAllTimersAsync(); + expect(element.style.display).toBe('none'); + expect(locked()).toBe(false); + }); + + it('should keep one backdrop when closed and reopened in one tick', async () => { + component.visible.set(true); + await settle(); + const backdrop = backdropService.activeBackdrop as HTMLElement; + const setBackdrop = vi.spyOn(backdropService, 'setBackdrop'); + + component.visible.set(false); + component.visible.set(true); + await settle(); + expect(setBackdrop).not.toHaveBeenCalled(); + expect(backdrop.isConnected).toBe(true); + expect(locked()).toBe(true); + }); + + it('should show and remove the backdrop without a fade when transition is off', async () => { + componentRef.setInput('transition', false); + component.visible.set(true); + fixture.detectChanges(); + const backdrop = backdropService.activeBackdrop as HTMLElement; + expect(backdrop.classList.contains('fade')).toBe(false); + expect(backdrop.classList.contains('show')).toBe(true); + + component.visible.set(false); + await settle(); + expect(backdrop.isConnected).toBe(false); + }); + + it('should keep scrolling locked by another owner after it closes', async () => { + const offcanvas = {}; + backdropService.lockScroll(offcanvas); + component.visible.set(true); + await settle(); + + component.visible.set(false); + await settle(); + expect(locked()).toBe(true); + expect(hasOpenClass()).toBe(false); + + backdropService.unlockScroll(offcanvas); + expect(locked()).toBe(false); + }); + }); + describe('focus return', () => { let inside: HTMLButtonElement; let input: HTMLInputElement; @@ -512,6 +803,55 @@ describe('ModalComponent', () => { expect(component.visible()).toBe(true); }); + it('should keep scrolling locked and modal-open when another modal opens in its place', async () => { + componentRef.setInput('id', 'first'); + other.setInput('id', 'second'); + service.toggle({ show: true, id: 'first' }); + await settle(); + const backdropService = TestBed.inject(BackdropService); + const backdrop = backdropService.activeBackdrop as HTMLElement; + + service.toggle({ show: true, id: 'second' }); + await settle(); + expect(component.visible()).toBe(false); + expect(document.body.style.overflow).toBe('hidden'); + expect(document.body.classList.contains('modal-open')).toBe(true); + expect(backdropService.activeBackdrop).toBe(backdrop); + expect(backdrop.isConnected).toBe(true); + + other.instance.visible.set(false); + await settle(); + expect(document.body.style.overflow).toBe(''); + expect(document.body.classList.contains('modal-open')).toBe(false); + }); + + it.each([ + ['the first', () => componentRef, () => other], + ['the second', () => other, () => componentRef] + ])('should keep modal-open when a modal without a backdrop closes after %s opened', async (_, open, closing) => { + closing().setInput('backdrop', false); + open().instance.visible.set(true); + closing().instance.visible.set(true); + await settle(); + + closing().instance.visible.set(false); + await settle(); + expect(document.body.classList.contains('modal-open')).toBe(true); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('should keep modal-open and the lock when a closed modal is created next to an open one', async () => { + component.visible.set(true); + await settle(); + + const created = fixture.componentInstance.outlet().createComponent(ModalComponent); + await settle(); + created.destroy(); + await settle(); + expect(document.body.classList.contains('modal-open')).toBe(true); + expect(document.body.style.overflow).toBe('hidden'); + }); + it('should stay open when an action closes another modal', async () => { component.visible.set(true); await settle(); @@ -703,7 +1043,7 @@ describe('ModalComponent', () => { expect(element.parentElement).toBe(originalParent); }); - it('should restore the modal element to its original position once the close transition ends', async () => { + it('should restore the modal element to its original position once the close transitions end', async () => { componentRef.setInput('portal', true); componentRef.setInput('container', container); componentRef.setInput('visible', true); @@ -711,14 +1051,15 @@ describe('ModalComponent', () => { await vi.runAllTimersAsync(); expect(element.parentElement).toBe(container); + const [hostEnd, dialogEnd] = holdTransitions(element, element.querySelector('.modal-dialog')!); componentRef.setInput('visible', false); fixture.detectChanges(); await vi.runAllTimersAsync(); expect(element.parentElement).toBe(container); - const dialogElement = element.querySelector('.modal-dialog') as HTMLElement; - dialogElement.dispatchEvent(new TransitionEvent('transitionend', { propertyName: 'transform' })); - + hostEnd(); + dialogEnd(); + await vi.runAllTimersAsync(); expect(element.parentElement).toBe(originalParent); }); @@ -786,3 +1127,35 @@ describe('ModalComponent', () => { }); }); }); + +describe('ModalComponent on the server', () => { + let component: ModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ModalComponent], + providers: [{ provide: PLATFORM_ID, useValue: 'server' }] + }).compileComponents(); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance.outlet().createComponent(ModalComponent).instance; + await fixture.whenStable(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('should release the lock and modal-open as it closes, with no render to wait for', () => { + component.visible.set(true); + fixture.detectChanges(); + expect(document.body.style.overflow).toBe('hidden'); + + component.visible.set(false); + fixture.detectChanges(); + expect(document.body.style.overflow).toBe(''); + expect(document.body.classList.contains('modal-open')).toBe(false); + }); +}); diff --git a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts index 4a5c2845..6482f37f 100644 --- a/projects/coreui-angular/src/lib/modal/modal/modal.component.ts +++ b/projects/coreui-angular/src/lib/modal/modal/modal.component.ts @@ -2,6 +2,7 @@ import { A11yModule, CdkTrapFocus } from '@angular/cdk/a11y'; import { DomPortal, DomPortalOutlet } from '@angular/cdk/portal'; import { isPlatformBrowser } from '@angular/common'; import { + afterRenderEffect, AfterViewInit, booleanAttribute, Component, @@ -29,6 +30,7 @@ import { BackdropService } from '../../backdrop/backdrop.service'; import { ModalContentComponent } from '../modal-content/modal-content.component'; import { ModalDialogComponent } from '../modal-dialog/modal-dialog.component'; import { restoreFocus } from '../../shared/focus.utils'; +import { afterTransitions } from '../../shared/transition.utils'; import { UIDService } from '../../services/uid.service'; @Component({ @@ -227,29 +229,26 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { protected readonly focusTrap = viewChild(CdkTrapFocus); readonly modalDialogRef = viewChild.required(ModalDialogComponent, { read: ElementRef }); - readonly #modalDialogEffect = effect((OnCleanup) => { - const modalDialogElement = this.modalDialogRef().nativeElement; - - const removeEventListeners = () => { - modalDialogElement?.removeEventListener('transitionend', this.#handleTransitionEnd); - }; - - OnCleanup(removeEventListeners); - - modalDialogElement?.addEventListener('transitionend', this.#handleTransitionEnd); - }); - - readonly #handleTransitionEnd = (event: TransitionEvent) => { - const modalDialogElement = this.modalDialogRef().nativeElement; - if (event.target === modalDialogElement && event.propertyName === 'transform') { - if (!this.visible()) { - this.domPortalCleanup(); - this.#renderer.setStyle(this.#hostElement.nativeElement, 'display', 'none'); + readonly #hideEffect = afterRenderEffect({ + read: (onCleanup) => { + if (this.visible() || this.#hidden) { + return; } + let cancelled = false; + onCleanup(() => (cancelled = true)); + Promise.all([ + afterTransitions(this.#hostElement.nativeElement), + afterTransitions(this.modalDialogRef().nativeElement) + ]).then(() => { + if (!cancelled && !this.visible()) { + this.#completeHide(); + } + }); } - }; + }); #activeBackdrop!: any; + #hidden = true; // private inBoundingClientRect!: boolean; @@ -277,19 +276,28 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { animateStart() { if (this.visible()) { - this.#backdropService.hideScrollbar(); + this.#hidden = false; this.#renderer.setStyle(this.#hostElement.nativeElement, 'display', 'block'); - } else { - if (!this.transition()) { - this.domPortalCleanup(); - this.#renderer.setStyle(this.#hostElement.nativeElement, 'display', 'none'); - } + } else if (!this.#hidden && !isPlatformBrowser(this.#platformId)) { + this.#completeHide(); } setTimeout(() => { this.show = this.visible(); }); } + #completeHide(): void { + this.#hidden = true; + this.domPortalCleanup(); + this.#renderer.setStyle(this.#hostElement.nativeElement, 'display', 'none'); + this.#releaseBody(); + } + + #releaseBody(): void { + this.#backdropService.unlockScroll(this); + this.#modalService.removeOpenClass(this); + } + onKeyDownHandler(event: KeyboardEvent): void { if (event.key === 'Escape' && !event.defaultPrevented && this.keyboard() && this.visible()) { if (this.backdrop() === 'static') { @@ -341,6 +349,7 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { this.#afterViewInit.set(false); this.setBackdrop(false); this.domPortalCleanup(); + this.#releaseBody(); } private stateToggleSubscribe(): void { @@ -396,18 +405,22 @@ export class ModalComponent implements OnInit, OnDestroy, AfterViewInit { } private setBackdrop(setBackdrop: boolean): void { - this.#activeBackdrop = setBackdrop - ? this.#backdropService.setBackdrop('modal') - : this.#backdropService.clearBackdrop(this.#activeBackdrop); + if (setBackdrop) { + this.#activeBackdrop ??= this.#backdropService.setBackdrop('modal', this.transition()); + } else { + this.#activeBackdrop = this.#backdropService.clearBackdrop(this.#activeBackdrop); + } } private setBodyStyles(open: boolean): void { - if (open) { - if (!!this.backdrop()) { - this.#renderer.addClass(this.#document.body, 'modal-open'); - } + if (!open) { + return; + } + this.#backdropService.lockScroll(this); + if (this.backdrop() !== false) { + this.#modalService.addOpenClass(this); } else { - this.#renderer.removeClass(this.#document.body, 'modal-open'); + this.#modalService.removeOpenClass(this); } } diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas.module.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas.module.ts index cc136f9a..2ffb5968 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas.module.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas.module.ts @@ -5,8 +5,6 @@ import { OffcanvasBodyComponent } from './offcanvas-body/offcanvas-body.componen import { OffcanvasHeaderComponent } from './offcanvas-header/offcanvas-header.component'; import { OffcanvasTitleDirective } from './offcanvas-title/offcanvas-title.directive'; import { OffcanvasToggleDirective } from './offcanvas-toggle/offcanvas-toggle.directive'; -import { OffcanvasService } from './offcanvas.service'; -import { BackdropService } from '../backdrop/backdrop.service'; @NgModule({ exports: [ @@ -22,10 +20,6 @@ import { BackdropService } from '../backdrop/backdrop.service'; OffcanvasHeaderComponent, OffcanvasTitleDirective, OffcanvasToggleDirective - ], - providers: [ - OffcanvasService, - BackdropService ] }) export class OffcanvasModule {} diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas.service.spec.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas.service.spec.ts index afba1df3..bfe37877 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas.service.spec.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas.service.spec.ts @@ -1,18 +1,26 @@ +import { createEnvironmentInjector, EnvironmentInjector, importProvidersFrom } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { BackdropService } from '../backdrop/backdrop.service'; +import { OffcanvasModule } from './offcanvas.module'; import { OffcanvasService } from './offcanvas.service'; describe('OffcanvasService', () => { let service: OffcanvasService; beforeEach(() => { - TestBed.configureTestingModule({ - providers: [OffcanvasService] - }); + TestBed.configureTestingModule({}); service = TestBed.inject(OffcanvasService); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should use the root offcanvas and backdrop services in an injector that imports OffcanvasModule', () => { + const injector = createEnvironmentInjector([importProvidersFrom(OffcanvasModule)], TestBed.inject(EnvironmentInjector)); + expect(injector.get(OffcanvasService)).toBe(service); + expect(injector.get(BackdropService)).toBe(TestBed.inject(BackdropService)); + injector.destroy(); + }); }); diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.html b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.html index 1346ee12..b390101d 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.html +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.html @@ -1,3 +1,3 @@ -
+
diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts index fb564e7a..e0e8152c 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.spec.ts @@ -1,7 +1,7 @@ /// import { CdkTrapFocus } from '@angular/cdk/a11y'; import { BreakpointObserver } from '@angular/cdk/layout'; -import { ComponentRef, DOCUMENT } from '@angular/core'; +import { Component, ComponentRef, DOCUMENT, viewChild, ViewContainerRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NEVER, Subject } from 'rxjs'; @@ -12,10 +12,34 @@ import { BackdropService } from '../../backdrop/backdrop.service'; import { OffcanvasComponent } from './offcanvas.component'; import { OffcanvasService } from '../offcanvas.service'; +const holdTransition = (element: Element) => { + let end!: () => void; + let running = true; + const finished = new Promise((resolve) => { + end = () => { + running = false; + resolve(); + }; + }); + Object.defineProperty(element, 'getAnimations', { + configurable: true, + value: () => (running ? [{ transitionProperty: 'transform', finished }] : []) + }); + return end; +}; + +@Component({ + template: '' +}) +class TestComponent { + readonly outlet = viewChild.required('outlet', { read: ViewContainerRef }); +} + describe('OffcanvasComponent', () => { let component: OffcanvasComponent; let componentRef: ComponentRef; - let fixture: ComponentFixture; + let element: HTMLElement; + let fixture: ComponentFixture; let document: Document; beforeEach(async () => { @@ -23,24 +47,25 @@ describe('OffcanvasComponent', () => { imports: [OffcanvasComponent] }).compileComponents(); - fixture = TestBed.createComponent(OffcanvasComponent); - fixture.nativeElement.style.position = 'fixed'; + fixture = TestBed.createComponent(TestComponent); + componentRef = fixture.componentInstance.outlet().createComponent(OffcanvasComponent); + component = componentRef.instance; + element = componentRef.location.nativeElement; + element.style.position = 'fixed'; await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve)); - component = fixture.componentInstance; - componentRef = fixture.componentRef; document = TestBed.inject(DOCUMENT); vi.useFakeTimers(); }); afterEach(async () => { - if (componentRef) { - componentRef.setInput('visible', false); - fixture.detectChanges(); - await vi.runAllTimersAsync(); + if (!componentRef.hostView.destroyed) { + component.visible.set(false); } + fixture.detectChanges(); + await vi.runAllTimersAsync(); vi.useRealTimers(); }); @@ -63,35 +88,53 @@ describe('OffcanvasComponent', () => { }); it('should have css classes', () => { - expect(fixture.nativeElement.classList.contains('offcanvas')).toBe(true); - expect(fixture.nativeElement.classList.contains('offcanvas-start')).toBe(true); - expect(fixture.nativeElement.getAttribute('id')).toContain('offcanvas-start-'); + expect(element.classList.contains('offcanvas')).toBe(true); + expect(element.classList.contains('offcanvas-start')).toBe(true); + expect(element.getAttribute('id')).toContain('offcanvas-start-'); }); - it('should set tabindex to -1', () => { - expect(fixture.nativeElement.getAttribute('tabindex')).toBe('-1'); + it('should give the host a tabindex only while its content is active', async () => { + expect(element.hasAttribute('tabindex')).toBe(false); + componentRef.setInput('visible', true); + fixture.detectChanges(); + await vi.runAllTimersAsync(); + expect(element.getAttribute('tabindex')).toBe('-1'); + + const end = holdTransition(element); + onTestFinished(end); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(element.hasAttribute('tabindex')).toBe(false); + end(); + await vi.runAllTimersAsync(); + expect(element.hasAttribute('tabindex')).toBe(false); + + element.style.position = 'static'; + componentRef.changeDetectorRef.markForCheck(); + await vi.runAllTimersAsync(); + expect(element.getAttribute('tabindex')).toBe('-1'); }); it('should react to visible changes', async () => { expect(componentRef.instance.visible()).toBe(false); - expect(fixture.nativeElement.inert).toBe(true); + expect(element.inert).toBe(true); componentRef.setInput('visible', true); fixture.detectChanges(); await vi.runAllTimersAsync(); expect(componentRef.instance.visible()).toBe(true); - expect(fixture.nativeElement.inert).toBeFalsy(); + expect(element.inert).toBeFalsy(); }); it('should close offcanvas to Esc keydown event', async () => { componentRef.setInput('visible', true); fixture.detectChanges(); expect(componentRef.instance.visible()).toBe(true); - expect(fixture.nativeElement.inert).toBeFalsy(); + expect(element.inert).toBeFalsy(); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await vi.runAllTimersAsync(); fixture.detectChanges(); expect(componentRef.instance.visible()).toBe(false); - expect(fixture.nativeElement.inert).toBe(true); + expect(element.inert).toBe(true); }); it('should not close offcanvas on an Esc keydown another component already handled', async () => { @@ -128,13 +171,13 @@ describe('OffcanvasComponent', () => { componentRef.setInput('responsive', 'false'); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.componentInstance.responsiveBreakpoint).toBe(false); + expect(component.responsiveBreakpoint).toBe(false); }); it('should not carry the hiding class before it was ever shown', async () => { await vi.runAllTimersAsync(); - expect(fixture.nativeElement.classList.contains('hiding')).toBe(false); - expect(fixture.nativeElement.classList.contains('showing')).toBe(false); + expect(element.classList.contains('hiding')).toBe(false); + expect(element.classList.contains('showing')).toBe(false); }); it('should add the hiding class only for a real hide transition', async () => { @@ -143,35 +186,38 @@ describe('OffcanvasComponent', () => { await vi.runAllTimersAsync(); componentRef.setInput('visible', false); fixture.detectChanges(); - expect(fixture.nativeElement.classList.contains('hiding')).toBe(true); + expect(element.classList.contains('hiding')).toBe(true); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.classList.contains('hiding')).toBe(false); + expect(element.classList.contains('hiding')).toBe(false); }); it('should render role and aria-modal only while open', async () => { - expect(fixture.nativeElement.getAttribute('role')).toBeNull(); - expect(fixture.nativeElement.getAttribute('aria-modal')).toBeNull(); + expect(element.getAttribute('role')).toBeNull(); + expect(element.getAttribute('aria-modal')).toBeNull(); componentRef.setInput('visible', true); fixture.detectChanges(); - expect(fixture.nativeElement.getAttribute('role')).toBe('dialog'); - expect(fixture.nativeElement.getAttribute('aria-modal')).toBe('true'); + expect(element.getAttribute('role')).toBe('dialog'); + expect(element.getAttribute('aria-modal')).toBe('true'); componentRef.setInput('visible', false); fixture.detectChanges(); - expect(fixture.nativeElement.getAttribute('role')).toBeNull(); - expect(fixture.nativeElement.getAttribute('aria-modal')).toBeNull(); + expect(element.getAttribute('role')).toBeNull(); + expect(element.getAttribute('aria-modal')).toBeNull(); }); it('should set inert only when the closed offcanvas is positioned as a panel', async () => { - expect(fixture.nativeElement.inert).toBe(true); - fixture.nativeElement.style.position = 'static'; + const content: HTMLElement = fixture.debugElement.query(By.directive(CdkTrapFocus)).nativeElement; + expect(element.inert).toBe(true); + expect(content.inert).toBe(true); + element.style.position = 'static'; componentRef.changeDetectorRef.markForCheck(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.inert).toBeFalsy(); + expect(element.inert).toBeFalsy(); + expect(content.inert).toBeFalsy(); }); it('should re-evaluate the panel position on window resize', async () => { expect(componentRef.instance.ariaHidden()).toBe(true); - fixture.nativeElement.style.position = 'static'; + element.style.position = 'static'; window.dispatchEvent(new Event('resize')); expect(componentRef.instance.ariaHidden()).toBeNull(); }); @@ -185,17 +231,17 @@ describe('OffcanvasComponent', () => { window.dispatchEvent(new Event('resize')); fixture.detectChanges(); expect(componentRef.instance.visible()).toBe(true); - fixture.nativeElement.style.position = 'static'; + element.style.position = 'static'; window.dispatchEvent(new Event('resize')); fixture.detectChanges(); expect(componentRef.instance.visible()).toBe(false); - expect(fixture.nativeElement.getAttribute('role')).toBeNull(); + expect(element.getAttribute('role')).toBeNull(); await vi.runAllTimersAsync(); expect(backdrop.isConnected).toBe(false); }); it('should stay open on resize when it was never positioned as a panel', async () => { - fixture.nativeElement.style.position = 'absolute'; + element.style.position = 'absolute'; componentRef.setInput('visible', true); fixture.detectChanges(); window.dispatchEvent(new Event('resize')); @@ -210,7 +256,7 @@ describe('OffcanvasComponent', () => { fixture.detectChanges(); componentRef.setInput('visible', false); fixture.detectChanges(); - fixture.nativeElement.style.position = 'static'; + element.style.position = 'static'; componentRef.setInput('visible', true); fixture.detectChanges(); window.dispatchEvent(new Event('resize')); @@ -219,12 +265,12 @@ describe('OffcanvasComponent', () => { }); it('should re-evaluate the panel position when responsive changes', async () => { - fixture.nativeElement.style.position = 'static'; + element.style.position = 'static'; componentRef.setInput('responsive', 'lg'); fixture.detectChanges(); fixture.detectChanges(); - expect(fixture.nativeElement.classList.contains('offcanvas-lg')).toBe(true); - expect(fixture.nativeElement.inert).toBeFalsy(); + expect(element.classList.contains('offcanvas-lg')).toBe(true); + expect(element.inert).toBeFalsy(); }); it('should enable the focus trap only while open', async () => { @@ -239,6 +285,258 @@ describe('OffcanvasComponent', () => { expect(trap.enabled).toBe(false); }); + describe('end of the transitions and scroll lock', () => { + let host: HTMLElement; + + const settle = async () => { + fixture.detectChanges(); + await vi.runAllTimersAsync(); + }; + const locked = () => document.body.style.overflow === 'hidden'; + + beforeEach(() => { + host = element; + expect(locked()).toBe(false); + }); + + afterEach(() => { + document.body.removeAttribute('dir'); + }); + + it('should take showing off once the show transition ends', async () => { + const end = holdTransition(host); + componentRef.setInput('visible', true); + await settle(); + expect(host.classList.contains('showing')).toBe(true); + + end(); + await vi.runAllTimersAsync(); + expect(host.classList.contains('showing')).toBe(false); + }); + + it('should keep the panel hit-testable and its content inert until the hide ends', async () => { + const content: HTMLElement = fixture.debugElement.query(By.directive(CdkTrapFocus)).nativeElement; + componentRef.setInput('visible', true); + await settle(); + expect(content.inert).toBeFalsy(); + const end = holdTransition(host); + componentRef.setInput('visible', false); + await settle(); + expect(content.inert).toBe(true); + expect(host.inert).toBeFalsy(); + expect(host.getAttribute('role')).toBeNull(); + expect(host.getAttribute('aria-modal')).toBeNull(); + + end(); + await settle(); + expect(host.inert).toBe(true); + }); + + it('should release the lock only once the close transition ends', async () => { + componentRef.setInput('visible', true); + await settle(); + const end = holdTransition(host); + + componentRef.setInput('visible', false); + await settle(); + expect(locked()).toBe(true); + expect(host.classList.contains('hiding')).toBe(true); + + end(); + await vi.runAllTimersAsync(); + expect(locked()).toBe(false); + expect(host.classList.contains('hiding')).toBe(false); + }); + + it('should remove the rtl padding after closing without a backdrop', async () => { + document.body.setAttribute('dir', 'rtl'); + componentRef.setInput('backdrop', false); + componentRef.setInput('visible', true); + await settle(); + expect(document.body.style.paddingLeft).toContain('px'); + + componentRef.setInput('visible', false); + await settle(); + expect(document.body.style.paddingLeft).toBe(''); + expect(document.body.style.paddingRight).toBe(''); + }); + + it('should keep the lock when reopened while hiding', async () => { + componentRef.setInput('visible', true); + await settle(); + const end = holdTransition(host); + componentRef.setInput('visible', false); + await settle(); + + componentRef.setInput('visible', true); + await settle(); + end(); + await vi.runAllTimersAsync(); + expect(locked()).toBe(true); + expect(host.classList.contains('hiding')).toBe(false); + }); + + it('should finish the hide when opened and closed again in one tick while hiding', async () => { + componentRef.setInput('visible', true); + await settle(); + const end = holdTransition(host); + componentRef.setInput('visible', false); + await settle(); + + component.visible.set(true); + component.visible.set(false); + await settle(); + end(); + await vi.runAllTimersAsync(); + expect(host.classList.contains('hiding')).toBe(false); + expect(locked()).toBe(false); + }); + + it('should not start a hide again once fully hidden', async () => { + componentRef.setInput('visible', true); + await settle(); + componentRef.setInput('visible', false); + await settle(); + + component.visible.set(true); + component.visible.set(false); + fixture.detectChanges(); + expect(host.classList.contains('hiding')).toBe(false); + }); + + it('should finish a hide closed again after its wait gave way to a reopen', async () => { + componentRef.setInput('visible', true); + await settle(); + const end = holdTransition(host); + componentRef.setInput('visible', false); + await settle(); + + component.visible.set(true); + end(); + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + component.visible.set(false); + await settle(); + expect(host.classList.contains('hiding')).toBe(false); + expect(locked()).toBe(false); + }); + + it('should not finish a hide when reopened before the next render', async () => { + componentRef.setInput('visible', true); + await settle(); + const end = holdTransition(host); + componentRef.setInput('visible', false); + await settle(); + + component.visible.set(true); + end(); + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + expect(locked()).toBe(true); + expect(host.classList.contains('hiding')).toBe(true); + }); + + it('should not keep the lock when reopened with scroll on while hiding', async () => { + componentRef.setInput('visible', true); + await settle(); + const end = holdTransition(host); + componentRef.setInput('visible', false); + fixture.detectChanges(); + + componentRef.setInput('scroll', true); + componentRef.setInput('visible', true); + await settle(); + expect(locked()).toBe(false); + + end(); + await vi.runAllTimersAsync(); + expect(locked()).toBe(false); + expect(host.classList.contains('hiding')).toBe(false); + }); + + it('should hold the lock of a hiding offcanvas until its transition ends while another opens with scroll', async () => { + const other = fixture.componentInstance.outlet().createComponent(OffcanvasComponent); + onTestFinished(() => other.destroy()); + other.setInput('scroll', true); + componentRef.setInput('visible', true); + await settle(); + const backdrop = TestBed.inject(BackdropService).activeBackdrop as HTMLElement; + const end = holdTransition(host); + + componentRef.setInput('visible', false); + other.setInput('visible', true); + await settle(); + expect(locked()).toBe(true); + expect(TestBed.inject(BackdropService).activeBackdrop).toBe(backdrop); + expect(backdrop.classList.contains('show')).toBe(true); + + end(); + await vi.runAllTimersAsync(); + expect(locked()).toBe(false); + other.instance.visible.set(false); + await settle(); + }); + + it('should finish only the latest hide after closing, reopening and closing again', async () => { + componentRef.setInput('visible', true); + await settle(); + const firstEnd = holdTransition(host); + componentRef.setInput('visible', false); + await settle(); + componentRef.setInput('visible', true); + await settle(); + const secondEnd = holdTransition(host); + componentRef.setInput('visible', false); + await settle(); + + firstEnd(); + await vi.runAllTimersAsync(); + expect(host.classList.contains('hiding')).toBe(true); + expect(locked()).toBe(true); + + secondEnd(); + await vi.runAllTimersAsync(); + expect(host.classList.contains('hiding')).toBe(false); + expect(locked()).toBe(false); + }); + + it('should keep the lock when closed and reopened in one tick', async () => { + componentRef.setInput('visible', true); + await settle(); + const backdrop = TestBed.inject(BackdropService).activeBackdrop as HTMLElement; + + component.visible.set(false); + component.visible.set(true); + await settle(); + expect(locked()).toBe(true); + expect(TestBed.inject(BackdropService).activeBackdrop).toBe(backdrop); + expect(backdrop.isConnected).toBe(true); + }); + + it('should release the lock at once when destroyed while open', async () => { + componentRef.setInput('visible', true); + await settle(); + + fixture.destroy(); + expect(locked()).toBe(false); + }); + + it('should not finish a hide that ends after it was destroyed', async () => { + componentRef.setInput('visible', true); + await settle(); + const end = holdTransition(host); + componentRef.setInput('visible', false); + await settle(); + + fixture.destroy(); + end(); + await vi.runAllTimersAsync(); + expect(host.classList.contains('hiding')).toBe(true); + }); + }); + describe('focus return', () => { let input: HTMLInputElement; let service: OffcanvasService; @@ -264,7 +562,7 @@ describe('OffcanvasComponent', () => { it('should return focus to the toggle that opened it', async () => { const closeButton = document.createElement('button'); - fixture.nativeElement.append(closeButton); + element.append(closeButton); input.focus(); await toggle('toggle', trigger); closeButton.focus(); @@ -277,10 +575,37 @@ describe('OffcanvasComponent', () => { componentRef.setInput('visible', true); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(document.activeElement).toBe(fixture.nativeElement); + expect(document.activeElement).toBe(element); + componentRef.setInput('visible', false); + fixture.detectChanges(); + expect(document.activeElement).toBe(input); + }); + + it.each([true, false])('should not take its delayed focus once closed (hide transition held: %s)', async (held) => { + input.focus(); + if (held) { + onTestFinished(holdTransition(element)); + } + componentRef.setInput('visible', true); + fixture.detectChanges(); componentRef.setInput('visible', false); fixture.detectChanges(); + await vi.runAllTimersAsync(); + expect(document.activeElement).toBe(input); + }); + + it('should take focus through setFocus while closed only when laid out in place', async () => { + input.focus(); + component.setFocus(); + await vi.runAllTimersAsync(); expect(document.activeElement).toBe(input); + + element.style.position = 'static'; + componentRef.changeDetectorRef.markForCheck(); + await vi.runAllTimersAsync(); + component.setFocus(); + await vi.runAllTimersAsync(); + expect(document.activeElement).toBe(element); }); it('should keep the element focused before opening when an input changes while open', async () => { @@ -326,7 +651,7 @@ describe('OffcanvasComponent', () => { it('should return focus when destroyed while open', async () => { await toggle(true, trigger); - expect(document.activeElement).toBe(fixture.nativeElement); + expect(document.activeElement).toBe(element); fixture.destroy(); expect(document.activeElement).toBe(trigger); }); @@ -361,8 +686,7 @@ describe('OffcanvasComponent', () => { beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); - document.body.appendChild(fixture.nativeElement); - originalParent = fixture.nativeElement.parentElement; + originalParent = element.parentElement!; }); afterEach(() => { @@ -379,8 +703,8 @@ describe('OffcanvasComponent', () => { fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(container.contains(fixture.nativeElement)).toBe(false); - expect(fixture.nativeElement.parentElement).toBe(originalParent); + expect(container.contains(element)).toBe(false); + expect(element.parentElement).toBe(originalParent); }); it('should move the offcanvas element into the given container when portal is enabled', async () => { @@ -390,7 +714,7 @@ describe('OffcanvasComponent', () => { fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(container); + expect(element.parentElement).toBe(container); }); it('should resolve a container provided as a function', async () => { @@ -400,7 +724,7 @@ describe('OffcanvasComponent', () => { fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(container); + expect(element.parentElement).toBe(container); }); it('should not attach the portal when the container resolves to null', async () => { @@ -410,7 +734,7 @@ describe('OffcanvasComponent', () => { fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(originalParent); + expect(element.parentElement).toBe(originalParent); }); it('should restore the offcanvas element to its original position once the close transition ends', async () => { @@ -419,15 +743,17 @@ describe('OffcanvasComponent', () => { componentRef.setInput('visible', true); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(container); + expect(element.parentElement).toBe(container); + const end = holdTransition(element); componentRef.setInput('visible', false); fixture.detectChanges(); - expect(fixture.nativeElement.parentElement).toBe(container); - - fixture.nativeElement.dispatchEvent(new TransitionEvent('transitionend', { propertyName: 'transform' })); + await vi.runAllTimersAsync(); + expect(element.parentElement).toBe(container); - expect(fixture.nativeElement.parentElement).toBe(originalParent); + end(); + await vi.runAllTimersAsync(); + expect(element.parentElement).toBe(originalParent); }); it('should restore the offcanvas element when the close transition never fires', async () => { @@ -436,30 +762,29 @@ describe('OffcanvasComponent', () => { componentRef.setInput('visible', true); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(container); + expect(element.parentElement).toBe(container); componentRef.setInput('visible', false); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(originalParent); - expect(fixture.nativeElement.classList.contains('hiding')).toBe(false); + expect(element.parentElement).toBe(originalParent); + expect(element.classList.contains('hiding')).toBe(false); }); it('should not run the hide completion for an offcanvas that was never shown', async () => { document.body.style.overflow = 'hidden'; - const localFixture = TestBed.createComponent(OffcanvasComponent); - localFixture.detectChanges(); + const local = fixture.componentInstance.outlet().createComponent(OffcanvasComponent); + fixture.detectChanges(); await vi.runAllTimersAsync(); expect(document.body.style.overflow).toBe('hidden'); - localFixture.destroy(); + local.destroy(); document.body.style.removeProperty('overflow'); }); it('should commit the hidden starting style after the move, before the show classes are applied', async () => { - const element: HTMLElement = fixture.nativeElement; const reflows: { parent: Element | null; classes: string }[] = []; Object.defineProperty(element, 'offsetHeight', { configurable: true, @@ -489,13 +814,13 @@ describe('OffcanvasComponent', () => { componentRef.setInput('visible', true); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(container); + expect(element.parentElement).toBe(container); componentRef.setInput('container', secondContainer); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(secondContainer); + expect(element.parentElement).toBe(secondContainer); secondContainer.remove(); }); @@ -505,37 +830,34 @@ describe('OffcanvasComponent', () => { componentRef.setInput('visible', true); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(container); + expect(element.parentElement).toBe(container); componentRef.setInput('portal', false); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.nativeElement.parentElement).toBe(originalParent); + expect(element.parentElement).toBe(originalParent); }); it('should detach the portal when the component is destroyed', async () => { - const localFixture = TestBed.createComponent(OffcanvasComponent); - const localComponentRef = localFixture.componentRef; - await vi.runAllTimersAsync(); - document.body.appendChild(localFixture.nativeElement); - - localComponentRef.setInput('portal', true); - localComponentRef.setInput('container', container); - localComponentRef.setInput('visible', true); - localFixture.detectChanges(); + componentRef.setInput('portal', true); + componentRef.setInput('container', container); + componentRef.setInput('visible', true); + fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(localFixture.nativeElement.parentElement).toBe(container); + expect(element.parentElement).toBe(container); - localFixture.destroy(); + fixture.destroy(); - expect(container.contains(localFixture.nativeElement)).toBe(false); + expect(container.contains(element)).toBe(false); }); }); }); describe('OffcanvasComponent responsive', () => { - let fixture: ComponentFixture; + let component: OffcanvasComponent; + let componentRef: ComponentRef; + let fixture: ComponentFixture; let layout: Subject; let queries: string[]; @@ -557,86 +879,94 @@ describe('OffcanvasComponent responsive', () => { } ] }); - fixture = TestBed.createComponent(OffcanvasComponent); - fixture.componentRef.setInput('responsive', 'lg'); + fixture = TestBed.createComponent(TestComponent); + componentRef = fixture.componentInstance.outlet().createComponent(OffcanvasComponent); + component = componentRef.instance; + componentRef.setInput('responsive', 'lg'); await fixture.whenStable(); }); - afterEach(() => { + afterEach(async () => { + component.visible.set(false); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); document.documentElement.style.removeProperty('--cui-breakpoint-lg'); }); it('should close when the viewport grows past its breakpoint', async () => { - fixture.componentRef.setInput('visible', true); + componentRef.setInput('visible', true); await fixture.whenStable(); expect(queries).toEqual(['(max-width: 991.98px)']); layout.next(true); await fixture.whenStable(); - expect(fixture.componentInstance.visible()).toBe(true); + expect(component.visible()).toBe(true); layout.next(false); await fixture.whenStable(); - expect(fixture.componentInstance.visible()).toBe(false); + expect(component.visible()).toBe(false); }); it('should watch the new breakpoint when responsive changes while open', async () => { - fixture.componentRef.setInput('responsive', true); - fixture.componentRef.setInput('visible', true); + componentRef.setInput('responsive', true); + componentRef.setInput('visible', true); await fixture.whenStable(); expect(queries).toEqual([]); - fixture.componentRef.setInput('responsive', 'lg'); + componentRef.setInput('responsive', 'lg'); await fixture.whenStable(); expect(queries).toEqual(['(max-width: 991.98px)']); layout.next(false); await fixture.whenStable(); - expect(fixture.componentInstance.visible()).toBe(false); + expect(component.visible()).toBe(false); }); it('should drop the previous breakpoint subscription when it watches again', async () => { - fixture.componentRef.setInput('visible', true); + componentRef.setInput('visible', true); await fixture.whenStable(); - fixture.componentInstance.visible.set(false); - fixture.componentInstance.visible.set(true); + component.visible.set(false); + component.visible.set(true); await fixture.whenStable(); expect(queries).toHaveLength(2); - fixture.componentRef.setInput('visible', false); + componentRef.setInput('visible', false); await fixture.whenStable(); expect(layout.observed).toBe(false); }); it('should stop watching the breakpoint when responsive turns off while open', async () => { - fixture.componentRef.setInput('visible', true); + componentRef.setInput('visible', true); await fixture.whenStable(); - fixture.componentRef.setInput('responsive', true); + componentRef.setInput('responsive', true); await fixture.whenStable(); layout.next(false); await fixture.whenStable(); - expect(fixture.componentInstance.visible()).toBe(true); + expect(component.visible()).toBe(true); }); it('should open and close through the show alias', async () => { - fixture.componentInstance.show = true; + component.show = true; await fixture.whenStable(); - expect(fixture.componentInstance.show).toBe(true); - expect(fixture.componentInstance.visible()).toBe(true); + expect(component.show).toBe(true); + expect(component.visible()).toBe(true); - fixture.componentInstance.show = false; + component.show = false; await fixture.whenStable(); - expect(fixture.componentInstance.visible()).toBe(false); + expect(component.visible()).toBe(false); }); }); describe('OffcanvasComponent with inputs changed while open', () => { - let fixture: ComponentFixture; + let component: OffcanvasComponent; + let componentRef: ComponentRef; + let element: HTMLElement; + let fixture: ComponentFixture; let setBackdrop: MockInstance; const update = async (name: string, value: unknown) => { - fixture.componentRef.setInput(name, value); + componentRef.setInput(name, value); fixture.detectChanges(); await vi.runAllTimersAsync(); }; @@ -645,11 +975,15 @@ describe('OffcanvasComponent with inputs changed while open', () => { TestBed.configureTestingModule({ providers: [{ provide: BreakpointObserver, useValue: { observe: () => NEVER } }] }); - fixture = TestBed.createComponent(OffcanvasComponent); - fixture.nativeElement.style.position = 'fixed'; + fixture = TestBed.createComponent(TestComponent); + componentRef = fixture.componentInstance.outlet().createComponent(OffcanvasComponent); + component = componentRef.instance; + element = componentRef.location.nativeElement; + element.style.position = 'fixed'; await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve)); setBackdrop = vi.spyOn(TestBed.inject(BackdropService), 'setBackdrop'); + expect(document.body.style.overflow).toBe(''); vi.useFakeTimers(); }); @@ -682,13 +1016,13 @@ describe('OffcanvasComponent with inputs changed while open', () => { setBackdrop.mock.results[0].value.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.componentInstance.visible()).toBe(true); + expect(component.visible()).toBe(true); }); it('should not close on a backdrop click once the backdrop turns static after opening with it switched on', async () => { await update('backdrop', false); - fixture.componentRef.setInput('visible', true); - fixture.componentRef.setInput('backdrop', true); + componentRef.setInput('visible', true); + componentRef.setInput('backdrop', true); fixture.detectChanges(); await vi.runAllTimersAsync(); await update('backdrop', 'static'); @@ -698,13 +1032,13 @@ describe('OffcanvasComponent with inputs changed while open', () => { setBackdrop.mock.results[0].value.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.componentInstance.visible()).toBe(true); + expect(component.visible()).toBe(true); }); it('should not close on a backdrop click once the backdrop turns static after closing and reopening in one tick', async () => { await update('visible', true); - fixture.componentInstance.visible.set(false); - fixture.componentInstance.visible.set(true); + component.visible.set(false); + component.visible.set(true); fixture.detectChanges(); await vi.runAllTimersAsync(); await update('backdrop', 'static'); @@ -714,7 +1048,7 @@ describe('OffcanvasComponent with inputs changed while open', () => { setBackdrop.mock.results[0].value.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.componentInstance.visible()).toBe(true); + expect(component.visible()).toBe(true); }); it('should close on a backdrop click once a static backdrop turns dismissible while open', async () => { @@ -726,18 +1060,35 @@ describe('OffcanvasComponent with inputs changed while open', () => { setBackdrop.mock.results[0].value.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.componentInstance.visible()).toBe(false); + expect(component.visible()).toBe(false); }); - it('should leave scrolling unlocked when scroll turns off while open', async () => { + it('should lock scrolling when scroll turns off while open', async () => { await update('scroll', true); await update('visible', true); expect(document.body.style.overflow).toBe(''); await update('scroll', false); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('should unlock scrolling when scroll turns on while open', async () => { + await update('visible', true); + expect(document.body.style.overflow).toBe('hidden'); + + await update('scroll', true); expect(document.body.style.overflow).toBe(''); }); + it('should keep scrolling locked when the backdrop goes off and on while open', async () => { + await update('visible', true); + await update('backdrop', false); + expect(document.body.style.overflow).toBe('hidden'); + + await update('backdrop', true); + expect(document.body.style.overflow).toBe('hidden'); + }); + it('should take the backdrop off and put it back while open', async () => { await update('visible', true); const first = setBackdrop.mock.results[0].value; @@ -753,7 +1104,7 @@ describe('OffcanvasComponent with inputs changed while open', () => { second.dispatchEvent(new MouseEvent('click')); fixture.detectChanges(); await vi.runAllTimersAsync(); - expect(fixture.componentInstance.visible()).toBe(false); + expect(component.visible()).toBe(false); }); it.each([ @@ -762,7 +1113,7 @@ describe('OffcanvasComponent with inputs changed while open', () => { ['backdrop', 'static'] ])('should keep focus inside the panel when %s changes while open', async (name, value) => { const button = document.createElement('button'); - fixture.nativeElement.append(button); + element.append(button); await update('visible', true); button.focus(); @@ -777,7 +1128,7 @@ describe('OffcanvasComponent with inputs changed while open', () => { ['backdrop', 'static'] ])('should emit visibleChange once per opening when %s changes while open', async (name, value) => { const emitted: boolean[] = []; - fixture.componentInstance.visibleChange.subscribe((visible) => emitted.push(visible)); + component.visibleChange.subscribe((visible) => emitted.push(visible)); await update('visible', true); await update(name, value); diff --git a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts index df8a8dd0..b4118a85 100644 --- a/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts +++ b/projects/coreui-angular/src/lib/offcanvas/offcanvas/offcanvas.component.ts @@ -29,6 +29,7 @@ import { filter } from 'rxjs/operators'; import { BackdropService } from '../../backdrop/backdrop.service'; import { ThemeDirective } from '../../shared'; import { restoreFocus } from '../../shared/focus.utils'; +import { afterTransitions } from '../../shared/transition.utils'; import { OffcanvasService } from '../offcanvas.service'; let nextId = 0; @@ -42,10 +43,10 @@ let nextId = 0; hostDirectives: [{ directive: ThemeDirective, inputs: ['dark'] }], host: { '[attr.id]': 'id()', - '[inert]': 'ariaHidden()', + '[inert]': 'hostInert()', '[attr.role]': 'visible() ? role() : null', '[aria-modal]': 'visible() ? ariaModal() : null', - '[attr.tabindex]': 'tabIndex', + '[attr.tabindex]': 'ariaHidden() ? null : tabIndex', '[class]': 'hostClasses()', '(document:keydown)': 'onKeyDownHandler($event)', '(window:resize)': 'onResizeHandler()' @@ -129,15 +130,16 @@ export class OffcanvasComponent implements OnInit, OnDestroy { #activeBackdrop!: HTMLDivElement; #backdropClickSubscription!: Subscription; #layoutChangeSubscription!: Subscription; - #hideFallbackId?: ReturnType; #focusBeforeShow: HTMLElement | null = null; + readonly #hidden = signal(true); #isShown = false; #shownAsPanel = false; + #transitionWait?: object; #trigger: HTMLElement | null = null; readonly #inPlace = signal(false); /** - * Allow body scrolling while offcanvas is visible. Read when the offcanvas opens. + * Allow body scrolling while the offcanvas is open. Changes apply while it is open. * @returns boolean * @default false */ @@ -206,6 +208,21 @@ export class OffcanvasComponent implements OnInit, OnDestroy { untracked(() => this.setBackdrop(backdrop)); }); + readonly #scrollLockEffect = effect(() => { + const visible = this.visible(); + const scroll = this.scroll(); + untracked(() => { + if (!visible) { + return; + } + if (scroll) { + this.#backdropService.unlockScroll(this); + } else { + this.#backdropService.lockScroll(this); + } + }); + }); + readonly #layoutEffect = effect(() => { const breakpoint = this.visible() && this.responsiveBreakpoint; untracked(() => this.layoutChangeSubscribe(breakpoint)); @@ -233,6 +250,10 @@ export class OffcanvasComponent implements OnInit, OnDestroy { return this.visible() || this.#inPlace() ? null : true; }); + protected readonly hostInert = computed(() => { + return this.#hidden() ? this.ariaHidden() : null; + }); + get tabIndex(): string | null { return '-1'; } @@ -259,22 +280,31 @@ export class OffcanvasComponent implements OnInit, OnDestroy { } animateStart(visible: boolean = this.visible()): void { - const wasShown = this.#isShown; + const host = this.#hostElement.nativeElement; this.#isShown = visible; - this.#clearHideFallback(); if (visible) { - if (!this.scroll()) { - this.#backdropService.hideScrollbar(); - } - this.#renderer.removeClass(this.#hostElement.nativeElement, 'hiding'); - this.#renderer.addClass(this.#hostElement.nativeElement, 'showing'); + this.#hidden.set(false); + this.#renderer.removeClass(host, 'hiding'); + this.#renderer.addClass(host, 'showing'); } else { - this.#renderer.removeClass(this.#hostElement.nativeElement, 'showing'); - if (wasShown) { - this.#renderer.addClass(this.#hostElement.nativeElement, 'hiding'); - this.#scheduleHideFallback(); + this.#renderer.removeClass(host, 'showing'); + if (this.#hidden()) { + return; } + this.#renderer.addClass(host, 'hiding'); } + const transitionWait = {}; + this.#transitionWait = transitionWait; + afterTransitions(host).then(() => { + if (this.#transitionWait !== transitionWait || this.visible() !== visible) { + return; + } + if (visible) { + this.#renderer.removeClass(host, 'showing'); + } else { + this.#completeHide(); + } + }); } onKeyDownHandler(event: KeyboardEvent): void { @@ -302,8 +332,6 @@ export class OffcanvasComponent implements OnInit, OnDestroy { // hotfix to avoid offcanvas flicker on the first render this.#renderer.setStyle(this.#hostElement.nativeElement, 'display', 'flex'); }); - - this.#hostElement.nativeElement.addEventListener('transitionend', this.#handleTransitionEnd); } ngOnDestroy(): void { @@ -311,50 +339,19 @@ export class OffcanvasComponent implements OnInit, OnDestroy { this.#restoreFocus(); } this.#offcanvasService.toggle({ show: false, id: this.id() }); - this.#removeEventListeners(); - this.#clearHideFallback(); + this.#transitionWait = undefined; this.setBackdrop(false); + this.#backdropService.unlockScroll(this); this.domPortalCleanup(); } - readonly #removeEventListeners = () => { - this.#hostElement.nativeElement.removeEventListener('transitionend', this.#handleTransitionEnd); - }; - - readonly #handleTransitionEnd = (event: TransitionEvent) => { - const offcanvasElement = this.#hostElement.nativeElement; - if (event.target === offcanvasElement && event.propertyName === 'transform') { - if (this.visible()) { - this.#renderer.removeClass(offcanvasElement, 'showing'); - } else { - this.#completeHide(); - } - } - }; - #completeHide(): void { - this.#clearHideFallback(); + this.#hidden.set(true); this.#renderer.removeClass(this.#hostElement.nativeElement, 'hiding'); - this.#renderer.removeStyle(this.#document.body, 'overflow'); - this.#renderer.removeStyle(this.#document.body, 'paddingRight'); + this.#backdropService.unlockScroll(this); this.domPortalCleanup(); } - // `transitionend` never fires when there is nothing to animate — reduced motion, or a responsive - // offcanvas above its breakpoint — which would leave the body scroll-locked and the host parked - // in the portal container. - #scheduleHideFallback(): void { - const style = this.#document.defaultView?.getComputedStyle(this.#hostElement.nativeElement); - const duration = Number.parseFloat(style?.transitionDuration ?? '') || 0; - const delay = Number.parseFloat(style?.transitionDelay ?? '') || 0; - this.#hideFallbackId = setTimeout(() => this.#completeHide(), (duration + delay) * 1000 + 5); - } - - #clearHideFallback(): void { - clearTimeout(this.#hideFallbackId); - this.#hideFallbackId = undefined; - } - #readPosition(): void { const position = this.#document.defaultView?.getComputedStyle(this.#hostElement.nativeElement).position; this.#inPlace.set(!!position && position !== 'fixed'); diff --git a/projects/coreui-angular/src/lib/shared/transition.utils.spec.ts b/projects/coreui-angular/src/lib/shared/transition.utils.spec.ts new file mode 100644 index 00000000..653681a1 --- /dev/null +++ b/projects/coreui-angular/src/lib/shared/transition.utils.spec.ts @@ -0,0 +1,127 @@ +/// +import { afterTransitions } from './transition.utils'; + +const fakeAnimations = (element: Element) => { + const running: Animation[] = []; + Object.defineProperty(element, 'getAnimations', { configurable: true, value: () => [...running] }); + return (kind: 'transition' | 'animation' = 'transition') => { + let end!: () => void; + let cancel!: () => void; + const finished = new Promise((resolve, reject) => { + end = () => { + running.splice(running.indexOf(animation), 1); + resolve(); + }; + cancel = () => { + running.splice(running.indexOf(animation), 1); + reject(new DOMException('cancelled', 'AbortError')); + }; + }); + const animation = ( + kind === 'transition' ? { transitionProperty: 'opacity', finished } : { animationName: 'spin', finished } + ) as unknown as Animation; + running.push(animation); + return { end, cancel }; + }; +}; + +const flush = () => new Promise((resolve) => setTimeout(resolve)); + +describe('afterTransitions', () => { + let element: HTMLDivElement; + let done: boolean; + + const track = (promise: Promise) => { + done = false; + promise.then(() => (done = true)); + }; + + beforeEach(() => { + element = document.createElement('div'); + }); + + it('should resolve when the element has no getAnimations', async () => { + Object.defineProperty(element, 'getAnimations', { value: undefined }); + await expect(afterTransitions(element)).resolves.toBeUndefined(); + }); + + it('should resolve in a microtask, not synchronously, when nothing transitions', async () => { + fakeAnimations(element); + track(afterTransitions(element)); + expect(done).toBe(false); + await Promise.resolve(); + await Promise.resolve(); + expect(done).toBe(true); + }); + + it('should wait for every running transition', async () => { + const start = fakeAnimations(element); + const first = start(); + const second = start(); + track(afterTransitions(element)); + + first.end(); + await flush(); + expect(done).toBe(false); + + second.end(); + await flush(); + expect(done).toBe(true); + }); + + it('should resolve when a transition is cancelled and nothing replaces it', async () => { + const start = fakeAnimations(element); + const transition = start(); + track(afterTransitions(element)); + + transition.cancel(); + await flush(); + expect(done).toBe(true); + }); + + it('should follow the transition that replaces a cancelled one', async () => { + const start = fakeAnimations(element); + const cancelled = start(); + track(afterTransitions(element)); + + const replacement = start(); + cancelled.cancel(); + await flush(); + expect(done).toBe(false); + + replacement.end(); + await flush(); + expect(done).toBe(true); + }); + + it('should not wait for a transition that starts after the others finished', async () => { + const start = fakeAnimations(element); + const first = start(); + track(afterTransitions(element)); + + start(); + first.end(); + await flush(); + expect(done).toBe(true); + }); + + it('should ignore script animations', async () => { + let end!: () => void; + const finished = new Promise((resolve) => (end = resolve)); + Object.defineProperty(element, 'getAnimations', { value: () => [{ finished }] }); + await expect(afterTransitions(element)).resolves.toBeUndefined(); + end(); + }); + + it('should read the animations of the element itself, not of its subtree', async () => { + const getAnimations = vi.fn(() => []); + Object.defineProperty(element, 'getAnimations', { value: getAnimations }); + await afterTransitions(element); + expect(getAnimations).toHaveBeenCalledWith(); + }); + + it('should ignore animations that are not transitions', async () => { + fakeAnimations(element)('animation'); + await expect(afterTransitions(element)).resolves.toBeUndefined(); + }); +}); diff --git a/projects/coreui-angular/src/lib/shared/transition.utils.ts b/projects/coreui-angular/src/lib/shared/transition.utils.ts new file mode 100644 index 00000000..0f5fdc5d --- /dev/null +++ b/projects/coreui-angular/src/lib/shared/transition.utils.ts @@ -0,0 +1,15 @@ +/** + * Waits for the CSS transitions running on an element to finish. + * Reads the running transitions at once, so call it right after the style change that starts them; when one of them is + * cancelled (a later style change replaced it), reads again and waits for the transitions running then. + * Ignores CSS animations, script animations and transitions of descendants. + * @param element the element whose own transitions to wait for + * @returns a promise that never rejects; it resolves in a microtask when nothing transitions or the platform has no `getAnimations` (jsdom, server) + */ +export const afterTransitions = async (element: Element): Promise => { + const running = () => element.getAnimations?.().filter((animation) => 'transitionProperty' in animation) ?? []; + for (let transitions = running(); transitions.length; ) { + const results = await Promise.allSettled(transitions.map((transition) => transition.finished)); + transitions = results.some(({ status }) => status === 'rejected') ? running() : []; + } +};