/**
* 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)
}