From b3782a6cc18298c9d4ae815398a7e879bfe438d5 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 2 Jul 2026 11:24:31 -0300 Subject: [PATCH] feat: implement modal service and components for enhanced user interaction, including modal showcase in demo page --- src/app/app.html | 1 + src/app/app.spec.ts | 23 +- src/app/app.ts | 3 +- src/app/core/services/modal.service.spec.ts | 133 ++++++++++++ src/app/core/services/modal.service.ts | 147 +++++++++++++ .../reutilizables-test-page.component.html | 15 ++ .../reutilizables-test-page.component.scss | 47 +++++ .../reutilizables-test-page.component.spec.ts | 75 +++++++ .../reutilizables-test-page.component.ts | 99 +++++++++ .../modal-host/modal-host.component.html | 11 + .../modal-host/modal-host.component.spec.ts | 196 ++++++++++++++++++ .../modal-host/modal-host.component.ts | 93 +++++++++ .../modal-shell/modal-shell.component.html | 39 ++++ .../modal-shell/modal-shell.component.scss | 87 ++++++++ .../modal-shell/modal-shell.component.ts | 64 ++++++ 15 files changed, 1031 insertions(+), 2 deletions(-) create mode 100644 src/app/core/services/modal.service.spec.ts create mode 100644 src/app/core/services/modal.service.ts create mode 100644 src/app/shared/components/modal-host/modal-host.component.html create mode 100644 src/app/shared/components/modal-host/modal-host.component.spec.ts create mode 100644 src/app/shared/components/modal-host/modal-host.component.ts create mode 100644 src/app/shared/components/modal-shell/modal-shell.component.html create mode 100644 src/app/shared/components/modal-shell/modal-shell.component.scss create mode 100644 src/app/shared/components/modal-shell/modal-shell.component.ts diff --git a/src/app/app.html b/src/app/app.html index 13bafce..78d7d40 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,6 +1,7 @@ @if (status() === 'ready') { + } @else if (status() === 'not-found') {
diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 5677f40..200a98c 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,6 +1,12 @@ +import '@angular/compiler'; import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; +import { TestBed, getTestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting +} from '@angular/platform-browser/testing'; import { provideRouter } from '@angular/router'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { App } from './app'; import { Tenant } from './core/services/tenant.interface'; @@ -34,6 +40,21 @@ function createTenantServiceStub(status: 'ready' | 'not-found', currentTenant: T } describe('App', () => { + beforeAll(() => { + try { + getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting() + ); + } catch { + // Test environment may already be initialized by another setup entrypoint. + } + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + it('creates the app when the tenant is ready', async () => { await TestBed.configureTestingModule({ imports: [App], diff --git a/src/app/app.ts b/src/app/app.ts index cb66b6c..3ccb34f 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -3,6 +3,7 @@ import { RouterOutlet } from '@angular/router'; import { TenantService } from './core/services/tenant.service'; import { DEFAULT_TENANT_BRANDING } from './core/services/tenant-ssr-cache.store'; +import { ModalHostComponent } from './shared/components/modal-host/modal-host.component'; import { ToastContainerComponent } from './shared/components/toast-container/toast-container.component'; function hexToRgb(hex: string): string { @@ -22,7 +23,7 @@ function hexToRgb(hex: string): string { @Component({ selector: 'app-root', - imports: [RouterOutlet, ToastContainerComponent], + imports: [RouterOutlet, ToastContainerComponent, ModalHostComponent], templateUrl: './app.html', styleUrl: './app.scss', host: { diff --git a/src/app/core/services/modal.service.spec.ts b/src/app/core/services/modal.service.spec.ts new file mode 100644 index 0000000..82e63a6 --- /dev/null +++ b/src/app/core/services/modal.service.spec.ts @@ -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); +} diff --git a/src/app/core/services/modal.service.ts b/src/app/core/services/modal.service.ts new file mode 100644 index 0000000..19131e6 --- /dev/null +++ b/src/app/core/services/modal.service.ts @@ -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 { + title?: string; + data?: TData; + size?: ModalSize; + closeOnBackdrop?: boolean; + closeOnEscape?: boolean; + showCloseButton?: boolean; +} + +export interface NormalizedModalConfig + extends Omit< + ModalConfig, + 'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton' + > { + size: ModalSize; + closeOnBackdrop: boolean; + closeOnEscape: boolean; + showCloseButton: boolean; +} + +export interface ActiveModalState { + component: Type; + config: NormalizedModalConfig; + ref: ModalRef; +} + +export const MODAL_DATA = new InjectionToken('MODAL_DATA'); + +const DEFAULT_MODAL_CONFIG: Pick< + NormalizedModalConfig, + 'size' | 'closeOnBackdrop' | 'closeOnEscape' | 'showCloseButton' +> = { + size: 'md', + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true +}; + +export class ModalRef { + private readonly afterClosedSubject = new Subject(); + private closed = false; + + readonly afterClosed$: Observable = + this.afterClosedSubject.asObservable(); + readonly dismissReason = signal(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(null); + readonly activeModal = this.activeModalState.asReadonly(); + + open( + component: Type, + config: ModalConfig = {} + ): ModalRef { + this.activeModalState()?.ref.dismiss('replaced'); + + let ref!: ModalRef; + ref = new ModalRef( + (result) => this.close(ref, result), + (reason) => this.dismiss(ref, reason) + ); + + this.activeModalState.set({ + component, + config: this.normalizeConfig(config), + ref: ref as ModalRef + }); + + return ref; + } + + private close(ref: ModalRef, result?: TResult): void { + if (this.activeModalState()?.ref !== ref) { + return; + } + + ref.finalize(result); + this.activeModalState.set(null); + } + + private dismiss( + ref: ModalRef, + reason: ModalDismissReason = 'programmatic' + ): void { + if (this.activeModalState()?.ref !== ref) { + return; + } + + ref.finalize(undefined, reason); + this.activeModalState.set(null); + } + + private normalizeConfig( + config: ModalConfig + ): NormalizedModalConfig { + return { + ...DEFAULT_MODAL_CONFIG, + ...config + }; + } +} diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index 48c167c..c67cc48 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -59,6 +59,21 @@ Danger Persistente
+ +
+

Modal

+

+ Demo del modal global con servicio, host y devolución de resultado. +

+
+ Abrir modal simple + Abrir modal bloqueado + Abrir modal ancho +
+ +

diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss index 55e27df..fdb55e3 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss @@ -49,6 +49,49 @@ h1 { gap: 0.75rem; } +.modal-showcase__result { + padding: 0.875rem 1rem; + border: 1px dashed rgba(32, 32, 32, 0.14); + border-radius: 0.875rem; + background: linear-gradient(180deg, rgba(248, 248, 248, 0.92), rgba(255, 255, 255, 0.98)); + color: #495057; + font-size: 0.95rem; +} + +:host ::ng-deep .modal-demo-content { + display: grid; + gap: 1rem; +} + +:host ::ng-deep .modal-demo-content__eyebrow { + margin: 0; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6c757d; +} + +:host ::ng-deep .modal-demo-content__title { + margin: 0; + font-size: 1.35rem; + font-weight: 700; + color: #202020; +} + +:host ::ng-deep .modal-demo-content__description { + margin: 0; + color: #505050; + line-height: 1.6; +} + +:host ::ng-deep .modal-demo-content__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; +} + .button-group h2 { margin: 0; font-size: 1rem; @@ -133,6 +176,10 @@ h1 { align-items: start; flex-direction: column; } + + .modal-showcase__result { + font-size: 0.875rem; + } } .configuracion-tennant { diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts index fddfd74..2cbeede 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts @@ -2,8 +2,10 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { describe, expect, it, vi } from 'vitest'; import { ReutilizablesTestPageComponent } from './reutilizables-test-page.component'; +import { ModalService } from '../../../../core/services/modal.service'; import { Tenant } from '../../../../core/services/tenant.interface'; import { TenantService } from '../../../../core/services/tenant.service'; +import { ToastService } from '../../../../core/services/toast.service'; const tenant: Tenant = { id: 1, @@ -32,14 +34,42 @@ function createTenantServiceStub(currentTenant: Tenant | null) { }; } +function createToastServiceStub() { + return { + success: vi.fn(), + danger: vi.fn(), + info: vi.fn() + }; +} + +function createModalServiceStub() { + return { + open: vi.fn().mockReturnValue({ + afterClosed$: { + subscribe: vi.fn() + }, + dismissReason: vi.fn().mockReturnValue(null) + }) + }; +} + describe('ReutilizablesTestPageComponent', () => { it('renders the tenant configuration section with logo sources and swatch colors', async () => { + const modalServiceStub = createModalServiceStub(); await TestBed.configureTestingModule({ imports: [ReutilizablesTestPageComponent], providers: [ { provide: TenantService, useValue: createTenantServiceStub(tenant) + }, + { + provide: ToastService, + useValue: createToastServiceStub() + }, + { + provide: ModalService, + useValue: modalServiceStub } ] }).compileComponents(); @@ -69,12 +99,21 @@ describe('ReutilizablesTestPageComponent', () => { }); it('renders the paginator demo with the initial page status', async () => { + const modalServiceStub = createModalServiceStub(); await TestBed.configureTestingModule({ imports: [ReutilizablesTestPageComponent], providers: [ { provide: TenantService, useValue: createTenantServiceStub(tenant) + }, + { + provide: ToastService, + useValue: createToastServiceStub() + }, + { + provide: ModalService, + useValue: modalServiceStub } ] }).compileComponents(); @@ -87,4 +126,40 @@ describe('ReutilizablesTestPageComponent', () => { expect(element.querySelector('app-paginator')).not.toBeNull(); expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/8'); }); + + it('renders the modal showcase and opens the basic modal from the demo button', async () => { + const modalServiceStub = createModalServiceStub(); + await TestBed.configureTestingModule({ + imports: [ReutilizablesTestPageComponent], + providers: [ + { + provide: TenantService, + useValue: createTenantServiceStub(tenant) + }, + { + provide: ToastService, + useValue: createToastServiceStub() + }, + { + provide: ModalService, + useValue: modalServiceStub + } + ] + }).compileComponents(); + + const fixture = TestBed.createComponent(ReutilizablesTestPageComponent); + fixture.detectChanges(); + + const element = fixture.nativeElement as HTMLElement; + const modalButtons = Array.from(element.querySelectorAll('.button-group app-button button')) + .filter((button) => button.textContent?.includes('Abrir modal')); + + expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain( + 'Todavía no se abrió ningún modal.' + ); + + (modalButtons[0] as HTMLButtonElement).click(); + + expect(modalServiceStub.open).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index 013d736..fc7fd05 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -7,9 +7,49 @@ import { ProductCardComponent } from '../../../../shared/components/product-card import { StoreSectionComponent } from '../../../../shared/components/store-section/store-section.component'; import { IconButtonComponent } from '../../../../shared/components/icon-button/icon-button.component'; import { CartIconComponent } from '../../../../shared/components/cart-icon/cart-icon.component'; +import { + MODAL_DATA, + ModalRef, + ModalService +} from '../../../../core/services/modal.service'; import { TenantService } from '../../../../core/services/tenant.service'; import { ToastService } from '../../../../core/services/toast.service'; +interface ModalDemoData { + title: string; + description: string; + confirmLabel: string; +} + +@Component({ + selector: 'app-modal-demo-content', + imports: [ButtonComponent], + template: ` + + ` +}) +export class ModalDemoContentComponent { + readonly data = inject(MODAL_DATA); + private readonly modalRef = inject>(ModalRef); + + protected confirm(): void { + this.modalRef.close(`confirm:${this.data.title}`); + } + + protected dismiss(): void { + this.modalRef.dismiss('programmatic'); + } +} + @Component({ selector: 'app-reutilizables-test-page', imports: [ @@ -28,6 +68,7 @@ import { ToastService } from '../../../../core/services/toast.service'; export class ReutilizablesTestPageComponent { private readonly tenantService = inject(TenantService); private readonly toastService = inject(ToastService); + private readonly modalService = inject(ModalService); protected readonly tenant = this.tenantService.tenant; protected textValue = 'Auriculares'; protected numberValue = '24'; @@ -46,6 +87,7 @@ export class ReutilizablesTestPageComponent { protected editableDisabled = true; protected currentPage = 1; protected cartVisible = true; + protected lastModalResult = 'Todavía no se abrió ningún modal.'; protected readonly paginatorTotalPages = 8; protected readonly cartBackgroundColor = '#ffffff'; @@ -166,4 +208,61 @@ export class ReutilizablesTestPageComponent { this.toastService.info('Información persistente: Mantendremos este aviso en pantalla.', 0); } } + + protected openBasicModal(): void { + this.openDemoModal({ + title: 'Modal simple', + description: 'Caso base para verificar apertura, cierre y devolución de resultado.', + confirmLabel: 'Confirmar' + }); + } + + protected openLockedModal(): void { + this.openDemoModal( + { + title: 'Modal bloqueado', + description: 'Este modal no se cierra tocando el backdrop ni con la tecla Escape.', + confirmLabel: 'Entendido' + }, + { + closeOnBackdrop: false, + closeOnEscape: false + } + ); + } + + protected openWideModal(): void { + this.openDemoModal( + { + title: 'Modal ancho', + description: 'Demuestra una variante visual más amplia para contenido más pesado.', + confirmLabel: 'Seguir' + }, + { + size: 'xl' + } + ); + } + + private openDemoModal( + data: ModalDemoData, + overrides: Partial<{ + size: 'sm' | 'md' | 'lg' | 'xl' | 'full'; + closeOnBackdrop: boolean; + closeOnEscape: boolean; + }> = {} + ): void { + const ref = this.modalService.open(ModalDemoContentComponent, { + title: data.title, + data, + ...overrides + }); + + ref.afterClosed$.subscribe((result) => { + const dismissReason = ref.dismissReason(); + this.lastModalResult = result + ? `Resultado: ${result}` + : `Cerrado sin resultado${dismissReason ? ` (${dismissReason})` : ''}.`; + }); + } } diff --git a/src/app/shared/components/modal-host/modal-host.component.html b/src/app/shared/components/modal-host/modal-host.component.html new file mode 100644 index 0000000..57f3e48 --- /dev/null +++ b/src/app/shared/components/modal-host/modal-host.component.html @@ -0,0 +1,11 @@ +@if (activeModal(); as modal) { + + + +} diff --git a/src/app/shared/components/modal-host/modal-host.component.spec.ts b/src/app/shared/components/modal-host/modal-host.component.spec.ts new file mode 100644 index 0000000..fd5af67 --- /dev/null +++ b/src/app/shared/components/modal-host/modal-host.component.spec.ts @@ -0,0 +1,196 @@ +import '@angular/compiler'; +import { Component, inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { TestBed, getTestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + BrowserTestingModule, + platformBrowserTesting +} from '@angular/platform-browser/testing'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { + MODAL_DATA, + ModalRef, + ModalService +} from '../../../core/services/modal.service'; +import { ModalHostComponent } from './modal-host.component'; + +@Component({ + standalone: true, + template: ` + + ` +}) +class ModalContentTestComponent { + readonly data = inject<{ title: string } | null>(MODAL_DATA); + readonly modalRef = inject>(ModalRef); + + close(): void { + this.modalRef.close('accepted'); + } +} + +describe('ModalHostComponent', () => { + let service: ModalService; + let doc: Document; + + beforeAll(() => { + try { + getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting() + ); + } catch { + // Test environment may already be initialized by another setup entrypoint. + } + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ModalHostComponent] + }).compileComponents(); + + service = TestBed.inject(ModalService); + doc = TestBed.inject(DOCUMENT); + }); + + afterEach(() => { + doc.body.style.overflow = ''; + TestBed.resetTestingModule(); + }); + + it('renders nothing when no modal is active', () => { + const fixture = TestBed.createComponent(ModalHostComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent?.trim()).toBe(''); + expect(doc.body.style.overflow).toBe(''); + }); + + it('opens and renders the requested component with its title and data', () => { + const fixture = TestBed.createComponent(ModalHostComponent); + + service.open(ModalContentTestComponent, { + title: 'Editar producto', + data: { title: 'Contenido del modal' } + }); + fixture.detectChanges(); + + const title = fixture.nativeElement.querySelector('.modal-shell__title'); + const content = fixture.nativeElement.querySelector('.modal-test-title'); + + expect(title?.textContent).toContain('Editar producto'); + expect(content?.textContent).toContain('Contenido del modal'); + expect(doc.body.style.overflow).toBe('hidden'); + }); + + it('closes with a result from the child component', () => { + const fixture = TestBed.createComponent(ModalHostComponent); + const ref = service.open(ModalContentTestComponent, { + data: { title: 'Cerrar' } + }); + const closedSpy = vi.fn(); + + ref.afterClosed$.subscribe(closedSpy); + fixture.detectChanges(); + + const closeButton = fixture.nativeElement.querySelector('.modal-test-close') as HTMLButtonElement; + closeButton.click(); + fixture.detectChanges(); + + expect(closedSpy).toHaveBeenCalledWith('accepted'); + expect(service.activeModal()).toBeNull(); + expect(doc.body.style.overflow).toBe(''); + }); + + it('closes on backdrop click when enabled', () => { + const fixture = TestBed.createComponent(ModalHostComponent); + const ref = service.open(ModalContentTestComponent, { + data: { title: 'Backdrop' }, + closeOnBackdrop: true + }); + + fixture.detectChanges(); + + const backdrop = fixture.nativeElement.querySelector('.modal-shell') as HTMLDivElement; + backdrop.click(); + fixture.detectChanges(); + + expect(service.activeModal()).toBeNull(); + expect(ref.dismissReason()).toBe('backdrop'); + }); + + it('does not close on backdrop click when disabled', () => { + const fixture = TestBed.createComponent(ModalHostComponent); + const ref = service.open(ModalContentTestComponent, { + data: { title: 'Persistente' }, + closeOnBackdrop: false + }); + + fixture.detectChanges(); + + const backdrop = fixture.nativeElement.querySelector('.modal-shell') as HTMLDivElement; + backdrop.click(); + fixture.detectChanges(); + + expect(service.activeModal()?.ref).toBe(ref); + }); + + it('closes on Escape when enabled', () => { + const fixture = TestBed.createComponent(ModalHostComponent); + const ref = service.open(ModalContentTestComponent, { + data: { title: 'Escape' }, + closeOnEscape: true + }); + + fixture.detectChanges(); + doc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + fixture.detectChanges(); + + expect(service.activeModal()).toBeNull(); + expect(ref.dismissReason()).toBe('escape'); + }); + + it('keeps the modal open on Escape when disabled', () => { + const fixture = TestBed.createComponent(ModalHostComponent); + const ref = service.open(ModalContentTestComponent, { + data: { title: 'No Escape' }, + closeOnEscape: false + }); + + fixture.detectChanges(); + doc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + fixture.detectChanges(); + + expect(service.activeModal()?.ref).toBe(ref); + }); + + it('closes from the shell close button with a programmatic dismiss reason', () => { + const fixture = TestBed.createComponent(ModalHostComponent); + const ref = service.open(ModalContentTestComponent, { + title: 'Con cierre', + data: { title: 'Boton' } + }); + + fixture.detectChanges(); + + const closeButton = fixture.debugElement.query(By.css('.btn-close')).nativeElement as HTMLButtonElement; + closeButton.click(); + fixture.detectChanges(); + + expect(service.activeModal()).toBeNull(); + expect(ref.dismissReason()).toBe('programmatic'); + }); +}); diff --git a/src/app/shared/components/modal-host/modal-host.component.ts b/src/app/shared/components/modal-host/modal-host.component.ts new file mode 100644 index 0000000..7bc840e --- /dev/null +++ b/src/app/shared/components/modal-host/modal-host.component.ts @@ -0,0 +1,93 @@ +import { DOCUMENT, NgComponentOutlet, isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Injector, + PLATFORM_ID, + computed, + effect, + inject, + viewChild +} from '@angular/core'; + +import { + MODAL_DATA, + ModalRef, + ModalService +} from '../../../core/services/modal.service'; +import { ModalShellComponent } from '../modal-shell/modal-shell.component'; + +@Component({ + selector: 'app-modal-host', + imports: [NgComponentOutlet, ModalShellComponent], + templateUrl: './modal-host.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ModalHostComponent { + private readonly modalService = inject(ModalService); + private readonly injector = inject(Injector); + private readonly document = inject(DOCUMENT); + private readonly platformId = inject(PLATFORM_ID); + private readonly modalShell = viewChild(ModalShellComponent); + private readonly isBrowser = isPlatformBrowser(this.platformId); + + readonly activeModal = this.modalService.activeModal; + protected readonly contentInjector = computed(() => { + const modal = this.activeModal(); + + if (!modal) { + return undefined; + } + + return Injector.create({ + providers: [ + { provide: ModalRef, useValue: modal.ref }, + { provide: MODAL_DATA, useValue: modal.config.data ?? null } + ], + parent: this.injector + }); + }); + + constructor() { + effect((onCleanup) => { + const modal = this.activeModal(); + + if (!this.isBrowser || !modal) { + return; + } + + const body = this.document.body; + const previousOverflow = body.style.overflow; + body.style.overflow = 'hidden'; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || !this.activeModal()?.config.closeOnEscape) { + return; + } + + event.preventDefault(); + this.activeModal()?.ref.dismiss('escape'); + }; + + this.document.addEventListener('keydown', onKeyDown); + queueMicrotask(() => this.modalShell()?.focusInitialElement()); + + onCleanup(() => { + this.document.removeEventListener('keydown', onKeyDown); + body.style.overflow = previousOverflow; + }); + }); + } + + protected onBackdropClick(): void { + const modal = this.activeModal(); + + if (modal?.config.closeOnBackdrop) { + modal.ref.dismiss('backdrop'); + } + } + + protected onCloseRequested(): void { + this.activeModal()?.ref.dismiss('programmatic'); + } +} diff --git a/src/app/shared/components/modal-shell/modal-shell.component.html b/src/app/shared/components/modal-shell/modal-shell.component.html new file mode 100644 index 0000000..e392599 --- /dev/null +++ b/src/app/shared/components/modal-shell/modal-shell.component.html @@ -0,0 +1,39 @@ + diff --git a/src/app/shared/components/modal-shell/modal-shell.component.scss b/src/app/shared/components/modal-shell/modal-shell.component.scss new file mode 100644 index 0000000..46117e4 --- /dev/null +++ b/src/app/shared/components/modal-shell/modal-shell.component.scss @@ -0,0 +1,87 @@ +:host { + display: contents; +} + +.modal-shell { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(16, 18, 22, 0.48); + backdrop-filter: blur(2px); +} + +.modal-shell__dialog { + width: min(100%, 40rem); + margin: 0; +} + +.modal-shell__dialog.modal-sm { + width: min(100%, 24rem); +} + +.modal-shell__dialog.modal-lg { + width: min(100%, 52rem); +} + +.modal-shell__dialog.modal-xl { + width: min(100%, 68rem); +} + +.modal-shell__dialog--full { + width: min(100%, 92rem); + height: min(100%, calc(100dvh - 2rem)); +} + +.modal-shell__content { + max-height: calc(100dvh - 2rem); + border-radius: 1.25rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 248, 248, 0.98)); +} + +.modal-shell__dialog--full .modal-shell__content { + height: 100%; + max-height: 100%; +} + +.modal-shell__header { + align-items: flex-start; + padding: 1.25rem 1.25rem 0.75rem; +} + +.modal-shell__title { + margin: 0; + font-size: clamp(1.15rem, 1rem + 0.4vw, 1.5rem); + font-weight: 700; + line-height: 1.15; + color: #202020; +} + +.modal-shell__body { + padding: 0 1.25rem 1.25rem; + color: #303030; +} + +@media (max-width: 576px) { + .modal-shell { + padding: 0.75rem; + align-items: flex-end; + } + + .modal-shell__dialog, + .modal-shell__dialog.modal-sm, + .modal-shell__dialog.modal-lg, + .modal-shell__dialog.modal-xl, + .modal-shell__dialog--full { + width: 100%; + } + + .modal-shell__content { + max-height: min(100dvh - 1.5rem, 48rem); + border-radius: 1.25rem 1.25rem 0.75rem 0.75rem; + } +} diff --git a/src/app/shared/components/modal-shell/modal-shell.component.ts b/src/app/shared/components/modal-shell/modal-shell.component.ts new file mode 100644 index 0000000..58d0458 --- /dev/null +++ b/src/app/shared/components/modal-shell/modal-shell.component.ts @@ -0,0 +1,64 @@ +import { NgClass } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + computed, + input, + output, + viewChild +} from '@angular/core'; + +import { ModalSize } from '../../../core/services/modal.service'; + +@Component({ + selector: 'app-modal-shell', + imports: [NgClass], + templateUrl: './modal-shell.component.html', + styleUrl: './modal-shell.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ModalShellComponent { + private readonly panel = viewChild.required>('panel'); + + readonly title = input(); + readonly size = input('md'); + readonly showCloseButton = input(true); + + readonly backdropClick = output(); + readonly closeRequested = output(); + + protected readonly dialogClass = computed(() => { + const size = this.size(); + + return { + 'modal-sm': size === 'sm', + 'modal-lg': size === 'lg', + 'modal-xl': size === 'xl', + 'modal-shell__dialog--full': size === 'full' + }; + }); + + protected readonly titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`; + + protected get ariaLabelledBy(): string | null { + return this.title() ? this.titleId : null; + } + + focusInitialElement(): void { + const panel = this.panel().nativeElement; + const focusTarget = panel.querySelector( + [ + '[autofocus]', + 'button:not([disabled])', + '[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])' + ].join(', ') + ); + + (focusTarget ?? panel).focus(); + } +}