Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/components-dev/toast/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -90,8 +92,10 @@ export class DevToastComponent extends KbqToastComponent {
KbqProgressBarModule,
KbqDropdownModule,
KbqModalModule,
KbqPopoverModule,
KbqSidepanelModule,
KbqScrollbar,
KbqToolTipModule,
DevToastComponent,
DevDocsExamples
],
Expand Down
17 changes: 17 additions & 0 deletions packages/components-dev/toast/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@

<ng-template #template let-data>Template with data.title: {{ data.title }}</ng-template>

<!-- DS-5407: open a pop-up, then fire a toast — the pop-up must stay open. -->
<div class="dev-block">
<button kbq-button class="dev-button" [kbqTooltip]="'Must survive a toast'">
Tooltip (close-on-scroll)
</button>
<button
kbq-button
kbqPopover
class="dev-button"
[kbqPopoverContent]="'Must survive a toast'"
[closeOnScroll]="true"
>
Popover (closeOnScroll)
</button>
<button kbq-button class="dev-button" (click)="showToast('success')">Show toast</button>
</div>

<div class="dev-block">
<button kbq-button class="dev-button" (click)="showToast('contrast')">contrast</button>
<button kbq-button class="dev-button" (click)="showToast('success')">success</button>
Expand Down
27 changes: 12 additions & 15 deletions packages/components/toast/toast-container.component.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -35,16 +33,6 @@ export class KbqToastContainerComponent extends CdkScrollable {

readonly viewContainer = viewChild.required('container', { read: ViewContainerRef });

constructor() {
const elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
const scrollDispatcher = inject(ScrollDispatcher);
const ngZone = inject(NgZone);

super(elementRef, scrollDispatcher, ngZone);

this.service.animation.subscribe(this.dispatchScrollEvent);
}

createToast<C>(data: KbqToastData, componentType, onTop: boolean): ComponentRef<C> {
const injector = this.getInjector(data);
const index = onTop ? 0 : undefined;
Expand Down Expand Up @@ -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'));
};
}
157 changes: 153 additions & 4 deletions packages/components/toast/toast.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: `
<button [kbqTooltip]="'TOOLTIP_CONTENT'">Trigger</button>
`
})
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();
});
});
3 changes: 1 addition & 2 deletions tools/public_api_guard/components/toast.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,11 @@ export const kbqToastConfigurationProvider: (configuration: Partial<KbqToastConf

// @public (undocumented)
export class KbqToastContainerComponent extends CdkScrollable {
constructor();
// (undocumented)
createTemplate<C>(data: KbqToastData, template: TemplateRef<any>, onTop: boolean): EmbeddedViewRef<C>;
// (undocumented)
createToast<C>(data: KbqToastData, componentType: any, onTop: boolean): ComponentRef<C>;
// (undocumented)
// @deprecated
dispatchScrollEvent: () => void;
// (undocumented)
getInjector(data: KbqToastData): Injector;
Expand Down
Loading