feat: implement modal service and components for enhanced user interaction, including modal showcase in demo page
This commit is contained in:
@@ -59,6 +59,21 @@
|
||||
<app-button variant="danger" (click)="triggerPersistentToast('danger')">Danger Persistente</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<h2>Modal</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Demo del modal global con servicio, host y devolución de resultado.
|
||||
</p>
|
||||
<div class="button-grid">
|
||||
<app-button (click)="openBasicModal()">Abrir modal simple</app-button>
|
||||
<app-button variant="secondary" (click)="openLockedModal()">Abrir modal bloqueado</app-button>
|
||||
<app-button variant="danger-secondary" (click)="openWideModal()">Abrir modal ancho</app-button>
|
||||
</div>
|
||||
<div class="modal-showcase__result" data-testid="modal-last-result">
|
||||
{{ lastModalResult }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: `
|
||||
<div class="modal-demo-content">
|
||||
<p class="modal-demo-content__eyebrow">Demo modal</p>
|
||||
<h3 class="modal-demo-content__title">{{ data.title }}</h3>
|
||||
<p class="modal-demo-content__description">{{ data.description }}</p>
|
||||
|
||||
<div class="modal-demo-content__actions">
|
||||
<app-button variant="secondary" (click)="dismiss()">Cancelar</app-button>
|
||||
<app-button (click)="confirm()">{{ data.confirmLabel }}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ModalDemoContentComponent {
|
||||
readonly data = inject<ModalDemoData>(MODAL_DATA);
|
||||
private readonly modalRef = inject<ModalRef<string>>(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})` : ''}.`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user