From d8328efee42fdaf56a363e36cbca8dd761a4b14a Mon Sep 17 00:00:00 2001 From: sumeyye Date: Mon, 15 Jun 2026 16:57:14 +0300 Subject: [PATCH] update: theme-shared package for the latest upgrade (state management and html properties) --- .../packages/theme-shared/project.json | 2 +- .../lib/components/button/button.component.ts | 26 ++++------- .../lib/components/modal/modal.component.ts | 33 +++++++++---- .../toast-container.component.html | 10 ++-- .../toast-container.component.scss | 8 +++- .../toast-container.component.ts | 33 +++++-------- .../lib/components/toast/toast.component.scss | 27 +++++++++++ .../lib/components/toast/toast.component.ts | 14 +++++- .../theme-shared/src/lib/constants/styles.ts | 25 ++++++++++ .../src/lib/services/toaster.service.ts | 46 +++++++++++-------- .../src/lib/tests/button.component.spec.ts | 27 ++++++++++- 11 files changed, 174 insertions(+), 77 deletions(-) diff --git a/npm/ng-packs/packages/theme-shared/project.json b/npm/ng-packs/packages/theme-shared/project.json index 2bfa7e6574..5b7897d66d 100644 --- a/npm/ng-packs/packages/theme-shared/project.json +++ b/npm/ng-packs/packages/theme-shared/project.json @@ -31,7 +31,7 @@ "executor": "@nx/vitest:test", "outputs": ["{options.reportsDirectory}"], "options": { - "reportsDirectory": "../../coverage/packages/theme-shared" + "reportsDirectory": "{projectRoot}/../../coverage/packages/theme-shared" } } } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts index 010e66f8cb..6b4b978117 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts @@ -40,23 +40,13 @@ export class ButtonComponent implements OnInit { readonly buttonType = input('button'); readonly formName = input(undefined); readonly iconClass = input(undefined); - readonly loadingInput = input(false, { alias: 'loading' }); + readonly loading = input(false); readonly disabled = input(false); readonly attributes = input | undefined>(undefined); - // Internal writable signal for loading state - can be set programmatically - private readonly _loading = signal(false); + private readonly modalLoading = signal(null); - // Computed that combines input and internal state - readonly isLoading = computed(() => this.loadingInput() || this._loading()); - - // Getter/setter for backward compatibility (used by ModalComponent) - get loading(): boolean { - return this._loading(); - } - set loading(value: boolean) { - this._loading.set(value); - } + readonly isLoading = computed(() => this.modalLoading() ?? this.loading()); readonly click = output(); readonly focus = output(); @@ -67,9 +57,13 @@ export class ButtonComponent implements OnInit { readonly buttonRef = viewChild.required>('button'); - protected readonly icon = computed(() => { - return this.isLoading() ? 'fa fa-spinner fa-spin' : this.iconClass() || 'd-none'; - }); + protected readonly icon = computed(() => + this.isLoading() ? 'fa fa-spinner fa-spin' : this.iconClass() || 'd-none', + ); + + setLoading(value: boolean): void { + this.modalLoading.set(value); + } ngOnInit() { const attributes = this.attributes(); diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/modal/modal.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/modal/modal.component.ts index 107de42b0f..b1f2377cd0 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/modal/modal.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/modal/modal.component.ts @@ -45,14 +45,7 @@ export class ModalComponent implements OnInit, OnDestroy, DismissableModal { visible = model(false); - busy = input(false, { - transform: (value: boolean) => { - if (this.abpSubmit() && this.abpSubmit() instanceof ButtonComponent) { - this.abpSubmit().loading = value; - } - return value; - }, - }); + busy = input(false); options = input({ keyboard: true }); @@ -92,6 +85,15 @@ export class ModalComponent implements OnInit, OnDestroy, DismissableModal { effect(() => { this.toggle(this.visible()); }); + + effect(() => { + const submit = this.abpSubmit(); + if (!(submit instanceof ButtonComponent)) { + return; + } + + submit.setLoading(this.visible() && this.busy()); + }); } ngOnInit(): void { @@ -115,11 +117,20 @@ export class ModalComponent implements OnInit, OnDestroy, DismissableModal { this.visible.set(value); if (!value) { - this.modalRef?.dismiss(); + if (this.modalRef) { + const ref = this.modalRef; + this.modalRef = undefined!; + ref.dismiss(); + } + this.disappear.emit(); return; } + if (this.modalWindowRef) { + return; + } + setTimeout(() => this.listen(), 0); this.modalRef = this.modal.open(this.modalContent(), { size: 'md', @@ -136,6 +147,10 @@ export class ModalComponent implements OnInit, OnDestroy, DismissableModal { windowClass: `${this.options().windowClass || ''} ${this.modalIdentifier}`, }); + this.modalRef.result.finally(() => { + this.modalRef = undefined!; + }); + this.appear.emit(); } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.html b/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.html index 9969fd693f..3d19394115 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.html +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.html @@ -4,13 +4,9 @@ [style.right]="right() || 'auto'" [style.bottom]="bottom() || 'auto'" [style.left]="left() || 'auto'" - [style.display]="toasts.length ? 'flex' : 'none'" - [@toastInOut]="toasts.length" + [style.display]="toasts().length ? 'flex' : 'none'" > - @for (toast of toasts; track toast.options?.id) { - + @for (toast of toasts(); track toast.options?.id) { + } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.scss b/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.scss index 27e5e2dffa..881ff31f84 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.scss +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.scss @@ -1,3 +1,9 @@ +:host.abp-toast-host { + position: fixed; + z-index: 1900; + pointer-events: none; +} + .abp-toast-container { position: fixed; display: flex; @@ -6,7 +12,7 @@ justify-content: flex-end; min-width: 350px; min-height: 80px; - z-index: 1900; + pointer-events: auto; &.new-on-top { flex-direction: column-reverse; } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.ts index 11225f7b55..97bb3a02a0 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/toast-container/toast-container.component.ts @@ -1,6 +1,4 @@ -import { Component, OnInit, input, signal, effect } from '@angular/core'; -import { ReplaySubject } from 'rxjs'; -import { toastInOut } from '../../animations/toast.animations'; +import { Component, input, OnInit, signal, effect } from '@angular/core'; import { Toaster } from '../../models/toaster'; import { ToastComponent } from '../toast/toast.component'; @@ -8,18 +6,16 @@ import { ToastComponent } from '../toast/toast.component'; selector: 'abp-toast-container', templateUrl: './toast-container.component.html', styleUrls: ['./toast-container.component.scss'], - animations: [toastInOut], imports: [ToastComponent], host: { - '(window:resize)': 'onWindowResize()' - } + class: 'abp-toast-host', + '(window:resize)': 'onWindowResize()', + }, }) export class ToastContainerComponent implements OnInit { - toasts$!: ReplaySubject; - remove!: (toastId: number) => void; - toasts = [] as Toaster.Toast[]; + readonly toasts = signal([]); readonly top = input(undefined); readonly rightInput = input('30px', { alias: 'right' }); @@ -39,13 +35,13 @@ export class ToastContainerComponent implements OnInit { ngOnInit() { this.setDefaultRight(); - this.toasts$.subscribe(toasts => { - this.toasts = this.toastKey() - ? toasts.filter(t => { - return t.options && t.options.containerKey !== this.toastKey(); - }) - : toasts; - }); + } + + setToasts(toasts: Toaster.Toast[]) { + const key = this.toastKey(); + this.toasts.set( + key ? toasts.filter(t => t.options && t.options.containerKey !== key) : [...toasts], + ); } onWindowResize() { @@ -58,9 +54,4 @@ export class ToastContainerComponent implements OnInit { this.right.set(this.defaultMobileRight); } } - - trackByFunc(index: number, toast: Toaster.Toast) { - if (!toast) return null; - return toast.options?.id; - } } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/toast/toast.component.scss b/npm/ng-packs/packages/theme-shared/src/lib/components/toast/toast.component.scss index cf1b155427..e7264ad83c 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/toast/toast.component.scss +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/toast/toast.component.scss @@ -14,6 +14,33 @@ $toastClass: abp-toast; +:host { + display: block; + animation: abp-toast-in 350ms ease; +} + +:host.abp-toast-leaving { + animation: abp-toast-out 450ms ease forwards; +} + +@keyframes abp-toast-in { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes abp-toast-out { + to { + opacity: 0; + } +} + .#{$toastClass} { display: grid; grid-template-columns: 35px 1fr; diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/toast/toast.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/toast/toast.component.ts index e5a5f78684..7eca3e37ed 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/toast/toast.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/toast/toast.component.ts @@ -6,6 +6,9 @@ import { LocalizationPipe } from '@abp/ng.core'; selector: 'abp-toast', templateUrl: './toast.component.html', styleUrls: ['./toast.component.scss'], + host: { + '[class.abp-toast-leaving]': 'isLeaving', + }, imports: [LocalizationPipe], }) export class ToastComponent implements OnInit { @@ -50,8 +53,17 @@ export class ToastComponent implements OnInit { }, timeout); } + isLeaving = false; + close() { - this.remove.emit(this.toast().options?.id); + if (this.isLeaving) { + return; + } + + this.isLeaving = true; + setTimeout(() => { + this.remove.emit(this.toast().options?.id); + }, 450); } tap() { diff --git a/npm/ng-packs/packages/theme-shared/src/lib/constants/styles.ts b/npm/ng-packs/packages/theme-shared/src/lib/constants/styles.ts index f818f8fe27..2cf91177dd 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/constants/styles.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/constants/styles.ts @@ -90,6 +90,31 @@ export default ` animation: fadeOutTop 0.2s ease-in-out; } +.abp-collapse-y { + display: grid; + grid-template-rows: 1fr; + overflow: hidden; + transition: grid-template-rows 200ms linear; +} + +.abp-collapse-y.abp-collapse-y-collapsed { + grid-template-rows: 0fr; +} + +.abp-collapse-y-inner { + overflow: hidden; +} + +.abp-collapse-margin { + margin-top: 0; + overflow: hidden; + transition: margin-top 400ms linear; +} + +.abp-collapse-margin.abp-collapse-margin-collapsed { + margin-top: -100%; +} + .abp-collapsed-height { -moz-transition: max-height linear 0.35s; -ms-transition: max-height linear 0.35s; diff --git a/npm/ng-packs/packages/theme-shared/src/lib/services/toaster.service.ts b/npm/ng-packs/packages/theme-shared/src/lib/services/toaster.service.ts index 7b5496d879..b6db6e7716 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/services/toaster.service.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/services/toaster.service.ts @@ -1,11 +1,10 @@ +import { ApplicationRef, ComponentRef, inject, Injectable } from '@angular/core'; import { ContentProjectionService, LocalizationParam, PROJECTION_STRATEGY, Strict, } from '@abp/ng.core'; -import { ComponentRef, inject, Injectable } from '@angular/core'; -import { ReplaySubject } from 'rxjs'; import { ToastContainerComponent } from '../components/toast-container/toast-container.component'; import { Toaster } from '../models'; @@ -13,28 +12,33 @@ import { Toaster } from '../models'; providedIn: 'root', }) export class ToasterService implements ToasterContract { - private toasts$ = new ReplaySubject(1); + private readonly appRef = inject(ApplicationRef); + private readonly contentProjectionService = inject(ContentProjectionService); private lastId = -1; private toasts = [] as Toaster.Toast[]; private containerComponentRef!: ComponentRef; - private contentProjectionService: ContentProjectionService; private setContainer() { this.containerComponentRef = this.contentProjectionService.projectContent( PROJECTION_STRATEGY.AppendComponentToBody(ToastContainerComponent, { - toasts$: this.toasts$, remove: this.remove, }), ); - this.containerComponentRef.changeDetectorRef.detectChanges(); + this.syncContainer(); } - constructor() { - this.contentProjectionService = inject(ContentProjectionService); + private syncContainer() { + if (!this.containerComponentRef) { + return; + } + + this.containerComponentRef.instance.setToasts(this.toasts); + this.containerComponentRef.changeDetectorRef.detectChanges(); + this.appRef.tick(); } /** @@ -100,23 +104,27 @@ export class ToasterService implements ToasterContract { * @param severity Sets color of the toast. "success", "warning" etc. * @param options Spesific style or structural options for individual toast */ - show( message: LocalizationParam, title: LocalizationParam | undefined = undefined, severity: Toaster.Severity = 'neutral', options = {} as Partial, ): Toaster.ToasterId { - if (!this.containerComponentRef) this.setContainer(); + if (!this.containerComponentRef) { + this.setContainer(); + } const id = ++this.lastId; - this.toasts.push({ - message, - title, - severity, - options: { closable: true, id, ...options }, - }); - this.toasts$.next(this.toasts); + this.toasts = [ + ...this.toasts, + { + message, + title, + severity, + options: { closable: true, id, ...options }, + }, + ]; + this.syncContainer(); return id; } @@ -126,7 +134,7 @@ export class ToasterService implements ToasterContract { */ remove = (id: number) => { this.toasts = this.toasts.filter(toast => toast.options?.id !== id); - this.toasts$.next(this.toasts); + this.syncContainer(); }; /** @@ -136,7 +144,7 @@ export class ToasterService implements ToasterContract { this.toasts = !containerKey ? [] : this.toasts.filter(toast => toast.options?.containerKey !== containerKey); - this.toasts$.next(this.toasts); + this.syncContainer(); } } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/tests/button.component.spec.ts b/npm/ng-packs/packages/theme-shared/src/lib/tests/button.component.spec.ts index 2986e7c14e..73ae89d96d 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/tests/button.component.spec.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/tests/button.component.spec.ts @@ -42,13 +42,36 @@ describe('ButtonComponent', () => { }); it('should display the spinner icon', () => { - spectator.component.loading = true; + spectator.component.setLoading(true); spectator.detectComponentChanges(); expect(spectator.query('i')).toHaveClass('fa-spinner'); }); + it('should display the spinner icon when loading input is true', () => { + spectator = createHost( + 'Button', + { hostProps: { loading: true } }, + ); + spectator.detectComponentChanges(); + expect(spectator.query('i')).toHaveClass('fa-spinner'); + }); + + it('should clear the spinner icon when loading input becomes false', () => { + spectator = createHost( + 'Button', + { hostProps: { loading: true } }, + ); + spectator.detectComponentChanges(); + expect(spectator.query('i')).toHaveClass('fa-spinner'); + + spectator.setHostInput({ loading: false }); + spectator.detectComponentChanges(); + expect(spectator.query('i')).toHaveClass('fa-check'); + expect(spectator.query('i')).not.toHaveClass('fa-spinner'); + }); + it('should disabled when the loading input is true', () => { - spectator.component.loading = true; + spectator.component.setLoading(true); spectator.detectComponentChanges(); expect(spectator.query('[disabled]')).toBeTruthy(); });