/** * Lightweight UI helpers (design-system primitives without a framework). */ export function el(tag, attrs = {}, children = []) { const node = document.createElement(tag) for (const [k, v] of Object.entries(attrs || {})) { if (k === 'className') node.className = v else if (k === 'text') node.textContent = v else if (k === 'html') node.innerHTML = v else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v) else if (v != null) node.setAttribute(k, String(v)) } for (const c of [].concat(children)) { if (c == null) continue node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c) } return node } export function statusBadge(state) { const s = String(state || 'unknown').toLowerCase() const map = { running: 'success', healthy: 'success', exited: 'secondary', dead: 'danger', paused: 'warning', created: 'info', restarting: 'warning', unhealthy: 'danger', offline: 'danger', online: 'success', } const tone = map[s] || 'secondary' return `${escapeHtml(state || 'unknown')}` } export function emptyState({ icon = 'fa-inbox', title, body, ctaLabel, onCta } = {}) { const wrap = el('div', { className: 'pd-empty' }) wrap.innerHTML = `

${escapeHtml(title || 'Nothing here yet')}

${escapeHtml(body || '')}

` if (ctaLabel && onCta) { const btn = el('button', { className: 'btn btn-primary', type: 'button', text: ctaLabel }) btn.addEventListener('click', onCta) wrap.querySelector('.pd-empty-actions').appendChild(btn) } return wrap } export function skeletonRows(n = 5) { const wrap = el('div', { className: 'pd-skeleton-list' }) for (let i = 0; i < n; i++) { wrap.appendChild(el('div', { className: 'pd-skeleton-row' })) } return wrap } export function suggestedMark(source = 'Suggested') { return `${escapeHtml(source)}` } /** * Confirm / info dialog (designed modal — never native window.confirm). * @param {{ * title: string, * body: string, * confirmLabel?: string, * cancelLabel?: string, * danger?: boolean, * info?: boolean, * icon?: string, * requireText?: string, * hideCancel?: boolean, * }} opts * @returns {Promise} */ export function confirmDialog(opts) { return new Promise((resolve) => { const previouslyFocused = document.activeElement const danger = Boolean(opts.danger) const info = Boolean(opts.info) && !danger const icon = opts.icon || (danger ? 'fa-triangle-exclamation' : info ? 'fa-circle-info' : 'fa-circle-question') const tone = danger ? 'danger' : info ? 'info' : 'primary' const backdrop = el('div', { className: 'pd-modal-backdrop', role: 'presentation', }) const modal = el('div', { className: `pd-modal pd-modal--${tone}`, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'pd-confirm-title', }) modal.innerHTML = `

${escapeHtml(opts.title)}

${escapeHtml(opts.body)}

${ opts.requireText ? `
` : '' }
${ opts.hideCancel ? '' : `` }
` backdrop.appendChild(modal) document.body.appendChild(backdrop) // enter animation requestAnimationFrame(() => backdrop.classList.add('pd-modal-backdrop--visible')) const input = modal.querySelector('.pd-confirm-input') const ok = modal.querySelector('.pd-ok') const cancel = modal.querySelector('.pd-cancel') const close = (val) => { backdrop.classList.remove('pd-modal-backdrop--visible') backdrop.classList.add('pd-modal-backdrop--leaving') setTimeout(() => { backdrop.remove() document.removeEventListener('keydown', onKey) if (previouslyFocused && typeof previouslyFocused.focus === 'function') { try { previouslyFocused.focus() } catch { // ignore } } resolve(val) }, 160) } const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault() close(false) } if (e.key === 'Enter' && document.activeElement !== input) { e.preventDefault() ok?.click() } } document.addEventListener('keydown', onKey) if (cancel) cancel.onclick = () => close(false) ok.onclick = () => { if (opts.requireText && input && input.value !== opts.requireText) { input.classList.add('is-invalid') input.focus() return } close(true) } backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false) }) setTimeout(() => (input || ok)?.focus?.(), 30) }) } /** * Non-destructive confirm (e.g. open tunnel, clear log). * @param {string} title * @param {string} body * @param {{ confirmLabel?: string, info?: boolean, icon?: string }} [opts] */ export async function askConfirm(title, body, opts = {}) { return confirmDialog({ title, body, confirmLabel: opts.confirmLabel || 'Continue', info: opts.info !== false && !opts.danger, danger: opts.danger, icon: opts.icon, }) } export function fieldWithSuggestion(inputEl, source) { if (!inputEl || !inputEl.parentElement) return inputEl.dataset.suggested = '1' inputEl.dataset.suggestionSource = source || 'host' let badge = inputEl.parentElement.querySelector('.pd-suggested') if (!badge) { badge = document.createElement('span') badge.className = 'pd-suggested' inputEl.parentElement.appendChild(badge) } badge.textContent = source || 'Suggested' const clear = () => { delete inputEl.dataset.suggested badge.remove() inputEl.removeEventListener('input', clear) } inputEl.addEventListener('input', clear) } export function escapeHtml(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } /** Simple command palette with arrow-key navigation */ export function openCommandPalette(items, onPick) { const previouslyFocused = document.activeElement const backdrop = el('div', { className: 'pd-modal-backdrop pd-palette-backdrop' }) const box = el('div', { className: 'pd-palette', role: 'dialog', 'aria-label': 'Command palette' }) box.innerHTML = `
` backdrop.appendChild(box) document.body.appendChild(backdrop) const input = box.querySelector('.pd-palette-input') const list = box.querySelector('.pd-palette-list') /** @type {object[]} */ let filtered = [] let active = 0 const close = () => { backdrop.remove() if (previouslyFocused && typeof previouslyFocused.focus === 'function') { try { previouslyFocused.focus() } catch { // ignore } } } const pick = (item) => { if (!item) return close() onPick(item) } const setActive = (idx) => { const buttons = [...list.querySelectorAll('.pd-palette-item')] if (!buttons.length) { active = 0 return } active = Math.max(0, Math.min(buttons.length - 1, idx)) buttons.forEach((btn, i) => { btn.classList.toggle('is-active', i === active) btn.setAttribute('aria-selected', i === active ? 'true' : 'false') }) buttons[active]?.scrollIntoView?.({ block: 'nearest' }) } const render = () => { const q = input.value.toLowerCase().trim() filtered = items.filter( (i) => !q || i.label.toLowerCase().includes(q) || (i.keywords || '').includes(q) ) list.innerHTML = filtered .slice(0, 40) .map( (i, idx) => `` ) .join('') active = 0 list.querySelectorAll('.pd-palette-item').forEach((btn) => { btn.onclick = () => pick(filtered[Number(btn.dataset.idx)]) btn.onmouseenter = () => setActive(Number(btn.dataset.idx)) }) } input.addEventListener('input', render) input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault() close() return } if (e.key === 'ArrowDown') { e.preventDefault() setActive(active + 1) return } if (e.key === 'ArrowUp') { e.preventDefault() setActive(active - 1) return } if (e.key === 'Enter') { e.preventDefault() pick(filtered[active] || filtered[0]) } }) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close() }) render() setTimeout(() => input.focus(), 0) }