import { Component, ElementRef, EventEmitter, HostListener, Input, OnChanges, OnDestroy, Output, SimpleChanges, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { A11yModule } from '@angular/cdk/a11y'; export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'; export type ModalVariant = 'default' | 'primary' | 'danger' | 'warning' | 'success' | 'info'; export type ModalPosition = 'center' | 'top'; /** * Professional, reusable modal dialog component. * * Fully integrated with the application theme (supports Light/Dark mode via CSS tokens). * Uses @angular/cdk's A11yModule (cdkTrapFocus) for keyboard accessibility and focus trapping. * * Features: * - Responsive sizing: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' * - Semantic variants: 'default' | 'primary' | 'danger' | 'warning' | 'success' | 'info' * - Title, subtitle, and badge icon support * - Custom content projection via [modal-header], [modal-body], and [modal-footer] * - Smooth backdrop fade and panel scale transitions * - Accessible Esc and backdrop click dismissing * - Automatic background scroll-lock management */ @Component({ selector: 'app-modal', imports: [CommonModule, A11yModule], templateUrl: './modal.component.html', styleUrl: './modal.component.scss', }) export class ModalComponent implements OnChanges, OnDestroy { @Input() isOpen = false; @Input() title = ''; @Input() subtitle?: string; @Input() iconClass?: string; @Input() variant: ModalVariant = 'default'; @Input() size: ModalSize = 'md'; @Input() position: ModalPosition = 'center'; @Input() scrollable = true; @Input() closeOnBackdropClick = true; @Input() closeOnEsc = true; @Input() showCloseButton = true; /** Set false when you don't project any modal-footer content to omit the bottom action bar. */ @Input() showFooter = true; @Output() closed = new EventEmitter(); @Output() opened = new EventEmitter(); private previouslyFocusedElement: HTMLElement | null = null; private originalBodyOverflow = ''; constructor(private hostRef: ElementRef) {} ngOnChanges(changes: SimpleChanges): void { if (!changes['isOpen']) { return; } if (this.isOpen) { this.previouslyFocusedElement = document.activeElement as HTMLElement; this.originalBodyOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; this.opened.emit(); } else { this.unlockBodyScroll(); this.previouslyFocusedElement?.focus?.(); } } ngOnDestroy(): void { if (this.isOpen) { this.unlockBodyScroll(); } } private unlockBodyScroll(): void { document.body.style.overflow = this.originalBodyOverflow || ''; } @HostListener('document:keydown.escape') onEscKey(): void { if (this.isOpen && this.closeOnEsc) { this.close(); } } onBackdropClick(): void { if (this.closeOnBackdropClick) { this.close(); } } close(): void { this.closed.emit(); } get sizeClass(): string { switch (this.size) { case 'sm': return 'modal-panel--sm'; case 'lg': return 'modal-panel--lg'; case 'xl': return 'modal-panel--xl'; case '2xl': return 'modal-panel--2xl'; case 'full': return 'modal-panel--full'; default: return 'modal-panel--md'; } } get positionClass(): string { return this.position === 'top' ? 'items-start pt-12 md:pt-16' : 'items-center'; } }