feat: implement modal service and components for enhanced user interaction, including modal showcase in demo page

This commit is contained in:
2026-07-02 11:24:31 -03:00
parent d31cb65d65
commit b3782a6cc1
15 changed files with 1031 additions and 2 deletions

View File

@@ -0,0 +1,133 @@
import '@angular/compiler';
import { Component, inject } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MODAL_DATA,
ModalRef,
ModalService
} from './modal.service';
@Component({
template: ''
})
class FirstTestModalComponent {}
@Component({
template: ''
})
class SecondTestModalComponent {}
describe('ModalService', () => {
let service: ModalService;
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
});
afterEach(() => {
TestBed.resetTestingModule();
});
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ModalService]
});
service = TestBed.inject(ModalService);
});
it('opens a modal and stores the normalized config', () => {
const ref = service.open(FirstTestModalComponent, {
title: 'Confirmar compra',
data: { productId: 10 },
size: 'lg',
closeOnBackdrop: false
});
const activeModal = service.activeModal();
expect(activeModal).not.toBeNull();
expect(activeModal?.component).toBe(FirstTestModalComponent);
expect(activeModal?.ref).toBe(ref);
expect(activeModal?.config).toEqual({
title: 'Confirmar compra',
data: { productId: 10 },
size: 'lg',
closeOnBackdrop: false,
closeOnEscape: true,
showCloseButton: true
});
});
it('emits the close result and clears the active modal', () => {
const ref = service.open(FirstTestModalComponent);
const closedSpy = vi.fn();
ref.afterClosed$.subscribe(closedSpy);
ref.close({ confirmed: true });
expect(closedSpy).toHaveBeenCalledWith({ confirmed: true });
expect(service.activeModal()).toBeNull();
expect(ref.dismissReason()).toBeNull();
});
it('tracks dismiss reasons when closing programmatically', () => {
const ref = service.open(FirstTestModalComponent);
const closedSpy = vi.fn();
ref.afterClosed$.subscribe(closedSpy);
ref.dismiss();
expect(closedSpy).toHaveBeenCalledWith(undefined);
expect(ref.dismissReason()).toBe('programmatic');
expect(service.activeModal()).toBeNull();
});
it('dismisses the current modal as replaced when another one opens', () => {
const firstRef = service.open(FirstTestModalComponent);
const firstClosedSpy = vi.fn();
firstRef.afterClosed$.subscribe(firstClosedSpy);
const secondRef = service.open(SecondTestModalComponent, {
title: 'Segundo modal'
});
expect(firstClosedSpy).toHaveBeenCalledWith(undefined);
expect(firstRef.dismissReason()).toBe('replaced');
expect(service.activeModal()?.ref).toBe(secondRef);
expect(service.activeModal()?.component).toBe(SecondTestModalComponent);
});
it('injects MODAL_DATA and ModalRef into opened components through the host injector contract', () => {
const ref = service.open(DataTestModalComponent, {
data: { amount: 3 }
});
const activeModal = service.activeModal();
expect(activeModal?.config.data).toEqual({ amount: 3 });
expect(activeModal?.ref).toBe(ref);
});
});
@Component({
template: ''
})
class DataTestModalComponent {
readonly data = inject(MODAL_DATA);
readonly modalRef = inject(ModalRef);
}

View File

@@ -0,0 +1,147 @@
import { InjectionToken, Injectable, Type, signal } from '@angular/core';
import { Observable, Subject } from 'rxjs';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ModalDismissReason =
| 'backdrop'
| 'escape'
| 'programmatic'
| 'replaced';
export interface ModalConfig<TData = unknown> {
title?: string;
data?: TData;
size?: ModalSize;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
}
export interface NormalizedModalConfig<TData = unknown>
extends Omit<
ModalConfig<TData>,
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
> {
size: ModalSize;
closeOnBackdrop: boolean;
closeOnEscape: boolean;
showCloseButton: boolean;
}
export interface ActiveModalState<TResult = unknown, TData = unknown> {
component: Type<unknown>;
config: NormalizedModalConfig<TData>;
ref: ModalRef<unknown>;
}
export const MODAL_DATA = new InjectionToken<unknown>('MODAL_DATA');
const DEFAULT_MODAL_CONFIG: Pick<
NormalizedModalConfig,
'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton'
> = {
size: 'md',
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true
};
export class ModalRef<TResult = unknown> {
private readonly afterClosedSubject = new Subject<TResult | undefined>();
private closed = false;
readonly afterClosed$: Observable<TResult | undefined> =
this.afterClosedSubject.asObservable();
readonly dismissReason = signal<ModalDismissReason | null>(null);
constructor(
private readonly closeHandler: (result?: TResult) => void,
private readonly dismissHandler: (reason: ModalDismissReason) => void
) {}
close(result?: TResult): void {
if (this.closed) {
return;
}
this.closeHandler(result);
}
dismiss(reason: ModalDismissReason = 'programmatic'): void {
if (this.closed) {
return;
}
this.dismissHandler(reason);
}
finalize(result?: TResult, dismissReason?: ModalDismissReason): void {
if (this.closed) {
return;
}
this.closed = true;
this.dismissReason.set(dismissReason ?? null);
this.afterClosedSubject.next(result);
this.afterClosedSubject.complete();
}
}
@Injectable({
providedIn: 'root'
})
export class ModalService {
private readonly activeModalState = signal<ActiveModalState | null>(null);
readonly activeModal = this.activeModalState.asReadonly();
open<TComponent, TResult = unknown, TData = unknown>(
component: Type<TComponent>,
config: ModalConfig<TData> = {}
): ModalRef<TResult> {
this.activeModalState()?.ref.dismiss('replaced');
let ref!: ModalRef<TResult>;
ref = new ModalRef<TResult>(
(result) => this.close(ref, result),
(reason) => this.dismiss(ref, reason)
);
this.activeModalState.set({
component,
config: this.normalizeConfig(config),
ref: ref as ModalRef<unknown>
});
return ref;
}
private close<TResult>(ref: ModalRef<TResult>, result?: TResult): void {
if (this.activeModalState()?.ref !== ref) {
return;
}
ref.finalize(result);
this.activeModalState.set(null);
}
private dismiss<TResult>(
ref: ModalRef<TResult>,
reason: ModalDismissReason = 'programmatic'
): void {
if (this.activeModalState()?.ref !== ref) {
return;
}
ref.finalize(undefined, reason);
this.activeModalState.set(null);
}
private normalizeConfig<TData>(
config: ModalConfig<TData>
): NormalizedModalConfig<TData> {
return {
...DEFAULT_MODAL_CONFIG,
...config
};
}
}