fernotify 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,929 @@
1
+ /**
2
+ * Sistema de Notificaciones Modernas (ESM)
3
+ * Librería ligera de notificaciones con animaciones fluidas
4
+ *
5
+ * RECOMENDADO: Cargar dependencias antes de importar:
6
+ * <script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js"></script>
7
+ * <link rel="stylesheet" href="https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css">
8
+ *
9
+ * Uso:
10
+ * import NotificationSystem from '@fernandocabal/fernotify';
11
+ * const notify = new NotificationSystem();
12
+ * notify.success('¡Hola!');
13
+ */
14
+
15
+ // Importar el código UMD y ejecutarlo para generar window.notify
16
+ /**
17
+ * Sistema de Notificaciones Modernas
18
+ * Librería ligera de notificaciones con animaciones fluidas
19
+ * Dependencias automáticas: anime.js, Boxicons
20
+ */
21
+
22
+ (function ensureAnimeDependency() {
23
+ if (typeof anime !== 'undefined') {
24
+ initFerNotify();
25
+ } else {
26
+ const script = document.createElement('script');
27
+ script.src = 'https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js';
28
+ script.onload = initFerNotify;
29
+ script.onerror = () => {
30
+ console.error('FerNotify: No se pudo cargar anime.js. Por favor, cargalo manualmente.');
31
+ };
32
+ document.head.appendChild(script);
33
+ }
34
+
35
+ function initFerNotify() {
36
+ class NotificationSystem {
37
+ constructor() {
38
+ this.currentNotification = null;
39
+ this._lastActiveElement = null;
40
+ this._currentLoadingPromise = null;
41
+ this.injectStyles();
42
+ this.loadBoxicons();
43
+ }
44
+
45
+ /**
46
+ * Cargar Boxicons CSS si no está presente
47
+ */
48
+ loadBoxicons() {
49
+ if (!document.querySelector('link[href*="boxicons"]')) {
50
+ const link = document.createElement('link');
51
+ link.rel = 'stylesheet';
52
+ link.href = 'https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css';
53
+ document.head.appendChild(link);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Inyectar estilos CSS
59
+ */
60
+ injectStyles() {
61
+ const style = document.createElement('style');
62
+ style.textContent = `
63
+ .notification-overlay {
64
+ position: fixed;
65
+ top: 0;
66
+ left: 0;
67
+ width: 100%;
68
+ height: 100%;
69
+ background-color: rgba(0, 0, 0, 0.4);
70
+ backdrop-filter: blur(4px);
71
+ -webkit-backdrop-filter: blur(4px);
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ z-index: 9999;
76
+ opacity: 0;
77
+ overflow: hidden;
78
+ }
79
+
80
+ .notification-box {
81
+ background: white;
82
+ border-radius: 16px;
83
+ padding: 40px 30px;
84
+ max-width: 500px;
85
+ width: 90%;
86
+ max-height: 80vh;
87
+ overflow: auto;
88
+ position: relative;
89
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
90
+ text-align: center;
91
+ transform: scale(0.7);
92
+ opacity: 0;
93
+ }
94
+
95
+ .notification-content {
96
+ text-align: left;
97
+ margin-bottom: 18px;
98
+ }
99
+
100
+ .notification-close {
101
+ position: absolute;
102
+ top: 10px;
103
+ right: 10px;
104
+ width: 38px;
105
+ height: 38px;
106
+ border-radius: 8px;
107
+ border: none;
108
+ background: rgba(0,0,0,0.06);
109
+ color: #111827;
110
+ display: inline-flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ cursor: pointer;
114
+ font-size: 18px;
115
+ }
116
+
117
+ .notification-close:hover {
118
+ background: rgba(0,0,0,0.09);
119
+ }
120
+
121
+ /* Form controls inside the modal */
122
+ .notification-box input,
123
+ .notification-box textarea,
124
+ .notification-box select {
125
+ width: 100%;
126
+ padding: 10px 12px;
127
+ border: 1px solid #e5e7eb;
128
+ border-radius: 8px;
129
+ background: #ffffff;
130
+ color: #111827;
131
+ font-size: 15px;
132
+ box-sizing: border-box;
133
+ transition: box-shadow 0.15s ease, border-color 0.15s ease;
134
+ }
135
+
136
+ .notification-box input:focus,
137
+ .notification-box textarea:focus,
138
+ .notification-box select:focus {
139
+ outline: none;
140
+ border-color: #6366f1;
141
+ box-shadow: 0 6px 24px rgba(99,102,241,0.12), 0 0 0 4px rgba(99,102,241,0.06);
142
+ }
143
+
144
+ .notification-box label { display: block; margin-bottom: 6px; color: #374151; font-weight: 600; }
145
+
146
+ /* Soporte para tema oscuro con clase .dark (Tailwind darkMode: 'class') */
147
+ /* Esto tiene prioridad sobre prefers-color-scheme para respetar la elección del usuario en la web */
148
+ .dark .notification-box { background: #0f1724 !important; color: #e6eef8 !important; }
149
+ .dark .notification-box input,
150
+ .dark .notification-box textarea,
151
+ .dark .notification-box select {
152
+ background: #0b1220 !important;
153
+ border: 1px solid rgba(255,255,255,0.06) !important;
154
+ color: #e6eef8 !important;
155
+ }
156
+ .dark .notification-box .notification-close { background: rgba(255,255,255,0.03) !important; color: #e6eef8 !important; }
157
+ .dark .notification-overlay { background-color: rgba(0,0,0,0.6) !important; }
158
+ .dark .notification-title { color: #e6eef8 !important; }
159
+ .dark .notification-message { color: #cbd5e1 !important; }
160
+
161
+ /* Forzar modo claro cuando NO hay clase .dark, ignorando prefers-color-scheme */
162
+ html:not(.dark) .notification-box { background: white !important; color: #111827 !important; }
163
+ html:not(.dark) .notification-box input,
164
+ html:not(.dark) .notification-box textarea,
165
+ html:not(.dark) .notification-box select {
166
+ background: #ffffff !important;
167
+ border: 1px solid #e5e7eb !important;
168
+ color: #111827 !important;
169
+ }
170
+ html:not(.dark) .notification-box .notification-close { background: rgba(0,0,0,0.06) !important; color: #111827 !important; }
171
+ html:not(.dark) .notification-overlay { background-color: rgba(0, 0, 0, 0.4) !important; }
172
+ html:not(.dark) .notification-title { color: #1f2937 !important; }
173
+ html:not(.dark) .notification-message { color: #6b7280 !important; }
174
+
175
+ .notification-icon {
176
+ width: 80px;
177
+ height: 80px;
178
+ border-radius: 50%;
179
+ margin: 0 auto 25px;
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ font-size: 40px;
184
+ position: relative;
185
+ }
186
+
187
+ .notification-icon::before {
188
+ content: '';
189
+ position: absolute;
190
+ width: 100%;
191
+ height: 100%;
192
+ border-radius: 50%;
193
+ opacity: 0.2;
194
+ }
195
+
196
+ .notification-icon.success {
197
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
198
+ color: white;
199
+ }
200
+
201
+ .notification-icon.success::before {
202
+ background: #10b981;
203
+ }
204
+
205
+ .notification-icon.error {
206
+ background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
207
+ color: white;
208
+ }
209
+
210
+ .notification-icon.error::before {
211
+ background: #ef4444;
212
+ }
213
+
214
+ .notification-icon.warning {
215
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
216
+ color: white;
217
+ }
218
+
219
+ .notification-icon.warning::before {
220
+ background: #f59e0b;
221
+ }
222
+
223
+ .notification-icon.info {
224
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
225
+ color: white;
226
+ }
227
+
228
+ .notification-icon.info::before {
229
+ background: #3b82f6;
230
+ }
231
+
232
+ .notification-title {
233
+ font-size: 24px;
234
+ font-weight: 700;
235
+ color: #1f2937;
236
+ margin-bottom: 12px;
237
+ line-height: 1.3;
238
+ }
239
+
240
+ .notification-message {
241
+ font-size: 16px;
242
+ color: #6b7280;
243
+ line-height: 1.6;
244
+ margin-bottom: 30px;
245
+ }
246
+
247
+ .notification-button {
248
+ color: white;
249
+ border: none;
250
+ padding: 10px 14px;
251
+ border-radius: 8px;
252
+ font-size: 16px;
253
+ font-weight: 600;
254
+ cursor: pointer;
255
+ transition: all 0.3s ease;
256
+ }
257
+
258
+ .notification-button:hover {
259
+ transform: translateY(-2px);
260
+ filter: brightness(1.1);
261
+ }
262
+
263
+ .notification-button:active {
264
+ transform: translateY(0);
265
+ }
266
+
267
+ .notification-icon-checkmark {
268
+ animation: checkmark-draw 0.6s ease-in-out;
269
+ }
270
+
271
+ .notification-icon-cross {
272
+ animation: cross-draw 0.5s ease-in-out;
273
+ }
274
+
275
+ @keyframes checkmark-draw {
276
+ 0% {
277
+ transform: scale(0) rotate(-45deg);
278
+ opacity: 0;
279
+ }
280
+ 50% {
281
+ transform: scale(1.2) rotate(-45deg);
282
+ }
283
+ 100% {
284
+ transform: scale(1) rotate(0deg);
285
+ opacity: 1;
286
+ }
287
+ }
288
+
289
+ @keyframes cross-draw {
290
+ 0% {
291
+ transform: scale(0) rotate(-180deg);
292
+ opacity: 0;
293
+ }
294
+ 50% {
295
+ transform: scale(1.2) rotate(-90deg);
296
+ }
297
+ 100% {
298
+ transform: scale(1) rotate(0deg);
299
+ opacity: 1;
300
+ }
301
+ }
302
+
303
+ /* Loading spinner styles */
304
+ .notification-loading-container {
305
+ display: flex;
306
+ flex-direction: column;
307
+ align-items: center;
308
+ justify-content: center;
309
+ margin: 0 auto;
310
+ }
311
+
312
+ .notification-spinner {
313
+ width: 60px;
314
+ height: 60px;
315
+ border: 5px solid rgba(99, 102, 241, 0.15);
316
+ border-top-color: #6366f1;
317
+ border-radius: 50%;
318
+ animation: notification-spin 1s linear infinite;
319
+ margin: 0 auto;
320
+ }
321
+
322
+ @keyframes notification-spin {
323
+ to {
324
+ transform: rotate(360deg);
325
+ }
326
+ }
327
+
328
+ .notification-loading-text {
329
+ font-size: 14px;
330
+ color: #6b7280;
331
+ text-align: center;
332
+ margin-top: 12px;
333
+ }
334
+
335
+ .dark .notification-loading-text {
336
+ color: #cbd5e1;
337
+ }
338
+ `;
339
+ document.head.appendChild(style);
340
+ }
341
+
342
+ /**
343
+ * Obtener ícono según el tipo
344
+ */
345
+ getIcon(type) {
346
+ // Return a Boxicons markup string — presentation/index.php now imports the CSS.
347
+ const icons = {
348
+ 'success': '<i class="bx bx-check" aria-hidden="true"></i>',
349
+ 'error': '<i class="bx bx-x" aria-hidden="true"></i>',
350
+ 'warning': '<i class="bx bx-error" aria-hidden="true"></i>',
351
+ 'info': '<i class="bx bx-info-circle" aria-hidden="true"></i>'
352
+ };
353
+ return icons[type] || icons.info;
354
+ }
355
+
356
+ /**
357
+ * Obtener título por defecto según el tipo
358
+ */
359
+ getDefaultTitle(type) {
360
+ const titles = {
361
+ 'success': '¡Éxito!',
362
+ 'error': 'Error',
363
+ 'warning': 'Advertencia',
364
+ 'info': 'Información'
365
+ };
366
+ return titles[type] || 'Notificación';
367
+ }
368
+
369
+ /**
370
+ * Obtener gradiente de botón según el tipo
371
+ */
372
+ getButtonGradient(type) {
373
+ const gradients = {
374
+ 'success': 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
375
+ 'error': 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
376
+ 'warning': 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
377
+ 'info': 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)'
378
+ };
379
+ return gradients[type] || gradients.info;
380
+ }
381
+
382
+ /**
383
+ * Obtener color de sombra según el tipo
384
+ */
385
+ getButtonShadow(type) {
386
+ const shadows = {
387
+ 'success': 'rgba(16, 185, 129, 0.4)',
388
+ 'error': 'rgba(239, 68, 68, 0.4)',
389
+ 'warning': 'rgba(245, 158, 11, 0.4)',
390
+ 'info': 'rgba(59, 130, 246, 0.4)'
391
+ };
392
+ return shadows[type] || shadows.info;
393
+ }
394
+
395
+ /**
396
+ * Mostrar notificación
397
+ *
398
+ * @param {Object} options - Opciones de la notificación
399
+ * @param {string} options.type - Tipo: 'success', 'error', 'warning', 'info'
400
+ * @param {string} options.title - Título (opcional, usa título por defecto)
401
+ * @param {string} options.message - Mensaje a mostrar
402
+ * @param {string} options.buttonText - Texto del botón (opcional, default: 'OK')
403
+ * @param {string} options.buttonColor - Color del botón en formato CSS (opcional, usa color del tipo por defecto)
404
+ * @param {Function} options.onClose - Callback al cerrar (opcional)
405
+ * @param {number} options.timer - Auto-cerrar después de X ms (opcional)
406
+ * @param {boolean} options.allowOutsideClick - Permitir cerrar haciendo click fuera (default: true)
407
+ * @param {boolean} options.allowEscapeKey - Permitir cerrar con tecla ESC (default: true)
408
+ */
409
+ show(options = {}) {
410
+ // Cerrar notificación existente si hay (esperar a que termine)
411
+ if (this.currentNotification) {
412
+ // Asegurar que se elimine completamente antes de continuar
413
+ const oldOverlay = this.currentNotification;
414
+ this.currentNotification = null;
415
+ try {
416
+ if (oldOverlay && oldOverlay.parentNode) {
417
+ oldOverlay.parentNode.removeChild(oldOverlay);
418
+ }
419
+ } catch (e) { }
420
+ }
421
+
422
+ const {
423
+ type = 'info',
424
+ title = this.getDefaultTitle(type),
425
+ message = '',
426
+ buttonText = 'OK',
427
+ buttonColor = null,
428
+ onClose = null,
429
+ timer = null,
430
+ allowOutsideClick = true,
431
+ allowEscapeKey = true,
432
+ // New option: hideButton true -> do not render action button
433
+ hideButton = false
434
+ } = options;
435
+
436
+ // Option to show a small close 'X' in the corner
437
+ const showCloseButton = options.showCloseButton === true;
438
+
439
+ // Bloquear scroll del body y root (más robusto)
440
+ try { document.body.style.overflow = 'hidden'; } catch (e) { }
441
+ try { document.documentElement.style.overflow = 'hidden'; } catch (e) { }
442
+
443
+ // Crear overlay
444
+ const overlay = document.createElement('div');
445
+ overlay.className = 'notification-overlay';
446
+ // Accessibility: make overlay focusable and a dialog
447
+ overlay.tabIndex = -1;
448
+ overlay.setAttribute('role', 'dialog');
449
+ overlay.setAttribute('aria-modal', 'true');
450
+ // Ensure overlay receives pointer events
451
+ overlay.style.pointerEvents = 'auto';
452
+
453
+ // Crear box
454
+ const box = document.createElement('div');
455
+ box.className = 'notification-box';
456
+
457
+ // Crear ícono
458
+ const icon = document.createElement('div');
459
+ icon.className = `notification-icon ${type}`;
460
+
461
+ // Si la opción hideButton es true Y message contiene patrón de loading, mostrar spinner
462
+ if (hideButton && type === 'info') {
463
+ // Mostrar spinner para notificación de carga
464
+ icon.className = 'notification-loading-container';
465
+ icon.innerHTML = '<div class="notification-spinner"></div>';
466
+ icon.style.background = 'transparent';
467
+ icon.style.boxShadow = 'none';
468
+ // Mantener tamaño adecuado para el spinner
469
+ icon.style.width = '100px';
470
+ icon.style.height = '100px';
471
+ } else {
472
+ icon.innerHTML = this.getIcon(type);
473
+ }
474
+
475
+ // Crear título
476
+ const titleElement = document.createElement('h3');
477
+ titleElement.className = 'notification-title';
478
+ titleElement.textContent = title;
479
+
480
+ // Crear mensaje
481
+ const messageElement = document.createElement('p');
482
+ messageElement.className = 'notification-message';
483
+ messageElement.textContent = message;
484
+
485
+ // Custom content support: options.html (string) or options.content (HTMLElement)
486
+ let customContent = null;
487
+ if (options.html || options.content) {
488
+ customContent = document.createElement('div');
489
+ customContent.className = 'notification-content';
490
+ if (options.html) {
491
+ try { customContent.innerHTML = options.html; } catch (e) { customContent.textContent = options.html; }
492
+ } else if (options.content && options.content instanceof HTMLElement) {
493
+ customContent.appendChild(options.content);
494
+ }
495
+ }
496
+
497
+ // Crear (opcional) botón. Si hideButton === true o buttonText es falsy, no renderizamos.
498
+ let button = null;
499
+ if (!hideButton && buttonText) {
500
+ button = document.createElement('button');
501
+ button.className = 'notification-button';
502
+ button.textContent = buttonText;
503
+
504
+ // Aplicar color del botón (personalizado o automático según tipo)
505
+ const finalButtonColor = buttonColor || this.getButtonGradient(type);
506
+ const buttonShadowColor = this.getButtonShadow(type);
507
+ button.style.background = finalButtonColor;
508
+ button.style.boxShadow = `0 4px 12px ${buttonShadowColor}`;
509
+ }
510
+
511
+ // Close 'X' button in corner (optional)
512
+ let closeBtn = null;
513
+ if (showCloseButton) {
514
+ closeBtn = document.createElement('button');
515
+ closeBtn.setAttribute('aria-label', 'Cerrar');
516
+ closeBtn.className = 'notification-close';
517
+ closeBtn.innerHTML = '&times;';
518
+ closeBtn.addEventListener('click', (e) => {
519
+ e.stopPropagation();
520
+ closeHandler();
521
+ });
522
+ }
523
+
524
+ // Ensamblar
525
+ box.appendChild(icon);
526
+ // If customContent provided, prefer it. Otherwise render title/message as before.
527
+ if (customContent) {
528
+ // For accessibility, link aria-describedby to the content
529
+ const descId = 'notify-desc-' + Date.now();
530
+ customContent.id = descId;
531
+ overlay.setAttribute('aria-describedby', descId);
532
+ box.appendChild(customContent);
533
+ } else {
534
+ box.appendChild(titleElement);
535
+ box.appendChild(messageElement);
536
+ }
537
+ // Append close button last so it's visually on top
538
+ if (closeBtn) box.appendChild(closeBtn);
539
+ if (button) box.appendChild(button);
540
+ overlay.appendChild(box);
541
+ document.body.appendChild(overlay);
542
+
543
+ // Provide a Promise that resolves when this notification is closed.
544
+ const closePromise = new Promise((resolveClose) => {
545
+ try { overlay._externalResolve = resolveClose; } catch (e) { /* ignore */ }
546
+ });
547
+
548
+ // If page provides a live region, update it for screen readers
549
+ try {
550
+ const live = document.getElementById('notify-live');
551
+ if (live) {
552
+ live.textContent = `${title}: ${message}`;
553
+ }
554
+ } catch (e) { }
555
+
556
+ // Save current focused element to restore later
557
+ try { this._lastActiveElement = document.activeElement; } catch (e) { this._lastActiveElement = null; }
558
+
559
+ this.currentNotification = overlay;
560
+
561
+ // Move focus into the notification for accessibility
562
+ // Focus management: focus first focusable element inside box, otherwise the button, otherwise overlay
563
+ try {
564
+ const focusable = box.querySelectorAll('a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])');
565
+ if (focusable && focusable.length) {
566
+ focusable[0].focus();
567
+ } else if (button) {
568
+ button.focus();
569
+ } else {
570
+ overlay.focus();
571
+ }
572
+ } catch (e) { try { overlay.focus(); } catch (err) { } }
573
+
574
+ // Implement focus trap (Tab/Shift+Tab) inside the box
575
+ const focusTrap = (e) => {
576
+ if (e.key !== 'Tab') return;
577
+ const focusable = Array.from(box.querySelectorAll('a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'))
578
+ .filter(el => el.offsetParent !== null);
579
+ if (!focusable.length) {
580
+ e.preventDefault();
581
+ return;
582
+ }
583
+ const first = focusable[0];
584
+ const last = focusable[focusable.length - 1];
585
+ if (!e.shiftKey && document.activeElement === last) {
586
+ e.preventDefault();
587
+ first.focus();
588
+ } else if (e.shiftKey && document.activeElement === first) {
589
+ e.preventDefault();
590
+ last.focus();
591
+ }
592
+ };
593
+ overlay._focusTrap = focusTrap;
594
+ document.addEventListener('keydown', focusTrap);
595
+
596
+ // Allow optional animation overrides via options.anim
597
+ const anim = options.anim || {};
598
+ const overlayDuration = typeof anim.overlayDuration === 'number' ? anim.overlayDuration : 150;
599
+ const overlayEasing = anim.overlayEasing || 'easeOutQuad';
600
+ const boxDuration = typeof anim.boxDuration === 'number' ? anim.boxDuration : 200;
601
+ const boxDelay = typeof anim.boxDelay === 'number' ? anim.boxDelay : 50;
602
+ const boxEasing = anim.boxEasing || 'easeOutBack';
603
+ const boxStartScale = typeof anim.boxStartScale === 'number' ? anim.boxStartScale : 0.8;
604
+ const iconDuration = typeof anim.iconDuration === 'number' ? anim.iconDuration : 250;
605
+ const iconDelay = typeof anim.iconDelay === 'number' ? anim.iconDelay : 100;
606
+ const iconRotate = (typeof anim.iconRotate === 'number') ? anim.iconRotate : (type === 'success' ? -90 : type === 'error' ? 90 : 0);
607
+ if (typeof anim.overlayOpacity === 'number') {
608
+ overlay.style.backgroundColor = `rgba(0,0,0,${anim.overlayOpacity})`;
609
+ }
610
+
611
+ // Animación de entrada con anime.js - configurable
612
+ anime({
613
+ targets: overlay,
614
+ opacity: [0, 1],
615
+ duration: overlayDuration,
616
+ easing: overlayEasing
617
+ });
618
+
619
+ anime({
620
+ targets: box,
621
+ scale: [boxStartScale, 1],
622
+ opacity: [0, 1],
623
+ duration: boxDuration,
624
+ easing: boxEasing,
625
+ delay: boxDelay
626
+ });
627
+
628
+ anime({
629
+ targets: icon,
630
+ scale: [0, 1],
631
+ rotate: [iconRotate, 0],
632
+ duration: iconDuration,
633
+ easing: boxEasing,
634
+ delay: iconDelay
635
+ });
636
+
637
+ // Efecto hover y listener del botón (solo si existe)
638
+ const closeHandler = () => {
639
+ this.close(onClose);
640
+ };
641
+ if (button) {
642
+ const buttonShadowColor = this.getButtonShadow(type);
643
+ button.addEventListener('mouseenter', () => {
644
+ button.style.boxShadow = `0 6px 16px ${buttonShadowColor}`;
645
+ });
646
+ button.addEventListener('mouseleave', () => {
647
+ button.style.boxShadow = `0 4px 12px ${buttonShadowColor}`;
648
+ });
649
+ button.addEventListener('click', (e) => {
650
+ e.stopPropagation(); // Evitar que el evento llegue al overlay
651
+ e.preventDefault();
652
+ closeHandler();
653
+ });
654
+ }
655
+
656
+ // Click en overlay para cerrar (solo si está permitido)
657
+ if (allowOutsideClick) {
658
+ overlay.addEventListener('click', (e) => {
659
+ // Close when clicking outside the box (more robust than e.target === overlay)
660
+ if (!box.contains(e.target)) {
661
+ closeHandler();
662
+ }
663
+ });
664
+ }
665
+
666
+ // Auto-cerrar si hay timer
667
+ if (timer) {
668
+ setTimeout(() => {
669
+ closeHandler();
670
+ }, timer);
671
+ }
672
+
673
+ // Tecla ESC para cerrar (solo si está permitido)
674
+ if (allowEscapeKey) {
675
+ const escHandler = (e) => {
676
+ if (e.key === 'Escape') {
677
+ closeHandler();
678
+ document.removeEventListener('keydown', escHandler);
679
+ }
680
+ };
681
+ // Store handler reference on overlay so close() can remove it if needed
682
+ overlay._escHandler = escHandler;
683
+ document.addEventListener('keydown', escHandler);
684
+ }
685
+
686
+ // Return a Promise that resolves when the notification is closed
687
+ return closePromise;
688
+ }
689
+
690
+ /**
691
+ * Cerrar notificación actual
692
+ */
693
+ close(callback = null) {
694
+ if (!this.currentNotification) {
695
+ return Promise.resolve();
696
+ }
697
+
698
+ const overlay = this.currentNotification;
699
+ const box = overlay.querySelector('.notification-box');
700
+
701
+ // CRÍTICO: Limpiar la referencia INMEDIATAMENTE para evitar conflictos
702
+ this.currentNotification = null;
703
+
704
+ // Animación de salida
705
+ anime({
706
+ targets: box,
707
+ scale: 0.8,
708
+ opacity: 0,
709
+ duration: 100,
710
+ easing: 'easeInQuad'
711
+ });
712
+
713
+ return new Promise((resolve) => {
714
+ anime({
715
+ targets: overlay,
716
+ opacity: 0,
717
+ duration: 100,
718
+ easing: 'easeInQuad',
719
+ complete: () => {
720
+ // Remove keydown handler if present
721
+ try {
722
+ if (overlay && overlay._escHandler) {
723
+ document.removeEventListener('keydown', overlay._escHandler);
724
+ overlay._escHandler = null;
725
+ }
726
+ } catch (e) { }
727
+
728
+ // Remove focus trap if present
729
+ try {
730
+ if (overlay && overlay._focusTrap) {
731
+ document.removeEventListener('keydown', overlay._focusTrap);
732
+ overlay._focusTrap = null;
733
+ }
734
+ } catch (e) { }
735
+
736
+ // Resolve external Promise returned by show(), if present
737
+ try {
738
+ if (overlay && typeof overlay._externalResolve === 'function') {
739
+ try { overlay._externalResolve(); } catch (er) { }
740
+ overlay._externalResolve = null;
741
+ }
742
+ } catch (e) { }
743
+
744
+ // CRÍTICO: Asegurar eliminación completa del overlay
745
+ try {
746
+ if (overlay && overlay.parentNode) {
747
+ overlay.parentNode.removeChild(overlay);
748
+ }
749
+ } catch (e) {
750
+ try { overlay.remove(); } catch (er) { }
751
+ }
752
+
753
+ // Restaurar scroll SOLO si NO hay otra notificación activa
754
+ if (!this.currentNotification) {
755
+ try { document.body.style.overflow = ''; } catch (e) { }
756
+ try { document.documentElement.style.overflow = ''; } catch (e) { }
757
+ }
758
+
759
+ // Restore previous focus if possible
760
+ try {
761
+ if (this._lastActiveElement && typeof this._lastActiveElement.focus === 'function') {
762
+ this._lastActiveElement.focus();
763
+ }
764
+ } catch (e) { }
765
+ this._lastActiveElement = null;
766
+
767
+ if (callback) callback();
768
+ resolve();
769
+ }
770
+ });
771
+ });
772
+ }
773
+
774
+ /**
775
+ * Métodos de acceso rápido
776
+ */
777
+ success(message, title = null, options = {}) {
778
+ this.show({
779
+ type: 'success',
780
+ title: title || this.getDefaultTitle('success'),
781
+ message,
782
+ ...options
783
+ });
784
+ }
785
+
786
+ error(message, title = null, options = {}) {
787
+ this.show({
788
+ type: 'error',
789
+ title: title || this.getDefaultTitle('error'),
790
+ message,
791
+ ...options
792
+ });
793
+ }
794
+
795
+ warning(message, title = null, options = {}) {
796
+ this.show({
797
+ type: 'warning',
798
+ title: title || this.getDefaultTitle('warning'),
799
+ message,
800
+ ...options
801
+ });
802
+ }
803
+
804
+ info(message, title = null, options = {}) {
805
+ this.show({
806
+ type: 'info',
807
+ title: title || this.getDefaultTitle('info'),
808
+ message,
809
+ ...options
810
+ });
811
+ }
812
+
813
+ /**
814
+ * Mostrar notificación de carga con spinner
815
+ * Útil para operaciones async, cargas desde backend, etc
816
+ *
817
+ * @param {string} message - Mensaje a mostrar
818
+ * @param {string} title - Título (opcional, default: 'Cargando...')
819
+ * @param {Object} options - Opciones adicionales
820
+ * @param {number} options.timer - Auto-cerrar después de X ms (opcional)
821
+ * @param {boolean} options.allowOutsideClick - Permitir cerrar haciendo click fuera (default: false)
822
+ * @param {boolean} options.allowEscapeKey - Permitir cerrar con tecla ESC (default: false)
823
+ * @param {Function} options.onClose - Callback al cerrar (opcional)
824
+ * @returns {Promise} Promesa que se resuelve cuando se cierre la notificación
825
+ *
826
+ * @example
827
+ * // Usar como promise
828
+ * notify.loading('Procesando...', 'Espera')
829
+ * .then(() => console.log('Completado'));
830
+ *
831
+ * // Cerrar manualmente
832
+ * const loadingPromise = notify.loading('Subiendo archivo...');
833
+ * setTimeout(() => notify.closeLoading(), 3000);
834
+ *
835
+ * // Con respuesta de backend
836
+ * notify.loading('Obteniendo datos...');
837
+ * fetch('/api/datos')
838
+ * .then(res => res.json())
839
+ * .then(data => {
840
+ * notify.closeLoading();
841
+ * notify.success('Datos cargados');
842
+ * })
843
+ * .catch(err => {
844
+ * notify.closeLoading();
845
+ * notify.error('Error: ' + err.message);
846
+ * });
847
+ */
848
+ loading(message = 'Cargando...', title = 'Espera', options = {}) {
849
+ // No cierra al hacer click fuera ni ESC por defecto
850
+ const loadingOptions = {
851
+ type: 'info',
852
+ title,
853
+ message,
854
+ hideButton: true,
855
+ allowOutsideClick: false,
856
+ allowEscapeKey: false,
857
+ ...options
858
+ };
859
+
860
+ // Crear una promesa wrapper para poder guardar la referencia y resolver después
861
+ const loadingPromise = this.show(loadingOptions);
862
+
863
+ // Guardar referencia a esta promesa para poder cerrarla después
864
+ this._currentLoadingPromise = loadingPromise;
865
+
866
+ return loadingPromise;
867
+ }
868
+
869
+ /**
870
+ * Cerrar la notificación de carga actual
871
+ *
872
+ * @param {Function} callback - Callback al cerrar (opcional)
873
+ * @returns {Promise} Promesa que se resuelve cuando se cierre
874
+ *
875
+ * @example
876
+ * notify.closeLoading();
877
+ * // o con callback
878
+ * notify.closeLoading(() => console.log('Loading cerrado'));
879
+ */
880
+ closeLoading(callback = null) {
881
+ this._currentLoadingPromise = null;
882
+ return this.close(callback);
883
+ }
884
+
885
+ /**
886
+ * Alias para cerrar/ocultar la notificación actual
887
+ */
888
+ hide(callback = null) {
889
+ return this.close(callback);
890
+ }
891
+
892
+ /**
893
+ * Compatibilidad con nombre mal escrito 'hiden'
894
+ */
895
+ hiden(callback = null) {
896
+ return this.close(callback);
897
+ }
898
+
899
+ /**
900
+ * Formatea segundos a mm:ss
901
+ */
902
+ _formatTime(seconds) {
903
+ const s = Math.max(0, Math.floor(seconds));
904
+ const mm = Math.floor(s / 60).toString().padStart(2, '0');
905
+ const ss = (s % 60).toString().padStart(2, '0');
906
+ return `${mm}:${ss}`;
907
+ }
908
+
909
+ }
910
+
911
+ // Crear instancia global
912
+ window.notify = new NotificationSystem();
913
+
914
+ // Exponer también como objeto global para compatibilidad
915
+ window.Notification = window.notify;
916
+ }
917
+ })();
918
+
919
+
920
+ // Extraer la clase desde la instancia global
921
+ const NotificationSystem = window.notify?.constructor || function() {
922
+ throw new Error('NotificationSystem no se pudo cargar. Verifica que anime.js esté disponible.');
923
+ };
924
+
925
+ // Reexportar para módulos ESM
926
+ export default NotificationSystem;
927
+ export { NotificationSystem };
928
+
929
+ // La instancia global también está disponible como window.notify