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;