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
JavaScript
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