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.
- package/LICENSE +21 -0
- package/NOTIFICATION_SYSTEM_GUIDE.md +757 -0
- package/README.md +402 -0
- package/dist/notification-system.esm.js +929 -0
- package/dist/notification-system.esm.min.js +1 -0
- package/dist/notification-system.js +902 -0
- package/dist/notification-system.min.js +1 -0
- package/package.json +50 -0
|
@@ -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 = '×';
|
|
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
|