diff --git a/packages/components-dev/toast/module.ts b/packages/components-dev/toast/module.ts index ebc7fa26a..2b08f9b50 100644 --- a/packages/components-dev/toast/module.ts +++ b/packages/components-dev/toast/module.ts @@ -5,6 +5,7 @@ import { KbqDropdownModule } from '@koobiq/components/dropdown'; import { KbqIconModule } from '@koobiq/components/icon'; import { KbqLinkModule } from '@koobiq/components/link'; import { KbqModalModule, KbqModalService } from '@koobiq/components/modal'; +import { KbqPopoverModule } from '@koobiq/components/popover'; import { KbqProgressBarModule } from '@koobiq/components/progress-bar'; import { KbqScrollbar } from '@koobiq/components/scrollbar'; import { KbqSidepanelModule, KbqSidepanelPosition, KbqSidepanelService } from '@koobiq/components/sidepanel'; @@ -15,6 +16,7 @@ import { KbqToastService, KbqToastStyle } from '@koobiq/components/toast'; +import { KbqToolTipModule } from '@koobiq/components/tooltip'; import { ToastExamplesModule } from '../../docs-examples/components/toast'; @Component({ @@ -90,8 +92,10 @@ export class DevToastComponent extends KbqToastComponent { KbqProgressBarModule, KbqDropdownModule, KbqModalModule, + KbqPopoverModule, KbqSidepanelModule, KbqScrollbar, + KbqToolTipModule, DevToastComponent, DevDocsExamples ], diff --git a/packages/components-dev/toast/template.html b/packages/components-dev/toast/template.html index c20f643e9..0b8c7dd6b 100644 --- a/packages/components-dev/toast/template.html +++ b/packages/components-dev/toast/template.html @@ -89,6 +89,23 @@ Template with data.title: {{ data.title }} + +
+ + + +
+
diff --git a/packages/components/toast/toast-container.component.ts b/packages/components/toast/toast-container.component.ts index befb73928..be7bd21b2 100644 --- a/packages/components/toast/toast-container.component.ts +++ b/packages/components/toast/toast-container.component.ts @@ -1,13 +1,11 @@ -import { CdkScrollable, ScrollDispatcher } from '@angular/cdk/overlay'; +import { CdkScrollable } from '@angular/cdk/overlay'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, - ElementRef, EmbeddedViewRef, Injector, - NgZone, TemplateRef, ViewContainerRef, ViewEncapsulation, @@ -35,16 +33,6 @@ export class KbqToastContainerComponent extends CdkScrollable { readonly viewContainer = viewChild.required('container', { read: ViewContainerRef }); - constructor() { - const elementRef = inject>(ElementRef); - const scrollDispatcher = inject(ScrollDispatcher); - const ngZone = inject(NgZone); - - super(elementRef, scrollDispatcher, ngZone); - - this.service.animation.subscribe(this.dispatchScrollEvent); - } - createToast(data: KbqToastData, componentType, onTop: boolean): ComponentRef { const injector = this.getInjector(data); const index = onTop ? 0 : undefined; @@ -77,7 +65,16 @@ export class KbqToastContainerComponent extends CdkScrollable { }); } - dispatchScrollEvent = () => { - this.elementRef.nativeElement.dispatchEvent(new CustomEvent('scroll')); + /** + * Fakes a scroll on the container so that overlays anchored inside a toast are repositioned by their + * `RepositionScrollStrategy` when the stack shifts. + * + * @deprecated The container is a registered `CdkScrollable`, so this reaches the application-wide + * `ScrollDispatcher` and closes every unrelated overlay that uses a close-on-scroll strategy. It is no longer + * called automatically and is kept only for callers that already hold a container reference — the instance + * created by `KbqToastService` is not exposed. + */ +dispatchScrollEvent = () => { + this.elementRef.nativeElement.dispatchEvent(new Event('scroll')); }; } diff --git a/packages/components/toast/toast.spec.ts b/packages/components/toast/toast.spec.ts index 459bd0c7a..c8ea925f1 100644 --- a/packages/components/toast/toast.spec.ts +++ b/packages/components/toast/toast.spec.ts @@ -1,10 +1,22 @@ -import { OverlayContainer } from '@angular/cdk/overlay'; -import { Component, NgZone, TemplateRef, inject as inject_1, viewChild } from '@angular/core'; +import { AnimationEvent } from '@angular/animations'; +import { Overlay, OverlayContainer, OverlayRef, ScrollDispatcher } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { + ApplicationRef, + Component, + ElementRef, + NgZone, + TemplateRef, + inject as inject_1, + viewChild +} from '@angular/core'; import { TestBed, discardPeriodicTasks, fakeAsync, flush, inject, tick } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { kbqShadowDomOverlayProvider } from '@koobiq/components/core'; -import { Subject } from 'rxjs'; +import { dispatchMouseEvent, kbqShadowDomOverlayProvider } from '@koobiq/components/core'; +import { KbqToolTipModule, KbqTooltipTrigger } from '@koobiq/components/tooltip'; +import { Subject, Subscription } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { KbqToastContainerComponent } from './toast-container.component'; import { KbqToastModule } from './toast.module'; import { KbqToastService } from './toast.service'; import { KbqToastData } from './toast.type'; @@ -316,3 +328,140 @@ describe('ToastService in a Shadow DOM overlay container', () => { expect(document.body.querySelectorAll('kbq-toast').length).toBe(0); }); }); + +@Component({ + selector: 'toast-tooltip-wrapper', + imports: [KbqToolTipModule], + template: ` + + ` +}) +class ToastTooltipWrapper { + readonly triggerElementRef = viewChild.required(KbqTooltipTrigger, { read: ElementRef }); +} + +@Component({ + selector: 'toast-overlay-content', + template: 'OVERLAY_CONTENT' +}) +class ToastOverlayContent {} + +describe('ToastService: global scroll notifications', () => { + // `KbqTooltipTrigger` default enter delay (400ms) plus a buffer for the deferred show. + const tooltipEnterDelay = 410; + + let service: KbqToastService; + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + let scrolled: jest.Mock; + let scrollSubscription: Subscription; + + /** Emulates what the animation callbacks of every toast push into `KbqToastService.animation`. */ + const emitToastAnimationEvent = () => + service.animation.next({ + fromState: 'void', + toState: 'visible', + totalTime: 0, + phaseName: 'done', + element: document.createElement('div'), + triggerName: 'state', + disabled: false + } satisfies AnimationEvent); + + /** Renders the toast container and the toast itself — the container registers as a scrollable in `ngOnInit`. */ + const renderToast = () => { + const { id } = service.show(MOCK_TOAST_DATA, 0); + + TestBed.inject(ApplicationRef).tick(); + + return id; + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [KbqToastModule, NoopAnimationsModule, ToastTooltipWrapper] + }).compileComponents(); + + service = TestBed.inject(KbqToastService); + overlayContainer = TestBed.inject(OverlayContainer); + overlayContainerElement = overlayContainer.getContainerElement(); + scrolled = jest.fn(); + scrollSubscription = TestBed.inject(ScrollDispatcher).scrolled(0).subscribe(scrolled); + }); + + afterEach(() => { + scrollSubscription.unsubscribe(); + overlayContainer.ngOnDestroy(); + }); + + it('does not notify the global ScrollDispatcher when a toast is shown, animated and hidden', () => { + const id = renderToast(); + + emitToastAnimationEvent(); + service.hide(id); + + expect(scrolled).not.toHaveBeenCalled(); + }); + + it('keeps an overlay with the close-on-scroll strategy attached when a toast appears', () => { + // Models a third-party overlay (the reported case is a Mosaic popover in a micro-frontend): + // `CloseScrollStrategy` detaches on any emission that did not originate inside its own overlay. + const overlay = TestBed.inject(Overlay); + const overlayRef: OverlayRef = overlay.create({ scrollStrategy: overlay.scrollStrategies.close() }); + + overlayRef.attach(new ComponentPortal(ToastOverlayContent)); + expect(overlayRef.hasAttached()).toBe(true); + + renderToast(); + emitToastAnimationEvent(); + + expect(overlayRef.hasAttached()).toBe(true); + + overlayRef.dispose(); + }); + + it('keeps an open tooltip open when a toast appears', fakeAsync(() => { + const fixture = TestBed.createComponent(ToastTooltipWrapper); + + fixture.detectChanges(); + + dispatchMouseEvent(fixture.componentInstance.triggerElementRef().nativeElement, 'mouseenter'); + fixture.detectChanges(); + tick(tooltipEnterDelay); + fixture.detectChanges(); + expect(overlayContainerElement.querySelector('.kbq-tooltip')).toBeTruthy(); + + renderToast(); + emitToastAnimationEvent(); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.kbq-tooltip')).toBeTruthy(); + + flush(); + discardPeriodicTasks(); + })); + + it('keeps the container registered as a scrollable, so a real scroll still reaches the dispatcher', () => { + renderToast(); + + overlayContainerElement.querySelector('kbq-toast-container')!.dispatchEvent(new Event('scroll')); + + expect(scrolled).toHaveBeenCalled(); + }); + + it('dispatches a scroll event on the container element when `dispatchScrollEvent` is called explicitly', () => { + const fixture = TestBed.createComponent(KbqToastContainerComponent); + const onScroll = jest.fn(); + + fixture.detectChanges(); + fixture.nativeElement.addEventListener('scroll', onScroll); + + // Called detached, because the deprecated API is documented as a callback and must stay bound. + const { dispatchScrollEvent } = fixture.componentInstance; + + dispatchScrollEvent(); + + expect(onScroll).toHaveBeenCalled(); + }); +}); diff --git a/tools/public_api_guard/components/toast.api.md b/tools/public_api_guard/components/toast.api.md index a1f597f91..bd6daa3a6 100644 --- a/tools/public_api_guard/components/toast.api.md +++ b/tools/public_api_guard/components/toast.api.md @@ -108,12 +108,11 @@ export const kbqToastConfigurationProvider: (configuration: Partial(data: KbqToastData, template: TemplateRef, onTop: boolean): EmbeddedViewRef; // (undocumented) createToast(data: KbqToastData, componentType: any, onTop: boolean): ComponentRef; - // (undocumented) + // @deprecated dispatchScrollEvent: () => void; // (undocumented) getInjector(data: KbqToastData): Injector;