Skip to main content

max / balanced_breakfast

25.2 KB · 646 lines History Blame Raw
1 /**
2 * @fileoverview Shared UI components: toast notifications, progress bars,
3 * modals, and a dynamic form builder.
4 */
5 (function() {
6 'use strict';
7
8 /**
9 * Show a toast notification that auto-dismisses.
10 * @param {string} message - Text to display.
11 * @param {'success'|'error'} [type='success'] - Visual style.
12 * @param {Object} [opts] - Optional config.
13 * @param {{label: string, fn: function}} [opts.action] - Button added to toast.
14 * @param {number} [opts.duration=3000] - Auto-dismiss delay in ms.
15 */
16 function showToast(message, type, opts) {
17 type = type || 'success';
18 opts = opts || {};
19 const container = document.getElementById('toast-container');
20 const toast = document.createElement('div');
21 toast.className = 'toast ' + type;
22 toast.textContent = message;
23
24 if (opts.action) {
25 const btn = document.createElement('button');
26 btn.className = 'toast-action';
27 btn.textContent = opts.action.label;
28 btn.onclick = () => { toast.remove(); opts.action.fn(); };
29 toast.appendChild(btn);
30 }
31
32 container.appendChild(toast);
33 setTimeout(() => toast.remove(), opts.duration || 3000);
34 }
35
36 /**
37 * Create and append a progress bar to a container element.
38 * @param {HTMLElement} container - Parent element.
39 * @returns {{set: function(number), remove: function}} Controller.
40 */
41 function showProgress(container) {
42 const bar = document.createElement('div');
43 bar.className = 'progress-bar-container';
44 bar.innerHTML = '<div class="progress-bar" style="width: 0%"></div>';
45 container.appendChild(bar);
46 return {
47 set(pct) { bar.querySelector('.progress-bar').style.width = pct + '%'; },
48 remove() { bar.remove(); },
49 };
50 }
51
52 /** Show the modal overlay. Content should be set before calling. */
53 function openModal() {
54 document.getElementById('modal-overlay').style.display = 'flex';
55 }
56
57 /** Hide the modal overlay. */
58 function closeModal() {
59 document.getElementById('modal-overlay').style.display = 'none';
60 }
61
62 /**
63 * Build and show a dynamic form modal from a field specification.
64 *
65 * @param {Object} opts
66 * @param {string} opts.title - Modal heading text.
67 * @param {Array<Object>} opts.fields - Field definitions. Each field has:
68 * `name`, `label`, `type` ('text'|'select'|'textarea'|'secret'),
69 * `required`, `value`, `options` (for selects), `placeholder`, `description`.
70 * @param {string} [opts.submitLabel='Save'] - Submit button text.
71 * @param {function(Object): Promise} opts.onSubmit - Called with form data map.
72 */
73 function openFormModal(opts) {
74 const overlay = document.getElementById('modal-overlay');
75 const title = document.getElementById('modal-title');
76 const body = document.getElementById('modal-body');
77
78 title.textContent = opts.title || 'Form';
79 body.innerHTML = '';
80
81 const form = document.createElement('form');
82 form.className = 'modal-form';
83
84 (opts.fields || []).forEach(field => {
85 form.appendChild(renderFormField(field));
86 });
87
88 const actions = document.createElement('div');
89 actions.className = 'form-actions';
90
91 const cancelBtn = document.createElement('button');
92 cancelBtn.type = 'button';
93 cancelBtn.className = 'btn';
94 cancelBtn.textContent = 'Cancel';
95 cancelBtn.onclick = closeModal;
96 actions.appendChild(cancelBtn);
97
98 const submitBtn = document.createElement('button');
99 submitBtn.type = 'submit';
100 submitBtn.className = 'btn btn-primary';
101 submitBtn.textContent = opts.submitLabel || 'Save';
102 actions.appendChild(submitBtn);
103
104 form.appendChild(actions);
105
106 form.onsubmit = async (e) => {
107 e.preventDefault();
108 submitBtn.disabled = true;
109 const savedLabel = submitBtn.textContent;
110 submitBtn.textContent = 'Saving...';
111 const formData = new FormData(form);
112 const data = {};
113 for (const [k, v] of formData.entries()) {
114 data[k] = v;
115 }
116 if (opts.onSubmit) {
117 try {
118 await opts.onSubmit(data);
119 closeModal();
120 } catch (err) {
121 submitBtn.disabled = false;
122 submitBtn.textContent = savedLabel;
123 showToast('Error: ' + BB.utils.getErrorMessage(err), 'error');
124 }
125 }
126 };
127
128 body.appendChild(form);
129 overlay.style.display = 'flex';
130
131 // Focus first input
132 const firstInput = form.querySelector('input, select, textarea');
133 if (firstInput) firstInput.focus();
134 }
135
136 /**
137 * Show an error toast with a Retry button.
138 * @param {string} message - Error message.
139 * @param {function} retryFn - Called when Retry is clicked.
140 */
141 function showErrorWithRetry(message, retryFn) {
142 showToast(message, 'error', {
143 action: { label: 'Retry', fn: retryFn },
144 duration: 5000,
145 });
146 }
147
148 /**
149 * Show a styled confirmation dialog. Returns a Promise that resolves to true/false.
150 * @param {string} message - The question to display.
151 * @param {Object} [opts] - Optional config.
152 * @param {string} [opts.confirmLabel='Delete'] - Text for the confirm button.
153 * @param {boolean} [opts.danger=true] - Whether the confirm button uses danger styling.
154 * @returns {Promise<boolean>}
155 */
156 function confirmAction(message, opts) {
157 opts = opts || {};
158 const confirmLabel = opts.confirmLabel || 'Delete';
159 const danger = opts.danger !== false;
160
161 return new Promise((resolve) => {
162 const overlay = document.getElementById('modal-overlay');
163 const title = document.getElementById('modal-title');
164 const body = document.getElementById('modal-body');
165
166 title.textContent = 'Confirm';
167 body.innerHTML = '';
168
169 const msg = document.createElement('p');
170 msg.className = 'confirm-message';
171 msg.textContent = message;
172 body.appendChild(msg);
173
174 const actions = document.createElement('div');
175 actions.className = 'form-actions';
176
177 const cancelBtn = document.createElement('button');
178 cancelBtn.type = 'button';
179 cancelBtn.className = 'btn';
180 cancelBtn.textContent = 'Cancel';
181 cancelBtn.onclick = () => { overlay.style.display = 'none'; resolve(false); };
182 actions.appendChild(cancelBtn);
183
184 const confirmBtn = document.createElement('button');
185 confirmBtn.type = 'button';
186 confirmBtn.className = danger ? 'btn btn-primary' : 'btn btn-success';
187 confirmBtn.textContent = confirmLabel;
188 confirmBtn.onclick = () => { overlay.style.display = 'none'; resolve(true); };
189 actions.appendChild(confirmBtn);
190
191 body.appendChild(actions);
192 overlay.style.display = 'flex';
193 cancelBtn.focus();
194 });
195 }
196
197 /**
198 * Show a confirmation dialog with a title. Parity-named alias for
199 * `confirmAction` that accepts a separate title.
200 * @param {string} title - Modal heading.
201 * @param {string} message - The question to display.
202 * @param {Object} [opts] - Same as confirmAction.
203 * @returns {Promise<boolean>}
204 */
205 function showConfirmDialog(title, message, opts) {
206 opts = opts || {};
207 const confirmLabel = opts.confirmLabel || 'Delete';
208 const danger = opts.danger !== false;
209
210 return new Promise((resolve) => {
211 const overlay = document.getElementById('modal-overlay');
212 const titleEl = document.getElementById('modal-title');
213 const body = document.getElementById('modal-body');
214
215 titleEl.textContent = title;
216 body.innerHTML = '';
217
218 const msg = document.createElement('p');
219 msg.className = 'confirm-message';
220 msg.textContent = message;
221 body.appendChild(msg);
222
223 const actions = document.createElement('div');
224 actions.className = 'form-actions';
225
226 const cancelBtn = document.createElement('button');
227 cancelBtn.type = 'button';
228 cancelBtn.className = 'btn';
229 cancelBtn.textContent = 'Cancel';
230 cancelBtn.onclick = () => { overlay.style.display = 'none'; resolve(false); };
231 actions.appendChild(cancelBtn);
232
233 const confirmBtn = document.createElement('button');
234 confirmBtn.type = 'button';
235 confirmBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
236 confirmBtn.textContent = confirmLabel;
237 confirmBtn.onclick = () => { overlay.style.display = 'none'; resolve(true); };
238 actions.appendChild(confirmBtn);
239
240 body.appendChild(actions);
241 overlay.style.display = 'flex';
242 cancelBtn.focus();
243 });
244 }
245
246 /**
247 * Render the canonical empty-state block into a container.
248 * @param {HTMLElement} container - Target element (cleared before render).
249 * @param {string} message - Empty-state message.
250 * @param {Object} [opts] - Optional config.
251 * @param {string} [opts.icon] - Icon text/emoji shown above the message.
252 * @param {string} [opts.buttonLabel] - If set with onClick, renders a primary CTA.
253 * @param {function} [opts.onClick] - Click handler for the CTA button.
254 * @param {boolean} [opts.compact=false] - Use the compact size variant.
255 */
256 function renderEmptyState(container, message, opts) {
257 opts = opts || {};
258 container.innerHTML = '';
259 const el = document.createElement('div');
260 el.className = 'empty-state' + (opts.compact ? ' empty-state--compact' : '');
261
262 if (opts.icon) {
263 const icon = document.createElement('div');
264 icon.className = 'empty-state-icon';
265 icon.textContent = opts.icon;
266 el.appendChild(icon);
267 }
268
269 const text = document.createElement('p');
270 text.className = 'empty-state-text';
271 text.textContent = message;
272 el.appendChild(text);
273
274 if (opts.buttonLabel && opts.onClick) {
275 const btn = document.createElement('button');
276 btn.className = 'btn btn-primary';
277 btn.textContent = opts.buttonLabel;
278 btn.onclick = opts.onClick;
279 el.appendChild(btn);
280 }
281
282 container.appendChild(el);
283 }
284
285 /**
286 * Show a context menu at the click position with a list of items.
287 * Auto-dismisses on the next document click. Clamps to viewport.
288 * @param {Event} event - Click event (used for position; stopPropagation is called).
289 * @param {Array<{label: string, fn: function, danger?: boolean}>} items - Menu entries.
290 */
291 function showContextMenu(event, items) {
292 event.stopPropagation();
293
294 // Single global menu — remove any prior instance.
295 const old = document.getElementById('bb-context-menu');
296 if (old) old.remove();
297
298 const menu = document.createElement('div');
299 menu.id = 'bb-context-menu';
300 menu.className = 'context-menu visible';
301 menu.style.left = event.clientX + 'px';
302 menu.style.top = event.clientY + 'px';
303
304 for (const item of items) {
305 const btn = document.createElement('button');
306 btn.className = 'context-menu-item' + (item.danger ? ' context-menu-item--danger' : '');
307 btn.textContent = item.label;
308 btn.onclick = () => { menu.remove(); item.fn(); };
309 menu.appendChild(btn);
310 }
311
312 document.body.appendChild(menu);
313
314 // Clamp to viewport.
315 const rect = menu.getBoundingClientRect();
316 if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px';
317 if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px';
318
319 requestAnimationFrame(() => {
320 document.addEventListener('click', function dismiss() {
321 menu.remove();
322 document.removeEventListener('click', dismiss);
323 }, { once: true });
324 });
325 }
326
327 /**
328 * Show an undo toast that resolves the inverse action if the user
329 * doesn't dismiss within the duration.
330 *
331 * Pair with the F3 "bulk operations always undoable" rule — every bulk
332 * mutation should route through this helper with its inverse captured.
333 *
334 * @param {string} message - What just happened (e.g. "Deleted 3 bookmarks").
335 * @param {function} undoFn - Called if the user clicks Undo.
336 * @param {Object} [opts] - Optional config.
337 * @param {number} [opts.duration=5000] - Auto-dismiss in ms (also undo window).
338 */
339 function showUndoToast(message, undoFn, opts) {
340 opts = opts || {};
341 const duration = opts.duration || 5000;
342 const container = document.getElementById('toast-container');
343 const toast = document.createElement('div');
344 toast.className = 'toast toast-undo';
345
346 const msgEl = document.createElement('span');
347 msgEl.className = 'undo-message';
348 msgEl.textContent = message;
349 toast.appendChild(msgEl);
350
351 const btn = document.createElement('button');
352 btn.className = 'undo-btn';
353 btn.textContent = 'Undo';
354 toast.appendChild(btn);
355
356 const countdown = document.createElement('span');
357 countdown.className = 'undo-countdown';
358 toast.appendChild(countdown);
359
360 let remaining = Math.ceil(duration / 1000);
361 countdown.textContent = remaining + 's';
362 const tick = setInterval(() => {
363 remaining -= 1;
364 countdown.textContent = Math.max(0, remaining) + 's';
365 }, 1000);
366
367 const dismiss = () => { clearInterval(tick); toast.remove(); };
368 btn.onclick = () => { dismiss(); undoFn(); };
369
370 container.appendChild(toast);
371 setTimeout(dismiss, duration);
372 }
373
374 /**
375 * Render a row primitive with the canonical slot layout:
376 *
377 * [icon] [primary · badges] [meta] [actions]
378 * [secondary ]
379 *
380 * Returns the constructed element; the caller appends it. Slots are
381 * optional — omit and they don't render. Use this for any horizontal
382 * list-item shape (item rows, source rows, bookmark rows, plugin items,
383 * query-feed entries). Bespoke per-surface markup is the smell; per-
384 * surface CSS classes on the outer element are fine.
385 *
386 * @param {Object} model
387 * @param {string} [model.className] - Outer-element class (added to base).
388 * @param {string} [model.tag='div'] - Outer element tag (e.g. 'li' for lists).
389 * @param {HTMLElement|string} [model.icon] - Left slot (string is set as textContent).
390 * @param {HTMLElement|string} [model.primary] - Required-ish top line. String = textContent.
391 * @param {HTMLElement|string} [model.secondary] - Sub-line.
392 * @param {HTMLElement|string} [model.meta] - Small right-aligned label (date, count).
393 * @param {Array<{label: string, color?: string, filled?: boolean}>} [model.badges]
394 * - Rendered as `.badge[data-color]` next to primary.
395 * @param {Array<HTMLElement>} [model.actions] - Right-side buttons (hover-revealed via CSS).
396 * @param {function} [model.onClick] - Click handler on the outer element.
397 * @param {Object<string,string>} [model.attrs] - Extra HTML attributes (data-*, aria-*).
398 * @returns {HTMLElement}
399 */
400 function renderRow(model) {
401 const el = document.createElement(model.tag || 'div');
402 el.className = 'row' + (model.className ? ' ' + model.className : '');
403
404 if (model.attrs) {
405 for (const [k, v] of Object.entries(model.attrs)) el.setAttribute(k, v);
406 }
407
408 const appendSlot = (slotClass, value) => {
409 if (value == null) return null;
410 const slot = document.createElement('div');
411 slot.className = slotClass;
412 if (typeof value === 'string') slot.textContent = value;
413 else slot.appendChild(value);
414 el.appendChild(slot);
415 return slot;
416 };
417
418 if (model.icon != null) appendSlot('row-icon', model.icon);
419
420 const content = document.createElement('div');
421 content.className = 'row-content';
422
423 const primaryLine = document.createElement('div');
424 primaryLine.className = 'row-primary';
425 if (typeof model.primary === 'string') primaryLine.textContent = model.primary;
426 else if (model.primary) primaryLine.appendChild(model.primary);
427
428 if (Array.isArray(model.badges) && model.badges.length > 0) {
429 for (const b of model.badges) {
430 const badge = document.createElement('span');
431 badge.className = 'badge' + (b.filled ? ' badge--filled' : '');
432 if (b.color) badge.setAttribute('data-color', b.color);
433 badge.textContent = b.label;
434 primaryLine.appendChild(badge);
435 }
436 }
437 content.appendChild(primaryLine);
438
439 if (model.secondary != null) {
440 const sec = document.createElement('div');
441 sec.className = 'row-secondary';
442 if (typeof model.secondary === 'string') sec.textContent = model.secondary;
443 else sec.appendChild(model.secondary);
444 content.appendChild(sec);
445 }
446 el.appendChild(content);
447
448 if (model.meta != null) appendSlot('row-meta', model.meta);
449
450 if (Array.isArray(model.actions) && model.actions.length > 0) {
451 const actions = document.createElement('div');
452 actions.className = 'row-actions';
453 for (const a of model.actions) actions.appendChild(a);
454 el.appendChild(actions);
455 }
456
457 if (model.onClick) {
458 el.onclick = model.onClick;
459 el.style.cursor = 'pointer';
460 }
461
462 return el;
463 }
464
465 /**
466 * Build a single form field group (label + input + hint).
467 * Used internally by openFormModal; also exposed so per-surface forms
468 * (settings, sync, query-feed builder) build fields consistently.
469 *
470 * @param {Object} field
471 * @param {string} field.name - Form field name + id.
472 * @param {string} field.label - Visible label text.
473 * @param {'text'|'select'|'textarea'|'secret'|'number'|'email'|'url'} [field.type='text']
474 * @param {boolean} [field.required=false]
475 * @param {string} [field.value=''] - Initial value.
476 * @param {Array<string|{value, label}>} [field.options] - For type='select'.
477 * @param {string} [field.placeholder]
478 * @param {string} [field.description] - Help text shown as .form-hint.
479 * @param {string} [field.error] - Error message shown as .form-hint with error color.
480 * @returns {HTMLElement} The `.form-group` element.
481 */
482 function renderFormField(field) {
483 const group = document.createElement('div');
484 group.className = 'form-group';
485
486 const label = document.createElement('label');
487 label.className = 'form-label';
488 label.textContent = field.label + (field.required ? ' *' : '');
489 label.setAttribute('for', field.name);
490 group.appendChild(label);
491
492 // Pick the kind-specific input element so its visual treatment is
493 // targetable via .form-select / .form-textarea (vs the generic
494 // .form-input). 'secret' maps to password input.
495 let input;
496 if (field.type === 'select') {
497 input = document.createElement('select');
498 input.className = 'form-select';
499 (field.options || []).forEach(opt => {
500 const option = document.createElement('option');
501 option.value = typeof opt === 'object' ? opt.value : opt;
502 option.textContent = typeof opt === 'object' ? opt.label : opt;
503 if (field.value && option.value === field.value) option.selected = true;
504 input.appendChild(option);
505 });
506 } else if (field.type === 'textarea') {
507 input = document.createElement('textarea');
508 input.className = 'form-textarea';
509 input.value = field.value || '';
510 input.rows = 4;
511 } else {
512 input = document.createElement('input');
513 input.className = 'form-input';
514 input.type = field.type === 'secret' ? 'password' : (field.type || 'text');
515 input.value = field.value || '';
516 }
517
518 input.name = field.name;
519 input.id = field.name;
520 if (field.required) input.required = true;
521 if (field.placeholder) input.placeholder = field.placeholder;
522 group.appendChild(input);
523
524 if (field.description) {
525 const hint = document.createElement('div');
526 hint.className = 'form-hint';
527 hint.textContent = field.description;
528 group.appendChild(hint);
529 }
530
531 if (field.error) {
532 const err = document.createElement('div');
533 err.className = 'form-hint form-hint--error';
534 err.textContent = field.error;
535 group.appendChild(err);
536 }
537
538 return group;
539 }
540
541 /**
542 * Render N skeleton rows into a container (for first-paint loading state).
543 * Replaces ad-hoc `.skeleton-item` blocks in items / sources / bookmarks
544 * surfaces.
545 *
546 * @param {HTMLElement} container - Target (cleared before render).
547 * @param {Object} [opts]
548 * @param {number} [opts.rows=6] - Number of skeleton rows.
549 * @param {boolean} [opts.indicators=true] - Whether to render the left indicator block.
550 */
551 function renderSkeleton(container, opts) {
552 opts = opts || {};
553 const rows = opts.rows || 6;
554 const indicators = opts.indicators !== false;
555 container.innerHTML = '';
556 for (let i = 0; i < rows; i++) {
557 const row = document.createElement('div');
558 row.className = 'skeleton-item';
559 if (indicators) {
560 const ind = document.createElement('div');
561 ind.className = 'skeleton-indicators';
562 row.appendChild(ind);
563 }
564 const content = document.createElement('div');
565 content.className = 'skeleton-content';
566 const short = document.createElement('div');
567 short.className = 'skeleton-line short';
568 const long = document.createElement('div');
569 long.className = 'skeleton-line long';
570 content.appendChild(short);
571 content.appendChild(long);
572 row.appendChild(content);
573 container.appendChild(row);
574 }
575 }
576
577 /**
578 * Run a bulk mutation and surface the undo affordance. Enforces the
579 * F3 rule that every bulk operation must be reversible from the toast.
580 *
581 * @param {Object} opts
582 * @param {string} opts.label - Toast text (e.g. "Deleted 3 bookmarks").
583 * @param {function} opts.doAction - The forward action; awaited.
584 * @param {function} opts.undoAction - The inverse action; called on undo.
585 * @param {number} [opts.duration=5000] - Undo window in ms.
586 * @returns {Promise<void>}
587 */
588 async function bulkActionWithUndo(opts) {
589 await opts.doAction();
590 showUndoToast(opts.label, async () => {
591 try {
592 await opts.undoAction();
593 showToast('Undone');
594 } catch (e) {
595 showToast('Undo failed: ' + (e.message || e), 'error');
596 }
597 }, { duration: opts.duration });
598 }
599
600 /**
601 * Show a "Step N of M" indicator in the modal header. Apply to any
602 * flow with more than two sequential modal steps (OAuth, plugin
603 * import wizards, encryption setup). Call with (null) to clear.
604 *
605 * @param {number|null} step - Current step number (1-indexed), or null to hide.
606 * @param {number} [of] - Total step count.
607 */
608 function setModalStep(step, of) {
609 const header = document.querySelector('#modal-overlay .modal-header');
610 if (!header) return;
611 let indicator = header.querySelector('.modal-step-indicator');
612 if (step == null) {
613 if (indicator) indicator.remove();
614 return;
615 }
616 if (!indicator) {
617 indicator = document.createElement('span');
618 indicator.className = 'modal-step-indicator';
619 // Place after the title (which is currently <h2> with id="modal-title").
620 const title = header.querySelector('#modal-title');
621 if (title && title.nextSibling) header.insertBefore(indicator, title.nextSibling);
622 else header.appendChild(indicator);
623 }
624 indicator.textContent = 'Step ' + step + ' of ' + of;
625 }
626
627 BB.ui = {
628 showToast,
629 showProgress,
630 openModal,
631 closeModal,
632 openFormModal,
633 showErrorWithRetry,
634 confirmAction, // legacy — prefer showConfirmDialog for new code
635 showConfirmDialog,
636 renderEmptyState,
637 showContextMenu,
638 showUndoToast,
639 bulkActionWithUndo,
640 setModalStep,
641 renderRow,
642 renderFormField,
643 renderSkeleton,
644 };
645 })();
646