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