/**
* @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,
};
})();