/** * @fileoverview Shared UI components: toast notifications, progress bars, * modals, and a dynamic form builder. */ (function() { 'use strict'; /** * Show a toast notification that auto-dismisses. * @param {string} message - Text to display. * @param {'success'|'error'} [type='success'] - Visual style. * @param {Object} [opts] - Optional config. * @param {{label: string, fn: function}} [opts.action] - Button added to toast. * @param {number} [opts.duration=3000] - Auto-dismiss delay in ms. */ function showToast(message, type, opts) { type = type || 'success'; opts = opts || {}; const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = 'toast ' + type; toast.textContent = message; if (opts.action) { const btn = document.createElement('button'); btn.className = 'toast-action'; btn.textContent = opts.action.label; btn.onclick = () => { toast.remove(); opts.action.fn(); }; toast.appendChild(btn); } container.appendChild(toast); setTimeout(() => toast.remove(), opts.duration || 3000); } /** * Create and append a progress bar to a container element. * @param {HTMLElement} container - Parent element. * @returns {{set: function(number), remove: function}} Controller. */ function showProgress(container) { const bar = document.createElement('div'); bar.className = 'progress-bar-container'; bar.innerHTML = '
'; container.appendChild(bar); return { set(pct) { bar.querySelector('.progress-bar').style.width = pct + '%'; }, remove() { bar.remove(); }, }; } /** Show the modal overlay. Content should be set before calling. */ function openModal() { document.getElementById('modal-overlay').style.display = 'flex'; } /** Hide the modal overlay. */ function closeModal() { document.getElementById('modal-overlay').style.display = 'none'; } /** * Build and show a dynamic form modal from a field specification. * * @param {Object} opts * @param {string} opts.title - Modal heading text. * @param {Array} opts.fields - Field definitions. Each field has: * `name`, `label`, `type` ('text'|'select'|'textarea'|'secret'), * `required`, `value`, `options` (for selects), `placeholder`, `description`. * @param {string} [opts.submitLabel='Save'] - Submit button text. * @param {function(Object): Promise} opts.onSubmit - Called with form data map. */ function openFormModal(opts) { const overlay = document.getElementById('modal-overlay'); const title = document.getElementById('modal-title'); const body = document.getElementById('modal-body'); title.textContent = opts.title || 'Form'; body.innerHTML = ''; const form = document.createElement('form'); form.className = 'modal-form'; (opts.fields || []).forEach(field => { form.appendChild(renderFormField(field)); }); const actions = document.createElement('div'); actions.className = 'form-actions'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.className = 'btn'; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = closeModal; actions.appendChild(cancelBtn); const submitBtn = document.createElement('button'); submitBtn.type = 'submit'; submitBtn.className = 'btn btn-primary'; submitBtn.textContent = opts.submitLabel || 'Save'; actions.appendChild(submitBtn); form.appendChild(actions); form.onsubmit = async (e) => { e.preventDefault(); submitBtn.disabled = true; const savedLabel = submitBtn.textContent; submitBtn.textContent = 'Saving...'; const formData = new FormData(form); const data = {}; for (const [k, v] of formData.entries()) { data[k] = v; } if (opts.onSubmit) { try { await opts.onSubmit(data); closeModal(); } catch (err) { submitBtn.disabled = false; submitBtn.textContent = savedLabel; showToast('Error: ' + BB.utils.getErrorMessage(err), 'error'); } } }; body.appendChild(form); overlay.style.display = 'flex'; // Focus first input const firstInput = form.querySelector('input, select, textarea'); if (firstInput) firstInput.focus(); } /** * Show an error toast with a Retry button. * @param {string} message - Error message. * @param {function} retryFn - Called when Retry is clicked. */ function showErrorWithRetry(message, retryFn) { showToast(message, 'error', { action: { label: 'Retry', fn: retryFn }, duration: 5000, }); } /** * Show a styled confirmation dialog. Returns a Promise that resolves to true/false. * @param {string} message - The question to display. * @param {Object} [opts] - Optional config. * @param {string} [opts.confirmLabel='Delete'] - Text for the confirm button. * @param {boolean} [opts.danger=true] - Whether the confirm button uses danger styling. * @returns {Promise} */ function confirmAction(message, opts) { opts = opts || {}; const confirmLabel = opts.confirmLabel || 'Delete'; const danger = opts.danger !== false; return new Promise((resolve) => { const overlay = document.getElementById('modal-overlay'); const title = document.getElementById('modal-title'); const body = document.getElementById('modal-body'); title.textContent = 'Confirm'; body.innerHTML = ''; const msg = document.createElement('p'); msg.className = 'confirm-message'; msg.textContent = message; body.appendChild(msg); const actions = document.createElement('div'); actions.className = 'form-actions'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.className = 'btn'; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = () => { overlay.style.display = 'none'; resolve(false); }; actions.appendChild(cancelBtn); const confirmBtn = document.createElement('button'); confirmBtn.type = 'button'; confirmBtn.className = danger ? 'btn btn-primary' : 'btn btn-success'; confirmBtn.textContent = confirmLabel; confirmBtn.onclick = () => { overlay.style.display = 'none'; resolve(true); }; actions.appendChild(confirmBtn); body.appendChild(actions); overlay.style.display = 'flex'; cancelBtn.focus(); }); } /** * Show a confirmation dialog with a title. Parity-named alias for * `confirmAction` that accepts a separate title. * @param {string} title - Modal heading. * @param {string} message - The question to display. * @param {Object} [opts] - Same as confirmAction. * @returns {Promise} */ function showConfirmDialog(title, message, opts) { opts = opts || {}; const confirmLabel = opts.confirmLabel || 'Delete'; const danger = opts.danger !== false; return new Promise((resolve) => { const overlay = document.getElementById('modal-overlay'); const titleEl = document.getElementById('modal-title'); const body = document.getElementById('modal-body'); titleEl.textContent = title; body.innerHTML = ''; const msg = document.createElement('p'); msg.className = 'confirm-message'; msg.textContent = message; body.appendChild(msg); const actions = document.createElement('div'); actions.className = 'form-actions'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.className = 'btn'; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = () => { overlay.style.display = 'none'; resolve(false); }; actions.appendChild(cancelBtn); const confirmBtn = document.createElement('button'); confirmBtn.type = 'button'; confirmBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary'; confirmBtn.textContent = confirmLabel; confirmBtn.onclick = () => { overlay.style.display = 'none'; resolve(true); }; actions.appendChild(confirmBtn); body.appendChild(actions); overlay.style.display = 'flex'; cancelBtn.focus(); }); } /** * Render the canonical empty-state block into a container. * @param {HTMLElement} container - Target element (cleared before render). * @param {string} message - Empty-state message. * @param {Object} [opts] - Optional config. * @param {string} [opts.icon] - Icon text/emoji shown above the message. * @param {string} [opts.buttonLabel] - If set with onClick, renders a primary CTA. * @param {function} [opts.onClick] - Click handler for the CTA button. * @param {boolean} [opts.compact=false] - Use the compact size variant. */ function renderEmptyState(container, message, opts) { opts = opts || {}; container.innerHTML = ''; const el = document.createElement('div'); el.className = 'empty-state' + (opts.compact ? ' empty-state--compact' : ''); if (opts.icon) { const icon = document.createElement('div'); icon.className = 'empty-state-icon'; icon.textContent = opts.icon; el.appendChild(icon); } const text = document.createElement('p'); text.className = 'empty-state-text'; text.textContent = message; el.appendChild(text); if (opts.buttonLabel && opts.onClick) { const btn = document.createElement('button'); btn.className = 'btn btn-primary'; btn.textContent = opts.buttonLabel; btn.onclick = opts.onClick; el.appendChild(btn); } container.appendChild(el); } /** * Show a context menu at the click position with a list of items. * Auto-dismisses on the next document click. Clamps to viewport. * @param {Event} event - Click event (used for position; stopPropagation is called). * @param {Array<{label: string, fn: function, danger?: boolean}>} items - Menu entries. */ function showContextMenu(event, items) { event.stopPropagation(); // Single global menu — remove any prior instance. const old = document.getElementById('bb-context-menu'); if (old) old.remove(); const menu = document.createElement('div'); menu.id = 'bb-context-menu'; menu.className = 'context-menu visible'; menu.style.left = event.clientX + 'px'; menu.style.top = event.clientY + 'px'; for (const item of items) { const btn = document.createElement('button'); btn.className = 'context-menu-item' + (item.danger ? ' context-menu-item--danger' : ''); btn.textContent = item.label; btn.onclick = () => { menu.remove(); item.fn(); }; menu.appendChild(btn); } document.body.appendChild(menu); // Clamp to viewport. const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px'; if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px'; requestAnimationFrame(() => { document.addEventListener('click', function dismiss() { menu.remove(); document.removeEventListener('click', dismiss); }, { once: true }); }); } /** * Show an undo toast that resolves the inverse action if the user * doesn't dismiss within the duration. * * Pair with the F3 "bulk operations always undoable" rule — every bulk * mutation should route through this helper with its inverse captured. * * @param {string} message - What just happened (e.g. "Deleted 3 bookmarks"). * @param {function} undoFn - Called if the user clicks Undo. * @param {Object} [opts] - Optional config. * @param {number} [opts.duration=5000] - Auto-dismiss in ms (also undo window). */ function showUndoToast(message, undoFn, opts) { opts = opts || {}; const duration = opts.duration || 5000; const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = 'toast toast-undo'; const msgEl = document.createElement('span'); msgEl.className = 'undo-message'; msgEl.textContent = message; toast.appendChild(msgEl); const btn = document.createElement('button'); btn.className = 'undo-btn'; btn.textContent = 'Undo'; toast.appendChild(btn); const countdown = document.createElement('span'); countdown.className = 'undo-countdown'; toast.appendChild(countdown); let remaining = Math.ceil(duration / 1000); countdown.textContent = remaining + 's'; const tick = setInterval(() => { remaining -= 1; countdown.textContent = Math.max(0, remaining) + 's'; }, 1000); const dismiss = () => { clearInterval(tick); toast.remove(); }; btn.onclick = () => { dismiss(); undoFn(); }; container.appendChild(toast); setTimeout(dismiss, duration); } /** * Render a row primitive with the canonical slot layout: * * [icon] [primary · badges] [meta] [actions] * [secondary ] * * Returns the constructed element; the caller appends it. Slots are * optional — omit and they don't render. Use this for any horizontal * list-item shape (item rows, source rows, bookmark rows, plugin items, * query-feed entries). Bespoke per-surface markup is the smell; per- * surface CSS classes on the outer element are fine. * * @param {Object} model * @param {string} [model.className] - Outer-element class (added to base). * @param {string} [model.tag='div'] - Outer element tag (e.g. 'li' for lists). * @param {HTMLElement|string} [model.icon] - Left slot (string is set as textContent). * @param {HTMLElement|string} [model.primary] - Required-ish top line. String = textContent. * @param {HTMLElement|string} [model.secondary] - Sub-line. * @param {HTMLElement|string} [model.meta] - Small right-aligned label (date, count). * @param {Array<{label: string, color?: string, filled?: boolean}>} [model.badges] * - Rendered as `.badge[data-color]` next to primary. * @param {Array} [model.actions] - Right-side buttons (hover-revealed via CSS). * @param {function} [model.onClick] - Click handler on the outer element. * @param {Object} [model.attrs] - Extra HTML attributes (data-*, aria-*). * @returns {HTMLElement} */ function renderRow(model) { const el = document.createElement(model.tag || 'div'); el.className = 'row' + (model.className ? ' ' + model.className : ''); if (model.attrs) { for (const [k, v] of Object.entries(model.attrs)) el.setAttribute(k, v); } const appendSlot = (slotClass, value) => { if (value == null) return null; const slot = document.createElement('div'); slot.className = slotClass; if (typeof value === 'string') slot.textContent = value; else slot.appendChild(value); el.appendChild(slot); return slot; }; if (model.icon != null) appendSlot('row-icon', model.icon); const content = document.createElement('div'); content.className = 'row-content'; const primaryLine = document.createElement('div'); primaryLine.className = 'row-primary'; if (typeof model.primary === 'string') primaryLine.textContent = model.primary; else if (model.primary) primaryLine.appendChild(model.primary); if (Array.isArray(model.badges) && model.badges.length > 0) { for (const b of model.badges) { const badge = document.createElement('span'); badge.className = 'badge' + (b.filled ? ' badge--filled' : ''); if (b.color) badge.setAttribute('data-color', b.color); badge.textContent = b.label; primaryLine.appendChild(badge); } } content.appendChild(primaryLine); if (model.secondary != null) { const sec = document.createElement('div'); sec.className = 'row-secondary'; if (typeof model.secondary === 'string') sec.textContent = model.secondary; else sec.appendChild(model.secondary); content.appendChild(sec); } el.appendChild(content); if (model.meta != null) appendSlot('row-meta', model.meta); if (Array.isArray(model.actions) && model.actions.length > 0) { const actions = document.createElement('div'); actions.className = 'row-actions'; for (const a of model.actions) actions.appendChild(a); el.appendChild(actions); } if (model.onClick) { el.onclick = model.onClick; el.style.cursor = 'pointer'; } return el; } /** * Build a single form field group (label + input + hint). * Used internally by openFormModal; also exposed so per-surface forms * (settings, sync, query-feed builder) build fields consistently. * * @param {Object} field * @param {string} field.name - Form field name + id. * @param {string} field.label - Visible label text. * @param {'text'|'select'|'textarea'|'secret'|'number'|'email'|'url'} [field.type='text'] * @param {boolean} [field.required=false] * @param {string} [field.value=''] - Initial value. * @param {Array} [field.options] - For type='select'. * @param {string} [field.placeholder] * @param {string} [field.description] - Help text shown as .form-hint. * @param {string} [field.error] - Error message shown as .form-hint with error color. * @returns {HTMLElement} The `.form-group` element. */ function renderFormField(field) { const group = document.createElement('div'); group.className = 'form-group'; const label = document.createElement('label'); label.className = 'form-label'; label.textContent = field.label + (field.required ? ' *' : ''); label.setAttribute('for', field.name); group.appendChild(label); // Pick the kind-specific input element so its visual treatment is // targetable via .form-select / .form-textarea (vs the generic // .form-input). 'secret' maps to password input. let input; if (field.type === 'select') { input = document.createElement('select'); input.className = 'form-select'; (field.options || []).forEach(opt => { const option = document.createElement('option'); option.value = typeof opt === 'object' ? opt.value : opt; option.textContent = typeof opt === 'object' ? opt.label : opt; if (field.value && option.value === field.value) option.selected = true; input.appendChild(option); }); } else if (field.type === 'textarea') { input = document.createElement('textarea'); input.className = 'form-textarea'; input.value = field.value || ''; input.rows = 4; } else { input = document.createElement('input'); input.className = 'form-input'; input.type = field.type === 'secret' ? 'password' : (field.type || 'text'); input.value = field.value || ''; } input.name = field.name; input.id = field.name; if (field.required) input.required = true; if (field.placeholder) input.placeholder = field.placeholder; group.appendChild(input); if (field.description) { const hint = document.createElement('div'); hint.className = 'form-hint'; hint.textContent = field.description; group.appendChild(hint); } if (field.error) { const err = document.createElement('div'); err.className = 'form-hint form-hint--error'; err.textContent = field.error; group.appendChild(err); } return group; } /** * Render N skeleton rows into a container (for first-paint loading state). * Replaces ad-hoc `.skeleton-item` blocks in items / sources / bookmarks * surfaces. * * @param {HTMLElement} container - Target (cleared before render). * @param {Object} [opts] * @param {number} [opts.rows=6] - Number of skeleton rows. * @param {boolean} [opts.indicators=true] - Whether to render the left indicator block. */ function renderSkeleton(container, opts) { opts = opts || {}; const rows = opts.rows || 6; const indicators = opts.indicators !== false; container.innerHTML = ''; for (let i = 0; i < rows; i++) { const row = document.createElement('div'); row.className = 'skeleton-item'; if (indicators) { const ind = document.createElement('div'); ind.className = 'skeleton-indicators'; row.appendChild(ind); } const content = document.createElement('div'); content.className = 'skeleton-content'; const short = document.createElement('div'); short.className = 'skeleton-line short'; const long = document.createElement('div'); long.className = 'skeleton-line long'; content.appendChild(short); content.appendChild(long); row.appendChild(content); container.appendChild(row); } } /** * Run a bulk mutation and surface the undo affordance. Enforces the * F3 rule that every bulk operation must be reversible from the toast. * * @param {Object} opts * @param {string} opts.label - Toast text (e.g. "Deleted 3 bookmarks"). * @param {function} opts.doAction - The forward action; awaited. * @param {function} opts.undoAction - The inverse action; called on undo. * @param {number} [opts.duration=5000] - Undo window in ms. * @returns {Promise} */ async function bulkActionWithUndo(opts) { await opts.doAction(); showUndoToast(opts.label, async () => { try { await opts.undoAction(); showToast('Undone'); } catch (e) { showToast('Undo failed: ' + (e.message || e), 'error'); } }, { duration: opts.duration }); } /** * Show a "Step N of M" indicator in the modal header. Apply to any * flow with more than two sequential modal steps (OAuth, plugin * import wizards, encryption setup). Call with (null) to clear. * * @param {number|null} step - Current step number (1-indexed), or null to hide. * @param {number} [of] - Total step count. */ function setModalStep(step, of) { const header = document.querySelector('#modal-overlay .modal-header'); if (!header) return; let indicator = header.querySelector('.modal-step-indicator'); if (step == null) { if (indicator) indicator.remove(); return; } if (!indicator) { indicator = document.createElement('span'); indicator.className = 'modal-step-indicator'; // Place after the title (which is currently

with id="modal-title"). const title = header.querySelector('#modal-title'); if (title && title.nextSibling) header.insertBefore(indicator, title.nextSibling); else header.appendChild(indicator); } indicator.textContent = 'Step ' + step + ' of ' + of; } BB.ui = { showToast, showProgress, openModal, closeModal, openFormModal, showErrorWithRetry, confirmAction, // legacy — prefer showConfirmDialog for new code showConfirmDialog, renderEmptyState, showContextMenu, showUndoToast, bulkActionWithUndo, setModalStep, renderRow, renderFormField, renderSkeleton, }; })();