Skip to content
Merged
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
85 changes: 53 additions & 32 deletions packages/components/toast/toast.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const CHECK_INTERVAL = 500;

let templateId = 0;

/** Generic `T` is a type hint only; the runtime component comes from `KBQ_TOAST_FACTORY`. */
@Injectable({ providedIn: 'root' })
export class KbqToastService<T extends KbqToastComponent = KbqToastComponent> implements OnDestroy {
get toasts(): ComponentRef<T>[] {
Expand All @@ -47,10 +48,11 @@ export class KbqToastService<T extends KbqToastComponent = KbqToastComponent> im
shareReplay()
);

private containerInstance: KbqToastContainerComponent;
private overlayRef: OverlayRef;
private portal: ComponentPortal<KbqToastContainerComponent>;
private containerInstance?: KbqToastContainerComponent;
private overlayRef?: OverlayRef;
private portal?: ComponentPortal<KbqToastContainerComponent>;
private timerSubscription: Subscription;
private currentPosition?: KbqToastPosition;

private toastsDict: { [id: number]: ComponentRef<T> } = {};
private templatesDict: { [id: number]: EmbeddedViewRef<T> } = {};
Expand All @@ -70,16 +72,22 @@ export class KbqToastService<T extends KbqToastComponent = KbqToastComponent> im

ngOnDestroy(): void {
this.timerSubscription.unsubscribe();
this.overlayRef?.dispose();
this.overlayRef = undefined;
this.containerInstance = undefined;
this.portal = undefined;
this.currentPosition = undefined;
this.toastsDict = {};
this.templatesDict = {};
}

show(
data: KbqToastData,
duration: number = this.toastConfig.duration,
onTop: boolean = this.toastConfig.onTop
): { ref: ComponentRef<T>; id: number } {
this.prepareContainer();

const componentRef = this.containerInstance.createToast<T>(data, this.toastFactory, onTop);
const container = this.prepareContainer();
const componentRef = container.createToast<T>(data, this.toastFactory, onTop);

this.toastsDict[componentRef.instance.id] = componentRef;

Expand All @@ -95,17 +103,14 @@ export class KbqToastService<T extends KbqToastComponent = KbqToastComponent> im
duration: number = this.toastConfig.duration,
onTop: boolean = this.toastConfig.onTop
): { ref: EmbeddedViewRef<T>; id: number } {
this.prepareContainer();

const viewRef = this.containerInstance.createTemplate<T>(data, template, onTop);

this.templatesDict[templateId] = viewRef;
const container = this.prepareContainer();
const viewRef = container.createTemplate<T>(data, template, onTop);
const id = templateId++;

this.addRemoveTimer(templateId, duration);
this.templatesDict[id] = viewRef;
this.addRemoveTimer(id, duration);

templateId++;

return { ref: viewRef, id: templateId };
return { ref: viewRef, id };
}

hide(id: number) {
Expand All @@ -115,7 +120,7 @@ export class KbqToastService<T extends KbqToastComponent = KbqToastComponent> im
return;
}

this.containerInstance.remove(componentRef.hostView);
this.containerInstance?.remove(componentRef.hostView);

delete this.toastsDict[id];

Expand All @@ -129,19 +134,19 @@ export class KbqToastService<T extends KbqToastComponent = KbqToastComponent> im
return;
}

this.containerInstance.remove(viewRef);
this.containerInstance?.remove(viewRef);

delete this.templatesDict[id];

this.detachOverlay();
}

