Files
peardock/ui/components.js
T
snxraven 9e39c27b88
CI / test (push) Successful in 9m59s
Fix Pear Holesail client createRequire crash; polish confirm modals
Load holesail via dynamic ESM import (no createRequire) so Pear can connect local tunnels. Route confirmations through a designed modal with icons and animations instead of native dialogs.
2026-07-10 23:03:21 -04:00

314 lines
10 KiB
JavaScript

/**
* 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 `<span class="pd-badge pd-badge--${tone}">${escapeHtml(state || 'unknown')}</span>`
}
export function emptyState({ icon = 'fa-inbox', title, body, ctaLabel, onCta } = {}) {
const wrap = el('div', { className: 'pd-empty' })
wrap.innerHTML = `
<div class="pd-empty-icon"><i class="fas ${icon}"></i></div>
<h3 class="pd-empty-title">${escapeHtml(title || 'Nothing here yet')}</h3>
<p class="pd-empty-body">${escapeHtml(body || '')}</p>
<div class="pd-empty-actions"></div>`
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 `<span class="pd-suggested" title="Auto-filled from ${escapeHtml(source)}">${escapeHtml(source)}</span>`
}
/**
* 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<boolean>}
*/
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 = `
<div class="pd-modal-icon pd-modal-icon--${tone}" aria-hidden="true">
<i class="fas ${escapeHtml(icon)}"></i>
</div>
<h3 class="pd-modal-title" id="pd-confirm-title">${escapeHtml(opts.title)}</h3>
<p class="pd-modal-body">${escapeHtml(opts.body)}</p>
${
opts.requireText
? `<div class="pd-modal-confirm-field">
<label class="pd-modal-label">Type <strong>${escapeHtml(opts.requireText)}</strong> to confirm</label>
<input class="form-control bg-dark text-white pd-confirm-input" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(opts.requireText)}">
</div>`
: ''
}
<div class="pd-modal-actions">
${
opts.hideCancel
? ''
: `<button type="button" class="btn btn-outline-secondary pd-cancel">${escapeHtml(opts.cancelLabel || 'Cancel')}</button>`
}
<button type="button" class="btn ${danger ? 'btn-danger' : 'btn-primary'} pd-ok">
${escapeHtml(opts.confirmLabel || (info ? 'OK' : 'Confirm'))}
</button>
</div>`
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** 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 = `
<input class="form-control bg-dark text-white pd-palette-input" placeholder="Jump to… (type to filter)" autofocus aria-controls="pd-palette-list">
<div class="pd-palette-list" id="pd-palette-list" role="listbox"></div>`
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) =>
`<button type="button" role="option" class="pd-palette-item${idx === 0 ? ' is-active' : ''}" data-idx="${idx}" aria-selected="${idx === 0 ? 'true' : 'false'}"><i class="fas ${i.icon || 'fa-arrow-right'}"></i> ${escapeHtml(i.label)}</button>`
)
.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)
}