UNPKG

ngx-custom-modal

Version:

A custom Modal / Dialog (with inner component support) for Angular 17-20 projects with full version compatibility

836 lines (829 loc) 40.4 kB
import * as i1 from '@angular/common'; import { CommonModule } from '@angular/common'; import * as i0 from '@angular/core'; import { signal, Injectable, VERSION, inject, ElementRef, EventEmitter, computed, effect, HostListener, Output, Input, ContentChild, ViewChild, ChangeDetectionStrategy, Component } from '@angular/core'; /** * Service for managing multiple modal instances and their stacking order. * * Handles: * - Z-index calculation for proper stacking * - Modal registration/unregistration * - Top-most modal identification * - Body class management * * @injectable * @public */ class NgxModalStackService { constructor() { this.modals = signal([]); this.baseZIndex = 1050; } /** * Registers a modal instance with the stack service. * @param modal - The modal component to register * @public */ register(modal) { this.modals.update(modals => [...modals, modal]); } /** * Unregisters a modal instance from the stack service. * @param modal - The modal component to unregister * @public */ unregister(modal) { this.modals.update(modals => modals.filter(m => m !== modal)); } /** * Determines if the given modal is the topmost visible modal. * Used for proper event handling and accessibility. * @param modal - The modal instance to check * @returns {boolean} True if the modal is topmost * @public */ isTopMost(modal) { const modals = this.modals(); const visibleModals = modals.filter(m => m.visible()); return visibleModals[visibleModals.length - 1] === modal; } /** * Calculates the next z-index value for a new modal. * Each modal gets a z-index 10 units higher than the previous. * @returns {number} The z-index value to use * @public */ getNextZIndex() { const visibleCount = this.modals().filter(m => m.visible()).length; return this.baseZIndex + visibleCount * 10; } /** * Gets the count of currently active (visible) modals. * @returns {number} Number of active modals * @public */ getActiveModalsCount() { return this.modals().filter(m => m.visible()).length; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgxModalStackService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgxModalStackService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgxModalStackService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * Service to handle lifecycle hook compatibility across Angular versions. * Manages the breaking change from afterRender to afterEveryRender in Angular 20. */ class LifecycleCompatibilityService { constructor() { this.majorVersion = parseInt(VERSION.major); this.minorVersion = parseInt(VERSION.minor); } /** * Ejecuta un callback después de cada render (compatible entre versiones). */ afterEveryRender(callback, options) { try { // Intentar usar la función apropiada según la versión if (this.majorVersion >= 20) { // Para Angular 20+, intentar afterEveryRender const afterEveryRender = globalThis?.ng?.core?.afterEveryRender; if (afterEveryRender) { return afterEveryRender(callback, options); } } else if (this.majorVersion >= 17 || (this.majorVersion === 16 && this.minorVersion >= 2)) { // Para Angular 16.2-19, intentar afterRender const afterRender = globalThis?.ng?.core?.afterRender; if (afterRender) { return afterRender(callback, options); } } } catch (error) { console.warn('Error executing lifecycle hook:', error); } // Fallback usando requestAnimationFrame o setTimeout this.fallbackAfterRender(callback); return null; } /** * Ejecuta un callback después del siguiente render. */ afterNextRender(callback, options) { try { const afterNextRender = globalThis?.ng?.core?.afterNextRender; if (afterNextRender) { return afterNextRender(callback, options); } } catch (error) { console.warn('Error executing afterNextRender hook:', error); } // Fallback usando requestAnimationFrame o setTimeout this.fallbackAfterNextRender(callback); return null; } /** * Implementación de fallback para afterRender. */ fallbackAfterRender(callback) { if (typeof requestAnimationFrame !== 'undefined') { requestAnimationFrame(() => { try { callback(); } catch (error) { console.warn('Error in fallback afterRender callback:', error); } }); } else { setTimeout(() => { try { callback(); } catch (error) { console.warn('Error in fallback afterRender callback:', error); } }, 0); } } /** * Implementación de fallback para afterNextRender. */ fallbackAfterNextRender(callback) { this.fallbackAfterRender(callback); } /** * Retorna información sobre la versión actual. */ getVersionInfo() { return { major: this.majorVersion, minor: this.minorVersion, patch: VERSION.patch, hasAfterRender: true, // Siempre disponible mediante fallback hasAfterNextRender: true, // Siempre disponible mediante fallback hookName: this.majorVersion >= 20 ? 'afterEveryRender' : 'afterRender', }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: LifecycleCompatibilityService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: LifecycleCompatibilityService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: LifecycleCompatibilityService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); /** * A feature-rich, accessible modal component for Angular 17+ applications. * * Features: * - Signal-based reactivity for optimal performance * - Advanced modal stacking with z-index management * - Full WCAG accessibility compliance * - Bootstrap 3, 4 & 5 compatibility * - Touch-optimized for mobile devices * - Customizable animations and styling * - Cross-version compatibility (Angular 17-20+) * * @example * ```html * <ngx-custom-modal #modal [size]="'lg'" [centered]="true"> * <ng-template #modalHeader> * <h2>Modal Title</h2> * </ng-template> * <ng-template #modalBody> * <p>Modal content</p> * </ng-template> * </ngx-custom-modal> * ``` * * @public */ class NgxCustomModalComponent { constructor() { this.elementRef = inject(ElementRef); this.document = inject(Document, { optional: true }); this.lifecycleService = inject(LifecycleCompatibilityService); /** Template reference for modal header content */ this.header = null; /** Template reference for modal body content */ this.body = null; /** Template reference for modal footer content */ this.footer = null; /** Close modal when clicking outside the modal content. @default true */ this.closeOnOutsideClick = true; /** Close modal when pressing the Escape key. @default true */ this.closeOnEscape = true; /** Hide the default close button (×) in the header. @default false */ this.hideCloseButton = false; /** Configuration options object that overrides individual properties */ this.options = {}; /** Modal size preset. @default 'md' */ this.size = 'md'; /** Center modal vertically in viewport. @default false */ this.centered = false; /** Make modal body scrollable when content overflows. @default false */ this.scrollable = false; /** Enable CSS transition animations. @default true */ this.animation = true; /** Backdrop behavior configuration. @default 'dynamic' */ this.backdrop = 'dynamic'; /** Enable keyboard interactions. @default true */ this.keyboard = true; /** Enable automatic focus management. @default true */ this.focus = true; /** Emitted when modal starts opening */ this.opened = new EventEmitter(); /** Emitted when modal is fully closed */ this.closed = new EventEmitter(); /** Emitted before modal starts opening */ this.opening = new EventEmitter(); /** Emitted before modal starts closing */ this.closing = new EventEmitter(); this.modalState = signal({ isVisible: false, isAnimating: false, zIndex: 1050, previousFocusedElement: undefined, }); this.animationDuration = signal(200); this.modalStack = signal([]); /** Computed signal for modal visibility state */ this.visible = computed(() => this.modalState().isVisible); /** Signal for animation state */ this.visibleAnimate = signal(false); /** Computed signal for animation state */ this.isAnimating = computed(() => this.modalState().isAnimating); /** Unique ID for modal title (ARIA) */ this.titleId = signal(`modal-title-${Math.random().toString(36).substr(2, 9)}`); /** Unique ID for modal description (ARIA) */ this.descriptionId = signal(`modal-desc-${Math.random().toString(36).substr(2, 9)}`); this.previouslyFocusedElement = null; this.modalStackService = inject(NgxModalStackService, { optional: true }); this.afterRenderCleanup = null; this.afterNextRenderCleanup = null; // Effect to handle modal visibility changes effect(() => { const isVisible = this.visible(); if (isVisible) { this.handleModalOpen(); } else { this.handleModalClose(); } }); // Effect to handle animation duration changes effect(() => { const duration = this.options.animationDuration || 200; this.animationDuration.set(duration); this.updateCSSAnimationDuration(duration); }); // Setup lifecycle hooks with compatibility this.setupLifecycleHooks(); } ngOnInit() { this.registerInModalStack(); this.logVersionInfo(); } ngOnDestroy() { this.close(); this.unregisterFromModalStack(); this.restoreFocus(); this.removeBodyClass(); this.cleanupLifecycleHooks(); } /** * Opens the modal with proper accessibility and focus management. * * Automatically handles: * - Focus management (saves current focus, sets focus to modal) * - Body scroll locking * - Z-index management for stacking * - Screen reader announcements * * @public * @fires opening - Before modal animation starts * @fires opened - After modal is fully visible */ open() { if (this.visible()) return; this.opening.emit(); this.storePreviousFocus(); this.addBodyClass(); this.modalState.update(state => ({ ...state, isVisible: true, isAnimating: true, })); if (this.animation) { setTimeout(() => { this.visibleAnimate.set(true); this.modalState.update(state => ({ ...state, isAnimating: false })); this.focusFirstElement(); this.opened.emit(); }, 10); } else { this.visibleAnimate.set(true); this.modalState.update(state => ({ ...state, isAnimating: false })); this.focusFirstElement(); this.opened.emit(); } } /** * Closes the modal and restores previous state. * * Automatically handles: * - Focus restoration to previously focused element * - Body scroll unlock (if no other modals open) * - Cleanup of event listeners * * @public * @fires closing - Before modal animation starts * @fires closed - After modal is fully hidden */ close() { if (!this.visible()) return; this.closing.emit(); this.modalState.update(state => ({ ...state, isAnimating: true, })); this.visibleAnimate.set(false); const duration = this.animation ? this.animationDuration() : 0; setTimeout(() => { this.modalState.update(state => ({ ...state, isVisible: false, isAnimating: false, })); this.removeBodyClass(); this.restoreFocus(); this.closed.emit(); }, duration); } /** * Toggles the modal visibility state. * @public */ toggle() { if (this.visible()) { this.close(); } else { this.open(); } } /** * Handles clicks on the modal container for outside click detection. * @param event - The mouse click event * @private */ onContainerClicked(event) { const closeOnOutsideClick = this.options.closeOnOutsideClick ?? this.closeOnOutsideClick; const backdrop = this.options.backdrop || this.backdrop; if (event.target.classList.contains('modal') && this.isTopMost() && closeOnOutsideClick && backdrop === 'dynamic') { this.close(); } } /** * Handles global keyboard events for modal functionality. * @param event - The keyboard event * @private */ onKeyDownHandler(event) { if (!this.visible() || !this.isTopMost()) return; const closeOnEscape = this.options.closeOnEscape ?? this.closeOnEscape; const keyboardEnabled = this.options.keyboard ?? this.keyboard; if (event.key === 'Escape' && closeOnEscape && keyboardEnabled) { this.close(); } else if (event.key === 'Tab') { this.handleTabKey(event); } } /** * Checks if this modal is the topmost in the stack. * Used for proper event handling in nested modals. * * @returns {boolean} True if this modal is on top of the stack * @public */ isTopMost() { if (this.modalStackService) { return this.modalStackService.isTopMost(this); } return !this.elementRef.nativeElement.querySelector(':scope ngx-custom-modal > .modal'); } /** * Gets the combined custom CSS classes for the modal. * @returns {string} Space-separated CSS classes * @public */ getCustomClass() { const customClass = this.customClass ?? this.options.customClass ?? ''; const sizeClass = this.getSizeClass(); const animationClass = this.getAnimationClass(); return [customClass, sizeClass, animationClass].filter(Boolean).join(' '); } /** * Gets the CSS classes for the modal dialog container. * @returns {string} Space-separated CSS classes * @public */ getDialogClasses() { const classes = []; if (this.centered || this.options.centered) { classes.push('modal-dialog-centered'); } if (this.scrollable || this.options.scrollable) { classes.push('modal-dialog-scrollable'); } const size = this.options.size || this.size; if (size && size !== 'md') { classes.push(`modal-${size}`); } return classes.join(' '); } /** * Determines if the close button should be hidden. * @returns {boolean} True if close button should be hidden * @public */ shouldHideCloseButton() { return this.hideCloseButton || this.options.hideCloseButton || false; } /** * Gets the ARIA label for the close button. * @returns {string} Localized close button label * @public */ getCloseButtonLabel() { return 'Close modal'; } /** * Checks if header content is available. * @returns {boolean} True if header template exists * @public */ hasHeaderContent() { return !!this.header; } /** * Checks if footer content is available. * @returns {boolean} True if footer template exists * @public */ hasFooterContent() { return !!this.footer; } /** * Sets up lifecycle hooks with version compatibility. * @private */ setupLifecycleHooks() { // Setup afterNextRender for initial setup this.afterNextRenderCleanup = this.lifecycleService.afterNextRender(() => { this.setupAccessibility(); this.setupKeyboardNavigation(); this.setupFocusManagement(); }); // Setup afterEveryRender for ongoing updates this.afterRenderCleanup = this.lifecycleService.afterEveryRender(() => { if (this.visible() && this.modalDialog) { this.adjustModalPosition(); this.ensureProperZIndex(); } }); } /** * Cleans up lifecycle hook subscriptions. * @private */ cleanupLifecycleHooks() { if (this.afterRenderCleanup) { this.afterRenderCleanup(); this.afterRenderCleanup = null; } if (this.afterNextRenderCleanup) { this.afterNextRenderCleanup(); this.afterNextRenderCleanup = null; } } /** * Logs version information for debugging. * @private */ logVersionInfo() { const versionInfo = this.lifecycleService.getVersionInfo(); console.log(`[NgxCustomModal] Angular ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`); console.log(`[NgxCustomModal] Using lifecycle hook: ${versionInfo.hookName}`); console.log(`[NgxCustomModal] Hooks available - afterRender: ${versionInfo.hasAfterRender}, afterNextRender: ${versionInfo.hasAfterNextRender}`); } /** * Handles modal opening side effects. * @private */ handleModalOpen() { this.previouslyFocusedElement = this.document?.activeElement; this.announceToScreenReader('Modal opened'); } /** * Handles modal closing side effects. * @private */ handleModalClose() { this.announceToScreenReader('Modal closed'); } /** * Sets up accessibility attributes and ARIA labels. * @private */ setupAccessibility() { const modalElement = this.elementRef.nativeElement.querySelector('.modal'); if (modalElement) { modalElement.setAttribute('role', 'dialog'); modalElement.setAttribute('aria-modal', 'true'); } } /** * Sets up keyboard navigation handlers. * @private */ setupKeyboardNavigation() { // Keyboard navigation is handled in the @HostListener } /** * Sets up focus management systems. * @private */ setupFocusManagement() { // Focus management is handled in focusFirstElement and restoreFocus methods } /** * Adjusts modal position and size based on viewport. * @private */ adjustModalPosition() { const modalDialog = this.elementRef.nativeElement.querySelector('.modal-dialog'); if (modalDialog) { const rect = modalDialog.getBoundingClientRect(); if (rect.height > window.innerHeight * 0.9) { modalDialog.style.height = `${window.innerHeight * 0.9}px`; } } } /** * Ensures proper z-index for modal stacking. * @private */ ensureProperZIndex() { const modalElement = this.elementRef.nativeElement.querySelector('.modal'); if (modalElement && this.modalStackService) { const zIndex = this.modalStackService.getNextZIndex(); modalElement.style.zIndex = zIndex.toString(); this.modalState.update(state => ({ ...state, zIndex, })); } } /** * Stores the currently focused element for later restoration. * @private */ storePreviousFocus() { this.previouslyFocusedElement = this.document?.activeElement; this.modalState.update(state => ({ ...state, previousFocusedElement: this.previouslyFocusedElement || undefined, })); } /** * Restores focus to the previously focused element. * @private */ restoreFocus() { if (this.previouslyFocusedElement && this.focus) { setTimeout(() => { this.previouslyFocusedElement?.focus(); }, 0); } } /** * Manages focus within the modal for accessibility. * Focuses first focusable element or close button as fallback. * @private */ focusFirstElement() { if (!this.focus) return; setTimeout(() => { const modalElement = this.elementRef.nativeElement.querySelector('.modal'); const firstFocusable = modalElement?.querySelector('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'); if (firstFocusable) { firstFocusable.focus(); } else { const closeButton = modalElement?.querySelector('.close'); closeButton?.focus(); } }, 0); } /** * Handles tab key navigation to trap focus within the modal. * Implements circular focus trapping as per WCAG guidelines. * @param event - The keyboard event * @private */ handleTabKey(event) { const modalElement = this.elementRef.nativeElement.querySelector('.modal'); if (!modalElement) return; const focusableElements = Array.from(modalElement.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])')); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (event.shiftKey) { if (this.document?.activeElement === firstElement) { event.preventDefault(); lastElement?.focus(); } } else { if (this.document?.activeElement === lastElement) { event.preventDefault(); firstElement?.focus(); } } } /** * Adds modal-open class to body to prevent scrolling. * @private */ addBodyClass() { this.document?.body.classList.add('modal-open'); } /** * Removes modal-open class from body when no modals are open. * @private */ removeBodyClass() { if (this.modalStackService?.getActiveModalsCount() === 0) { this.document?.body.classList.remove('modal-open'); } } /** * Gets the CSS class for modal size. * @returns {string} Size-specific CSS class * @private */ getSizeClass() { const size = this.options.size || this.size; return size && size !== 'md' ? `modal-${size}` : ''; } /** * Gets the CSS class for animation state. * @returns {string} Animation-specific CSS class * @private */ getAnimationClass() { const hasAnimation = this.options.animation ?? this.animation; return hasAnimation ? 'modal-animated' : 'modal-no-animation'; } /** * Updates CSS custom property for animation duration. * @param duration - Animation duration in milliseconds * @private */ updateCSSAnimationDuration(duration) { const modalElement = this.elementRef.nativeElement.querySelector('.modal'); if (modalElement) { modalElement.style.setProperty('--modal-animation-duration', `${duration}ms`); } } /** * Registers this modal instance with the modal stack service. * @private */ registerInModalStack() { this.modalStackService?.register(this); } /** * Unregisters this modal instance from the modal stack service. * @private */ unregisterFromModalStack() { this.modalStackService?.unregister(this); } /** * Announces messages to screen readers for accessibility. * @param message - Message to announce * @private */ announceToScreenReader(message) { const announcement = this.document?.createElement('div'); if (!announcement) return; announcement.setAttribute('aria-live', 'polite'); announcement.setAttribute('aria-atomic', 'true'); announcement.className = 'sr-only visually-hidden'; announcement.style.position = 'absolute'; announcement.style.left = '-10000px'; announcement.style.width = '1px'; announcement.style.height = '1px'; announcement.style.overflow = 'hidden'; announcement.textContent = message; this.document?.body.appendChild(announcement); setTimeout(() => { this.document?.body.removeChild(announcement); }, 1000); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgxCustomModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.2", type: NgxCustomModalComponent, isStandalone: true, selector: "ngx-custom-modal", inputs: { closeOnOutsideClick: "closeOnOutsideClick", closeOnEscape: "closeOnEscape", customClass: "customClass", hideCloseButton: "hideCloseButton", options: "options", size: "size", centered: "centered", scrollable: "scrollable", animation: "animation", backdrop: "backdrop", keyboard: "keyboard", focus: "focus" }, outputs: { opened: "opened", closed: "closed", opening: "opening", closing: "closing" }, host: { listeners: { "click": "onContainerClicked($event)", "document:keydown": "onKeyDownHandler($event)" } }, queries: [{ propertyName: "header", first: true, predicate: ["modalHeader"], descendants: true }, { propertyName: "body", first: true, predicate: ["modalBody"], descendants: true }, { propertyName: "footer", first: true, predicate: ["modalFooter"], descendants: true }], viewQueries: [{ propertyName: "modalDialog", first: true, predicate: ["modalDialog"], descendants: true }], ngImport: i0, template: "<!-- ./projects/ngx-custom-modal/src/lib/ngx-custom-modal.component.html -->\n\n<!--\n Main modal container with conditional rendering using Angular 17+ control flow.\n Uses signals for reactive visibility state and implements WCAG accessibility standards.\n-->\n@if (visible()) {\n <div\n class=\"modal fade\"\n role=\"dialog\"\n tabindex=\"-1\"\n [class.in]=\"visibleAnimate()\"\n [ngClass]=\"getCustomClass()\"\n [attr.aria-modal]=\"true\"\n [attr.aria-labelledby]=\"titleId()\"\n [attr.aria-describedby]=\"descriptionId()\"\n >\n <div class=\"modal-dialog\" [ngClass]=\"getDialogClasses()\">\n <div class=\"modal-content\">\n <!-- Header section with conditional close button -->\n @if (header || hasHeaderContent()) {\n <div class=\"modal-header\">\n <!-- User-provided header content via ng-template -->\n <ng-container *ngTemplateOutlet=\"header\"></ng-container>\n\n <!-- Close button respects hideCloseButton setting and options -->\n @if (!shouldHideCloseButton()) {\n <button class=\"close\" type=\"button\" [attr.aria-label]=\"getCloseButtonLabel()\" (click)=\"close()\">\n <span aria-hidden=\"true\">\u00D7</span>\n </button>\n }\n </div>\n }\n\n <!-- Body section with proper ARIA labeling for screen readers -->\n @if (body) {\n <div class=\"modal-body\" [id]=\"descriptionId()\">\n <!-- User-provided body content via ng-template -->\n <ng-container *ngTemplateOutlet=\"body\"></ng-container>\n </div>\n }\n\n <!-- Footer section for action buttons and additional content -->\n @if (footer || hasFooterContent()) {\n <div class=\"modal-footer\">\n <!-- User-provided footer content via ng-template -->\n <ng-container *ngTemplateOutlet=\"footer\"></ng-container>\n </div>\n }\n </div>\n </div>\n </div>\n}\n", styles: [":host{display:contents}:host ::ng-deep .modal{position:fixed;top:0;left:0;width:100%;min-height:100%;background-color:#00000026;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;z-index:1050;opacity:0;transition:opacity var(--modal-animation-duration, .2s) ease-in-out}:host ::ng-deep .modal.in{opacity:1}:host ::ng-deep .modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none;transform:scale(.9);transition:transform var(--modal-animation-duration, .2s) ease-in-out}:host ::ng-deep .modal.in .modal-dialog{transform:scale(1)}:host ::ng-deep .modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.125);border-radius:.5rem;box-shadow:0 .5rem 1rem #00000026;outline:0}:host ::ng-deep .modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.5rem - 1px);border-top-right-radius:calc(.5rem - 1px)}:host ::ng-deep .modal-body{position:relative;flex:1 1 auto;padding:1rem}:host ::ng-deep .modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.5rem - 1px);border-bottom-left-radius:calc(.5rem - 1px)}:host ::ng-deep .close{padding:.25rem;margin:-.25rem -.25rem -.25rem auto;background:transparent;border:0;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5;cursor:pointer;transition:opacity .15s ease-in-out}:host ::ng-deep .close:hover{color:#000;text-decoration:none;opacity:.75}:host ::ng-deep .close:focus{outline:0;box-shadow:0 0 0 .25rem #0d6efd40;opacity:1}:host ::ng-deep .modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}:host ::ng-deep .modal-dialog-scrollable{height:calc(100% - 1rem)}:host ::ng-deep .modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}:host ::ng-deep .modal-dialog-scrollable .modal-body{overflow-y:auto}:host ::ng-deep .modal-sm{max-width:300px}:host ::ng-deep .modal-lg{max-width:800px}:host ::ng-deep .modal-xl{max-width:1140px}@media (prefers-reduced-motion: reduce){:host ::ng-deep .modal{transition:none}:host ::ng-deep .modal-dialog{transition:none}}@media (prefers-color-scheme: dark){:host ::ng-deep .modal{background-color:#000c}:host ::ng-deep .modal-content{background-color:#1f2937;color:#f9fafb;border-color:#374151}:host ::ng-deep .modal-header,:host ::ng-deep .modal-footer{border-color:#374151}:host ::ng-deep .close{color:#f9fafb;text-shadow:0 1px 0 #000}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.2", ngImport: i0, type: NgxCustomModalComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-custom-modal', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<!-- ./projects/ngx-custom-modal/src/lib/ngx-custom-modal.component.html -->\n\n<!--\n Main modal container with conditional rendering using Angular 17+ control flow.\n Uses signals for reactive visibility state and implements WCAG accessibility standards.\n-->\n@if (visible()) {\n <div\n class=\"modal fade\"\n role=\"dialog\"\n tabindex=\"-1\"\n [class.in]=\"visibleAnimate()\"\n [ngClass]=\"getCustomClass()\"\n [attr.aria-modal]=\"true\"\n [attr.aria-labelledby]=\"titleId()\"\n [attr.aria-describedby]=\"descriptionId()\"\n >\n <div class=\"modal-dialog\" [ngClass]=\"getDialogClasses()\">\n <div class=\"modal-content\">\n <!-- Header section with conditional close button -->\n @if (header || hasHeaderContent()) {\n <div class=\"modal-header\">\n <!-- User-provided header content via ng-template -->\n <ng-container *ngTemplateOutlet=\"header\"></ng-container>\n\n <!-- Close button respects hideCloseButton setting and options -->\n @if (!shouldHideCloseButton()) {\n <button class=\"close\" type=\"button\" [attr.aria-label]=\"getCloseButtonLabel()\" (click)=\"close()\">\n <span aria-hidden=\"true\">\u00D7</span>\n </button>\n }\n </div>\n }\n\n <!-- Body section with proper ARIA labeling for screen readers -->\n @if (body) {\n <div class=\"modal-body\" [id]=\"descriptionId()\">\n <!-- User-provided body content via ng-template -->\n <ng-container *ngTemplateOutlet=\"body\"></ng-container>\n </div>\n }\n\n <!-- Footer section for action buttons and additional content -->\n @if (footer || hasFooterContent()) {\n <div class=\"modal-footer\">\n <!-- User-provided footer content via ng-template -->\n <ng-container *ngTemplateOutlet=\"footer\"></ng-container>\n </div>\n }\n </div>\n </div>\n </div>\n}\n", styles: [":host{display:contents}:host ::ng-deep .modal{position:fixed;top:0;left:0;width:100%;min-height:100%;background-color:#00000026;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center;z-index:1050;opacity:0;transition:opacity var(--modal-animation-duration, .2s) ease-in-out}:host ::ng-deep .modal.in{opacity:1}:host ::ng-deep .modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none;transform:scale(.9);transition:transform var(--modal-animation-duration, .2s) ease-in-out}:host ::ng-deep .modal.in .modal-dialog{transform:scale(1)}:host ::ng-deep .modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.125);border-radius:.5rem;box-shadow:0 .5rem 1rem #00000026;outline:0}:host ::ng-deep .modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.5rem - 1px);border-top-right-radius:calc(.5rem - 1px)}:host ::ng-deep .modal-body{position:relative;flex:1 1 auto;padding:1rem}:host ::ng-deep .modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.5rem - 1px);border-bottom-left-radius:calc(.5rem - 1px)}:host ::ng-deep .close{padding:.25rem;margin:-.25rem -.25rem -.25rem auto;background:transparent;border:0;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5;cursor:pointer;transition:opacity .15s ease-in-out}:host ::ng-deep .close:hover{color:#000;text-decoration:none;opacity:.75}:host ::ng-deep .close:focus{outline:0;box-shadow:0 0 0 .25rem #0d6efd40;opacity:1}:host ::ng-deep .modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}:host ::ng-deep .modal-dialog-scrollable{height:calc(100% - 1rem)}:host ::ng-deep .modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}:host ::ng-deep .modal-dialog-scrollable .modal-body{overflow-y:auto}:host ::ng-deep .modal-sm{max-width:300px}:host ::ng-deep .modal-lg{max-width:800px}:host ::ng-deep .modal-xl{max-width:1140px}@media (prefers-reduced-motion: reduce){:host ::ng-deep .modal{transition:none}:host ::ng-deep .modal-dialog{transition:none}}@media (prefers-color-scheme: dark){:host ::ng-deep .modal{background-color:#000c}:host ::ng-deep .modal-content{background-color:#1f2937;color:#f9fafb;border-color:#374151}:host ::ng-deep .modal-header,:host ::ng-deep .modal-footer{border-color:#374151}:host ::ng-deep .close{color:#f9fafb;text-shadow:0 1px 0 #000}}\n"] }] }], ctorParameters: () => [], propDecorators: { modalDialog: [{ type: ViewChild, args: ['modalDialog', { static: false }] }], header: [{ type: ContentChild, args: ['modalHeader'] }], body: [{ type: ContentChild, args: ['modalBody'] }], footer: [{ type: ContentChild, args: ['modalFooter'] }], closeOnOutsideClick: [{ type: Input }], closeOnEscape: [{ type: Input }], customClass: [{ type: Input }], hideCloseButton: [{ type: Input }], options: [{ type: Input }], size: [{ type: Input }], centered: [{ type: Input }], scrollable: [{ type: Input }], animation: [{ type: Input }], backdrop: [{ type: Input }], keyboard: [{ type: Input }], focus: [{ type: Input }], opened: [{ type: Output }], closed: [{ type: Output }], opening: [{ type: Output }], closing: [{ type: Output }], onContainerClicked: [{ type: HostListener, args: ['click', ['$event']] }], onKeyDownHandler: [{ type: HostListener, args: ['document:keydown', ['$event']] }] } }); // ./projects/ngx-custom-modal/src/public-api.ts /** * @fileoverview Public API for ngx-custom-modal library * * This file defines what is exported from the library and available * for consumers to import. All exports are carefully curated to provide * a clean and stable API surface. * * @version 20.0.0 * @author Angel Careaga <dev.angelcareaga@gmail.com> */ /** * Core modal component and stack management service. * These are the primary exports that most users will need. */ /** * Generated bundle index. Do not edit. */ export { NgxCustomModalComponent, NgxModalStackService }; //# sourceMappingURL=ngx-custom-modal.mjs.map