The close cross on a detail pane is drawn, not a glyph, and sits in no box. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
152 lines
7.1 KiB
TypeScript
152 lines
7.1 KiB
TypeScript
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||
|
||
// ---- spinners ----
|
||
export function Spinner({ size = 14 }: { size?: number }) {
|
||
return <span className="spin" style={{ width: size, height: size }} aria-hidden="true" />
|
||
}
|
||
// The × in the corner of a detail pane. A drawn cross rather than the × glyph, which sits
|
||
// differently in every font and theme.
|
||
export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; title?: string }) {
|
||
return (
|
||
<button type="button" className="detail-close" title={title} aria-label={title} onClick={onClick}>
|
||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" fill="none" /></svg>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
export function Loading({ label = 'Loading…' }: { label?: string }) {
|
||
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
|
||
}
|
||
|
||
// Button that shows a spinner while its async handler runs; `confirm` asks first.
|
||
export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title, children }: {
|
||
onClick: () => unknown | Promise<unknown>; busy?: boolean; confirm?: string; className?: string; disabled?: boolean; title?: string; children: ReactNode
|
||
}) {
|
||
const [inner, setInner] = useState(false)
|
||
const b = !!busy || inner
|
||
return (
|
||
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
|
||
onClick={async () => {
|
||
if (confirm && !(await askYes(confirm))) return
|
||
const r = onClick()
|
||
if (r && typeof (r as Promise<unknown>).then === 'function') {
|
||
setInner(true)
|
||
try { await r } finally { setInner(false) }
|
||
}
|
||
}}>
|
||
{b && <Spinner />}{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ---- toasts: short feedback for actions ----
|
||
interface Toast { id: number; kind: 'ok' | 'err' | 'info'; msg: string }
|
||
const listeners = new Set<(t: Toast[]) => void>()
|
||
let toasts: Toast[] = []
|
||
let seq = 0
|
||
function emit() { listeners.forEach((l) => l(toasts)) }
|
||
export function dismissToast(id: number) { toasts = toasts.filter((t) => t.id !== id); emit() }
|
||
export function toast(msg: string, kind: Toast['kind'] = 'ok') {
|
||
const t = { id: ++seq, kind, msg }
|
||
toasts = [...toasts.slice(-4), t]
|
||
emit()
|
||
window.setTimeout(() => dismissToast(t.id), kind === 'err' ? 8000 : 3500)
|
||
}
|
||
export function Toasts() {
|
||
const [list, setList] = useState(toasts)
|
||
useEffect(() => { listeners.add(setList); return () => { listeners.delete(setList) } }, [])
|
||
return (
|
||
<div className="toasts">
|
||
{list.map((t) => <div key={t.id} className={'toast ' + t.kind} onClick={() => dismissToast(t.id)}>{t.msg}</div>)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// act runs an action and toasts its outcome. Returns whether it succeeded.
|
||
export async function act(label: string, fn: () => Promise<unknown>): Promise<boolean> {
|
||
try { await fn(); toast(label); return true }
|
||
catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
|
||
}
|
||
|
||
// ---- thin activity bar at the top while any request is in flight ----
|
||
export function NetBar() {
|
||
const [n, setN] = useState(0)
|
||
useEffect(() => {
|
||
const h = (e: Event) => setN((e as CustomEvent<number>).detail)
|
||
window.addEventListener('hamlab:inflight', h)
|
||
return () => window.removeEventListener('hamlab:inflight', h)
|
||
}, [])
|
||
return <div className={'netbar' + (n > 0 ? ' on' : '')} />
|
||
}
|
||
|
||
// useEscape runs fn when Escape is pressed while active — every menu and dialog closes that way.
|
||
// A question (askYes/askText) sits on top of whatever asked it: Escape answers the question and
|
||
// goes no further, or one key would cancel the question and close the dialog underneath as well.
|
||
let escapeTaken: Event | null = null
|
||
export function useEscape(fn: () => void, active = true, isAsk = false) {
|
||
const ref = useRef(fn); ref.current = fn
|
||
useEffect(() => {
|
||
if (!active) return
|
||
const h = (e: KeyboardEvent) => {
|
||
if (e.key !== 'Escape') return
|
||
if (isAsk) { escapeTaken = e; ref.current() } else if (asks.length === 0 && escapeTaken !== e) ref.current()
|
||
}
|
||
window.addEventListener('keydown', h)
|
||
return () => window.removeEventListener('keydown', h)
|
||
}, [active])
|
||
}
|
||
|
||
// ---- dialogs: askYes / askText replace the browser's confirm() and prompt() ----
|
||
// Same promise shape as the natives (false / null on cancel), drawn like the rest of the
|
||
// console, usable on a phone, closed by Escape or a tap outside.
|
||
interface Ask { id: number; msg: string; input?: string; ok: string; danger: boolean; done: (v: string | boolean | null) => void }
|
||
let asks: Ask[] = []
|
||
let nextId = 0
|
||
const askListeners = new Set<(a: Ask[]) => void>()
|
||
const emitAsks = () => askListeners.forEach((l) => l(asks))
|
||
function ask(msg: string, input: string | undefined, ok?: string): Promise<string | boolean | null> {
|
||
return new Promise((resolve) => {
|
||
const id = ++nextId
|
||
const danger = /^(delete|remove|revoke|close|send sig|stop|disable|reset|discard|force)/i.test(msg) || /\bforce\b/i.test(msg)
|
||
asks = [...asks, { id, msg, input, ok: ok ?? (input !== undefined ? 'OK' : danger ? msg.split(/[ ?]/)[0] : 'OK'), danger, done: (v) => { asks = asks.filter((a) => a.id !== id); emitAsks(); resolve(v) } }]
|
||
emitAsks()
|
||
})
|
||
}
|
||
export const askYes = (msg: string, ok?: string) => ask(msg, undefined, ok) as Promise<boolean>
|
||
export const askText = (msg: string, initial = '', ok?: string) => ask(msg, initial, ok) as Promise<string | null>
|
||
|
||
function AskBox({ a }: { a: Ask }) {
|
||
const [val, setVal] = useState(a.input ?? '')
|
||
const cancel = () => a.done(a.input !== undefined ? null : false)
|
||
const accept = () => a.done(a.input !== undefined ? val : true)
|
||
useEscape(cancel, true, true)
|
||
return (
|
||
<div className="modal-back ask-back" onClick={cancel}>
|
||
<form className="modal ask" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
|
||
<div className="ask-msg">{a.msg}</div>
|
||
{a.input !== undefined && <input autoFocus value={val} onChange={(e) => setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||
<div className="svc-actions">
|
||
<button type="submit" autoFocus={a.input === undefined} className={'btn ' + (a.danger ? 'danger' : 'on')}>{a.ok}</button>
|
||
<span className="spacer" />
|
||
<button type="button" className="btn" onClick={cancel}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
export function Dialogs() {
|
||
const [list, setList] = useState<Ask[]>(asks)
|
||
useEffect(() => { askListeners.add(setList); return () => { askListeners.delete(setList) } }, [])
|
||
const top = list[list.length - 1]
|
||
return top ? <AskBox key={top.id} a={top} /> : null
|
||
}
|
||
|
||
// Unsaved work: whoever holds some says so, and whoever is about to navigate away asks.
|
||
let unsaved = ''
|
||
export function setUnsaved(what: string) { unsaved = what }
|
||
export const hasUnsaved = () => unsaved !== ''
|
||
export async function okToLeave(): Promise<boolean> {
|
||
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
|
||
return false
|
||
}
|