private detachOverlay() {
if (this.toasts.length !== 0) {
if (this.toasts.length !== 0 || this.templates.length !== 0) {
return;
}

this.overlayRef.detach();
this.overlayRef?.detach();
}

private processToasts = () => {
Expand Down Expand Up @@ -170,38 +175,54 @@ export class KbqToastService<T extends KbqToastComponent = KbqToastComponent> im
setTimeout(() => this.hideTemplate(id), duration);
}

private prepareContainer() {
this.createOverlay();
private prepareContainer(): KbqToastContainerComponent {
const overlayRef = this.createOverlay();
const portal = this.portal || new ComponentPortal(KbqToastContainerComponent, null, this.injector);

this.portal = this.portal || new ComponentPortal(KbqToastContainerComponent, null, this.injector);
this.portal = portal;

if (!this.overlayRef.hasAttached()) {
this.containerInstance = this.overlayRef.attach(this.portal).instance;
if (!overlayRef.hasAttached()) {
this.containerInstance = overlayRef.attach(portal).instance;
this.containerInstance
.getElementRef()
.nativeElement.classList.add(`kbq-toast-container-${this.toastConfig.position}`);
}

this.toTop();
this.toTop(overlayRef);

return this.containerInstance!;
}

private toTop() {
private toTop(overlayRef: OverlayRef) {
const overlays = this.overlayContainer.getContainerElement().childNodes;

if (overlays.length > 1) {
overlays[overlays.length - 1].after(this.overlayRef.hostElement);
overlays[overlays.length - 1].after(overlayRef.hostElement);
}
}

private createOverlay() {
if (this.overlayRef) {
private createOverlay(): OverlayRef {
const expectedPosition = this.toastConfig.position;

if (this.overlayRef && this.currentPosition === expectedPosition) {
return this.overlayRef;
}

const positionStrategy = this.getPositionStrategy(this.toastConfig.position);
if (this.overlayRef) {
this.overlayRef.dispose();
this.containerInstance = undefined;
this.portal = undefined;
}

const positionStrategy = this.getPositionStrategy(expectedPosition);
const overlayRef = this.overlay.create({ positionStrategy });

overlayRef.hostElement.classList.add('kbq-toast-overlay');

this.overlayRef = overlayRef;
this.currentPosition = expectedPosition;

this.overlayRef = this.overlay.create({ positionStrategy });
this.overlayRef.hostElement.classList.add('kbq-toast-overlay');
return overlayRef;
}

private getPositionStrategy(position?: KbqToastPosition): GlobalPositionStrategy {
Expand Down
70 changes: 69 additions & 1 deletion packages/components/toast/toast.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { Component, NgZone } from '@angular/core';
import { Component, NgZone, TemplateRef, ViewChild } from '@angular/core';
import { TestBed, discardPeriodicTasks, fakeAsync, flush, inject, tick } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Subject } from 'rxjs';
Expand Down Expand Up @@ -208,3 +208,71 @@ class KbqToastButtonWrapperComponent {
this.toastService.show({ style: 'warning', title: 'Warning', content: 'Message Content' }, 0);
}
}

@Component({
selector: 'kbq-toast-template-wrapper',
standalone: true,
imports: [KbqToastModule],
template: `
<ng-template #tpl>tpl</ng-template>
`
})
class KbqToastTemplateWrapperComponent {
@ViewChild('tpl', { static: true }) template!: TemplateRef<unknown>;
}

describe('ToastService regression: multiple containers / cleanup', () => {
let service: KbqToastService;
let overlayContainer: OverlayContainer;
let overlayContainerElement: HTMLElement;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [KbqToastModule, NoopAnimationsModule, KbqToastTemplateWrapperComponent]
}).compileComponents();

service = TestBed.inject(KbqToastService);
overlayContainer = TestBed.inject(OverlayContainer);
overlayContainerElement = overlayContainer.getContainerElement();
});

afterEach(() => {
overlayContainer.ngOnDestroy();
});

it('disposes overlay on service destroy so re-bootstrap does not leak a second container', () => {
service.show(MOCK_TOAST_DATA, 0);
expect(overlayContainerElement.querySelectorAll('.kbq-toast-overlay').length).toBe(1);

service.ngOnDestroy();
expect(overlayContainerElement.querySelectorAll('.kbq-toast-overlay').length).toBe(0);
});

it('hideTemplate removes by returned id (regression: off-by-one)', () => {
const fixture = TestBed.createComponent(KbqToastTemplateWrapperComponent);

fixture.detectChanges();

const { id } = service.showTemplate(MOCK_TOAST_DATA, fixture.componentInstance.template, 0);

expect(service.templates.length).toBe(1);

service.hideTemplate(id);
expect(service.templates.length).toBe(0);
});

it('keeps container alive while templates are visible after the last toast is hidden', () => {
const fixture = TestBed.createComponent(KbqToastTemplateWrapperComponent);

fixture.detectChanges();

const toast = service.show(MOCK_TOAST_DATA, 0);

service.showTemplate(MOCK_TOAST_DATA, fixture.componentInstance.template, 0);

service.hide(toast.id);

expect(service.templates.length).toBe(1);
expect(overlayContainerElement.querySelectorAll('kbq-toast-container').length).toBe(1);
});
});
2 changes: 1 addition & 1 deletion tools/public_api_guard/components/toast.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export enum KbqToastPosition {
TOP_RIGHT = "top-right"
}

// @public (undocumented)
// @public
export class KbqToastService<T extends KbqToastComponent = KbqToastComponent> implements OnDestroy {
constructor(overlay: Overlay, injector: Injector, overlayContainer: OverlayContainer, ngZone: NgZone, toastFactory: any, toastConfig: KbqToastConfig);
// (undocumented)
Expand Down
Loading