vanillaforge 1.9.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,427 @@
1
+ /**
2
+ * VanillaForge built-in alerts plugin.
3
+ *
4
+ * Provides zero-dependency toasts and confirm dialogs so apps don't need
5
+ * SweetAlert, alert(), or any other UI library for basic user feedback.
6
+ * Folds in and replaces the old src/utils/notification.js interface so the
7
+ * ErrorHandler automatically benefits when this plugin is installed.
8
+ *
9
+ * Usage:
10
+ * import { createApp, alertsPlugin } from './src/framework.js';
11
+ * const app = createApp({ ... });
12
+ * app.use(alertsPlugin);
13
+ * app.use(alertsPlugin, { duration: 3000, maxToasts: 4 });
14
+ *
15
+ * In any component:
16
+ * this.service('alerts').success('Saved!');
17
+ * this.service('alerts').error('Something went wrong');
18
+ * this.service('alerts').warning('Check your input');
19
+ * this.service('alerts').info('Processing...');
20
+ *
21
+ * const confirmed = await this.service('alerts').confirm('Delete this item?', {
22
+ * title: 'Are you sure?', // optional heading
23
+ * confirmText: 'Delete', // default 'Confirm'
24
+ * cancelText: 'Cancel', // default 'Cancel'
25
+ * danger: true, // red confirm button
26
+ * onConfirm: () => { ... }, // optional callback (in addition to the Promise)
27
+ * onCancel: () => { ... }, // optional callback
28
+ * });
29
+ *
30
+ * Toasts use --vf-* custom properties if the theme plugin is installed,
31
+ * with plain-CSS fallback values so they look fine without it.
32
+ */
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Minimal inline SVGs for toast type indicators
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const ICONS = {
39
+ success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4,12 9,17 20,6"/></svg>',
40
+ error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
41
+ warning: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>',
42
+ info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Injected stylesheet
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const ALERTS_STYLES = `
50
+ /* --- VanillaForge alerts --- */
51
+ #vf-alerts-toasts {
52
+ position: fixed;
53
+ top: 20px;
54
+ right: 20px;
55
+ z-index: 9999;
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: 8px;
59
+ pointer-events: none;
60
+ max-width: 360px;
61
+ width: calc(100vw - 40px);
62
+ }
63
+
64
+ .vf-toast {
65
+ pointer-events: auto;
66
+ display: flex;
67
+ align-items: flex-start;
68
+ gap: 10px;
69
+ padding: 12px 14px;
70
+ border-radius: var(--vf-radius, 6px);
71
+ box-shadow: var(--vf-shadow-md, 0 4px 16px rgba(0,0,0,.12));
72
+ font-size: .9rem;
73
+ line-height: 1.45;
74
+ border-left: 4px solid transparent;
75
+ animation: vf-toast-in .22s cubic-bezier(.2,.8,.3,1);
76
+ word-break: break-word;
77
+ }
78
+
79
+ @keyframes vf-toast-in {
80
+ from { transform: translateX(110%); opacity: 0; }
81
+ to { transform: translateX(0); opacity: 1; }
82
+ }
83
+
84
+ .vf-toast-success { background: #f0fdf4; color: #166534; border-left-color: var(--vf-success, #10b981); }
85
+ .vf-toast-error { background: #fef2f2; color: #991b1b; border-left-color: var(--vf-danger, #ef4444); }
86
+ .vf-toast-warning { background: #fffbeb; color: #92400e; border-left-color: var(--vf-warning, #f59e0b); }
87
+ .vf-toast-info { background: #eff6ff; color: #1e40af; border-left-color: var(--vf-primary, #3b82f6); }
88
+
89
+ .vf-toast-icon { flex-shrink: 0; margin-top: 1px; }
90
+ .vf-toast-body { flex: 1; }
91
+
92
+ .vf-toast-close {
93
+ flex-shrink: 0;
94
+ background: none;
95
+ border: none;
96
+ cursor: pointer;
97
+ opacity: .5;
98
+ font-size: 1.1rem;
99
+ line-height: 1;
100
+ padding: 0;
101
+ margin-left: 4px;
102
+ color: inherit;
103
+ align-self: flex-start;
104
+ }
105
+ .vf-toast-close:hover { opacity: 1; }
106
+
107
+ /* Dialog / confirm overlay */
108
+ .vf-dialog-overlay {
109
+ position: fixed;
110
+ inset: 0;
111
+ background: rgba(0,0,0,.45);
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ z-index: 10000;
116
+ animation: vf-fade-in .15s ease;
117
+ }
118
+
119
+ @keyframes vf-fade-in {
120
+ from { opacity: 0; }
121
+ to { opacity: 1; }
122
+ }
123
+
124
+ .vf-dialog {
125
+ background: var(--vf-surface, #fff);
126
+ border-radius: var(--vf-radius-lg, 12px);
127
+ box-shadow: var(--vf-shadow-lg, 0 10px 30px rgba(0,0,0,.16));
128
+ padding: 28px;
129
+ max-width: 400px;
130
+ width: calc(100% - 40px);
131
+ animation: vf-dialog-in .2s cubic-bezier(.2,.8,.3,1);
132
+ }
133
+
134
+ @keyframes vf-dialog-in {
135
+ from { transform: scale(.92); opacity: 0; }
136
+ to { transform: scale(1); opacity: 1; }
137
+ }
138
+
139
+ .vf-dialog-title {
140
+ font-size: 1.05rem;
141
+ font-weight: 700;
142
+ margin: 0 0 8px;
143
+ color: var(--vf-text, #1f2933);
144
+ }
145
+
146
+ .vf-dialog-message {
147
+ color: var(--vf-text-muted, #7b8794);
148
+ font-size: .93rem;
149
+ margin: 0 0 24px;
150
+ line-height: 1.55;
151
+ }
152
+
153
+ .vf-dialog-details {
154
+ margin: 0 0 20px;
155
+ font-size: .82rem;
156
+ color: var(--vf-text-muted, #7b8794);
157
+ }
158
+
159
+ .vf-dialog-details summary { cursor: pointer; }
160
+
161
+ .vf-dialog-details pre {
162
+ overflow: auto;
163
+ max-height: 180px;
164
+ margin-top: 8px;
165
+ padding: 8px;
166
+ background: var(--vf-border, #e5e7eb);
167
+ border-radius: var(--vf-radius-sm, 4px);
168
+ white-space: pre-wrap;
169
+ word-break: break-all;
170
+ font-size: .8rem;
171
+ }
172
+
173
+ .vf-dialog-actions {
174
+ display: flex;
175
+ justify-content: flex-end;
176
+ gap: 10px;
177
+ }
178
+ `;
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // AlertsService
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /**
185
+ * Provides toasts and confirm dialogs.
186
+ * Registered under the key 'alerts' via app.provide().
187
+ * Also compatible with the old Notification interface (showToast / showModal)
188
+ * so ErrorHandler picks it up automatically.
189
+ */
190
+ export class AlertsService {
191
+ constructor(options = {}) {
192
+ this._duration = options.duration ?? 4000;
193
+ this._maxToasts = options.maxToasts ?? 5;
194
+ this._container = null;
195
+ this._styleInjected = false;
196
+ }
197
+
198
+ // ---- new clean API -------------------------------------------------------
199
+
200
+ /** Show a success toast. */
201
+ success(message, opts = {}) { return this._toast(message, 'success', opts); }
202
+
203
+ /** Show an error toast. */
204
+ error(message, opts = {}) { return this._toast(message, 'error', opts); }
205
+
206
+ /** Show a warning toast. */
207
+ warning(message, opts = {}) { return this._toast(message, 'warning', opts); }
208
+
209
+ /** Show an info toast. */
210
+ info(message, opts = {}) { return this._toast(message, 'info', opts); }
211
+
212
+ /**
213
+ * Show a confirm dialog. Returns a Promise that resolves to true (confirm)
214
+ * or false (cancel / backdrop click). Also calls opts.onConfirm / opts.onCancel
215
+ * if provided.
216
+ *
217
+ * @param {string} message - The main question shown in the dialog body.
218
+ * @param {Object} [opts]
219
+ * @param {string} [opts.title] - Optional heading above the message.
220
+ * @param {string} [opts.confirmText] - Confirm button label (default 'Confirm').
221
+ * @param {string} [opts.cancelText] - Cancel button label (default 'Cancel').
222
+ * @param {boolean} [opts.danger] - Red confirm button (default false).
223
+ * @param {Function} [opts.onConfirm] - Called when the user confirms.
224
+ * @param {Function} [opts.onCancel] - Called when the user cancels.
225
+ * @returns {Promise<boolean>}
226
+ */
227
+ confirm(message, opts = {}) {
228
+ return new Promise((resolve) => {
229
+ this._ensureStyles();
230
+ this._showConfirm({
231
+ title: opts.title || '',
232
+ message,
233
+ confirmText: opts.confirmText || 'Confirm',
234
+ cancelText: opts.cancelText || 'Cancel',
235
+ danger: opts.danger || false,
236
+ onConfirm: opts.onConfirm,
237
+ onCancel: opts.onCancel,
238
+ resolve,
239
+ });
240
+ });
241
+ }
242
+
243
+ // ---- backward-compat interface (for ErrorHandler) -----------------------
244
+
245
+ /** @deprecated Use the typed methods (success, error, …) instead. */
246
+ showToast(message, type = 'info') {
247
+ this._toast(message, type);
248
+ }
249
+
250
+ /** @deprecated Use confirm() instead. */
251
+ showModal(title, message, options = {}) {
252
+ this._ensureStyles();
253
+ const buttons = options.buttons || [{ label: 'Close', action: 'close' }];
254
+ this._showLegacyModal(title, message, options.details || null, buttons);
255
+ }
256
+
257
+ // ---- private ------------------------------------------------------------
258
+
259
+ _toast(message, type, opts = {}) {
260
+ if (typeof document === 'undefined') return null;
261
+ this._ensureStyles();
262
+ this._ensureContainer();
263
+
264
+ // Enforce maxToasts by removing the oldest one first.
265
+ while (this._container.children.length >= this._maxToasts) {
266
+ this._container.firstElementChild.remove();
267
+ }
268
+
269
+ const toast = document.createElement('div');
270
+ toast.className = `vf-toast vf-toast-${type}`;
271
+ toast.innerHTML = [
272
+ `<span class="vf-toast-icon">${ICONS[type] || ''}</span>`,
273
+ `<span class="vf-toast-body">${escapeHtml(message)}</span>`,
274
+ `<button class="vf-toast-close" aria-label="Close">&times;</button>`,
275
+ ].join('');
276
+
277
+ let timer;
278
+ const dismiss = () => {
279
+ clearTimeout(timer);
280
+ if (toast.parentNode) toast.remove();
281
+ };
282
+
283
+ toast.querySelector('.vf-toast-close').addEventListener('click', dismiss);
284
+ timer = setTimeout(dismiss, opts.duration ?? this._duration);
285
+
286
+ this._container.appendChild(toast);
287
+ return toast;
288
+ }
289
+
290
+ _showConfirm({ title, message, confirmText, cancelText, danger, onConfirm, onCancel, resolve }) {
291
+ const overlay = document.createElement('div');
292
+ overlay.className = 'vf-dialog-overlay';
293
+ overlay.innerHTML = `
294
+ <div class="vf-dialog">
295
+ ${title ? `<h4 class="vf-dialog-title">${escapeHtml(title)}</h4>` : ''}
296
+ <p class="vf-dialog-message">${escapeHtml(message)}</p>
297
+ <div class="vf-dialog-actions">
298
+ <button class="vf-btn vf-btn-secondary vf-dialog-cancel">${escapeHtml(cancelText)}</button>
299
+ <button class="vf-btn ${danger ? 'vf-btn-danger' : 'vf-btn-primary'} vf-dialog-confirm">${escapeHtml(confirmText)}</button>
300
+ </div>
301
+ </div>
302
+ `;
303
+
304
+ const close = (result) => {
305
+ overlay.remove();
306
+ resolve(result);
307
+ if (result && onConfirm) onConfirm();
308
+ if (!result && onCancel) onCancel();
309
+ };
310
+
311
+ overlay.querySelector('.vf-dialog-cancel').addEventListener('click', () => close(false));
312
+ overlay.querySelector('.vf-dialog-confirm').addEventListener('click', () => close(true));
313
+ // Backdrop click cancels.
314
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
315
+
316
+ document.body.appendChild(overlay);
317
+ // Move focus into the dialog for keyboard accessibility.
318
+ overlay.querySelector('.vf-dialog-confirm').focus();
319
+ }
320
+
321
+ _showLegacyModal(title, message, details, buttons) {
322
+ const overlay = document.createElement('div');
323
+ overlay.className = 'vf-dialog-overlay';
324
+
325
+ const btnHtml = buttons.map((btn) => {
326
+ const cls = btn.action === 'close' ? 'vf-btn-secondary' : 'vf-btn-primary';
327
+ return `<button class="vf-btn ${cls}" data-action="${escapeHtml(String(btn.action))}">${escapeHtml(btn.label)}</button>`;
328
+ }).join('');
329
+
330
+ overlay.innerHTML = `
331
+ <div class="vf-dialog">
332
+ <h4 class="vf-dialog-title">${escapeHtml(title)}</h4>
333
+ <p class="vf-dialog-message">${escapeHtml(message)}</p>
334
+ ${details ? `
335
+ <details class="vf-dialog-details">
336
+ <summary>Technical details</summary>
337
+ <pre>${escapeHtml(details)}</pre>
338
+ </details>` : ''}
339
+ <div class="vf-dialog-actions">${btnHtml}</div>
340
+ </div>
341
+ `;
342
+
343
+ const close = () => overlay.remove();
344
+
345
+ buttons.forEach((btn) => {
346
+ const el = overlay.querySelector(`[data-action="${escapeHtml(String(btn.action))}"]`);
347
+ if (el) {
348
+ el.addEventListener('click', () => {
349
+ if (btn.action !== 'close' && btn.onClick) btn.onClick();
350
+ close();
351
+ });
352
+ }
353
+ });
354
+
355
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
356
+ document.body.appendChild(overlay);
357
+ }
358
+
359
+ _ensureContainer() {
360
+ // Re-query in case the body was replaced (e.g. navigation in tests).
361
+ if (!this._container || !document.body.contains(this._container)) {
362
+ this._container = document.getElementById('vf-alerts-toasts');
363
+ if (!this._container) {
364
+ this._container = document.createElement('div');
365
+ this._container.id = 'vf-alerts-toasts';
366
+ document.body.appendChild(this._container);
367
+ }
368
+ }
369
+ }
370
+
371
+ _ensureStyles() {
372
+ if (this._styleInjected || typeof document === 'undefined') return;
373
+ if (document.getElementById('vf-alerts-styles')) {
374
+ this._styleInjected = true;
375
+ return;
376
+ }
377
+ const style = document.createElement('style');
378
+ style.id = 'vf-alerts-styles';
379
+ style.textContent = ALERTS_STYLES;
380
+ document.head.appendChild(style);
381
+ this._styleInjected = true;
382
+ }
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Plugin export
387
+ // ---------------------------------------------------------------------------
388
+
389
+ /**
390
+ * Plugin object. Install with:
391
+ * app.use(alertsPlugin)
392
+ * app.use(alertsPlugin, { duration: 3000, maxToasts: 4 })
393
+ *
394
+ * Options:
395
+ * duration {number} - Toast auto-dismiss delay in ms (default 4000).
396
+ * maxToasts {number} - Maximum toasts shown at once (default 5).
397
+ * Oldest toast is removed when the limit is hit.
398
+ *
399
+ * Side effects on install:
400
+ * - Registers AlertsService under 'alerts' (app.get('alerts')).
401
+ * - Replaces app.errorHandler.notification so framework errors use the
402
+ * new styled toasts/dialogs automatically.
403
+ */
404
+ export const alertsPlugin = {
405
+ name: 'alerts',
406
+
407
+ install(app, options = {}) {
408
+ const service = new AlertsService(options);
409
+ app.provide('alerts', service);
410
+ // Wire ErrorHandler to the new service — it expects showToast() / showModal().
411
+ if (app.errorHandler) {
412
+ app.errorHandler.notification = service;
413
+ }
414
+ },
415
+ };
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Private helpers
419
+ // ---------------------------------------------------------------------------
420
+
421
+ function escapeHtml(str) {
422
+ return String(str)
423
+ .replace(/&/g, '&amp;')
424
+ .replace(/</g, '&lt;')
425
+ .replace(/>/g, '&gt;')
426
+ .replace(/"/g, '&quot;');
427
+ }