// Dialogs, action menus, option pickers, buttons and toasts. import { append, h, replace } from "./dom.ts"; import { focus, focusFirst, Keys, popLayer, pushLayer, type KeyHandling } from "./focus.ts"; export function button(label: string, onClick: () => void, cls = ""): HTMLButtonElement { return h(`button.btn.f${cls ? "." + cls : ""}` as "button", { onclick: onClick, tabindex: -1 }, label); } // Dialog: a centred box over everything; Back closes it. export class Dialog { el: HTMLElement; box: HTMLElement; private onClose?: () => void; private closed = false; constructor(title: string, cls = "", onClose?: () => void) { this.onClose = onClose; this.box = h("div.dialog-box", h("h2", title)); if (cls) this.box.classList.add(cls); this.el = h("div.dialog", this.box); } add(...children: (Node | string | null)[]): this { append(this.box, ...children); return this; } // open shows the dialog; called again (content changed), it only moves // the focus. open(focusEl?: HTMLElement): this { if (this.closed) return this; if (!this.el.isConnected) { document.getElementById("overlays")!.appendChild(this.el); pushLayer(this.el, () => this.close()); } if (focusEl) focus(focusEl); else focusFirst(this.el); return this; } close(): void { if (this.closed) return; this.closed = true; popLayer(this.el); this.el.remove(); this.onClose?.(); } } export interface Action { label: string; run: () => void; danger?: boolean; hint?: string; } // actionSheet lists what can be done with an item; OK runs one. export function actionSheet(title: string, actions: Action[]): void { const d = new Dialog(title, "sheet"); for (const a of actions) { const b = button(a.label, () => { d.close(); a.run(); }, a.danger ? "danger" : ""); if (a.hint) b.appendChild(h("span.btn-hint", a.hint)); d.add(b); } d.add(button("Cancel", () => d.close(), "ghost")); d.open(); } export function confirmDialog(message: string, yes: string, run: () => void, danger = false): void { const d = new Dialog("Are you sure?"); d.add(h("p.dialog-text", message)); const no = button("Cancel", () => d.close(), "ghost"); d.add(h("div.row-buttons", button(yes, () => { d.close(); run(); }, danger ? "danger" : ""), no)); d.open(no); } // Choice: a value picked from options with left / right (or OK for the // next one), the TV-friendly dropdown. export class Choice { el: HTMLElement & KeyHandling; private valueEl: HTMLElement; options: { label: string; value: V }[] = []; index = 0; onChange?: (v: V) => void; constructor(label: string, options: { label: string; value: V }[], value?: V, onChange?: (v: V) => void) { this.valueEl = h("span.choice-value"); this.el = h("div.choice.f", { tabindex: -1 }, h("span.choice-label", label), h("span.choice-arrows", "‹", this.valueEl, "›")) as HTMLElement & KeyHandling; this.onChange = onChange; this.setOptions(options, value); this.el.onKey = (ev) => { if (ev.keyCode !== Keys.left && ev.keyCode !== Keys.right) return false; this.step(ev.keyCode === Keys.left ? -1 : 1); return true; }; this.el.addEventListener("click", (ev) => { const r = this.el.getBoundingClientRect(); const x = (ev as MouseEvent).clientX; // A pointer click on the left third goes back; OK and other clicks forward. this.step(x > 0 && x < r.left + r.width / 3 ? -1 : 1); }); } get value(): V { return this.options[this.index]?.value; } setOptions(options: { label: string; value: V }[], value?: V): void { this.options = options; const i = value === undefined ? -1 : options.findIndex((o) => o.value === value); this.index = i >= 0 ? i : Math.min(this.index, Math.max(0, options.length - 1)); this.show(); } private step(d: number): void { if (this.options.length < 2) return; this.index = (this.index + d + this.options.length) % this.options.length; this.show(); this.onChange?.(this.value); } private show(): void { replace(this.valueEl, this.options[this.index]?.label ?? "–"); } } // Toggle: a checkbox; OK flips it. export class Toggle { el: HTMLElement; value: boolean; onChange?: (v: boolean) => void; constructor(label: string, value: boolean, onChange?: (v: boolean) => void) { this.value = value; this.onChange = onChange; this.el = h("div.toggle.f", { tabindex: -1, onclick: () => this.set(!this.value, true) }, h("span.toggle-box"), h("span", label)); this.set(value, false); } set(v: boolean, notify: boolean): void { this.value = v; this.el.classList.toggle("on", v); if (notify) this.onChange?.(v); } } export function textField(label: string, value: string, opts: { secret?: boolean; placeholder?: string; onInput?: (v: string) => void } = {}): { el: HTMLElement; input: HTMLInputElement } { const input = h("input.f", { type: opts.secret ? "password" : "text", value, placeholder: opts.placeholder ?? "", spellcheck: false, autocomplete: "off" }); if (opts.onInput) input.addEventListener("input", () => opts.onInput!(input.value)); return { el: h("label.field", h("span.field-label", label), input), input }; } // --- Toasts and the log --- export type Level = "info" | "ok" | "warn" | "error"; export interface LogEntry { at: Date; level: Level; text: string; } export const log: LogEntry[] = []; const logListeners: (() => void)[] = []; export function onLog(f: () => void): void { logListeners.push(f); } // toast shows a message for a few seconds and keeps it in the log. export function toast(text: string, level: Level = "info"): void { log.push({ at: new Date(), level, text }); if (log.length > 500) log.splice(0, log.length - 500); for (const f of logListeners) f(); const box = document.getElementById("toasts"); if (!box) return; const el = h(`div.toast.toast-${level}` as "div", text); box.appendChild(el); while (box.children.length > 4) box.firstElementChild!.remove(); setTimeout(() => el.classList.add("gone"), level === "error" ? 7000 : 4000); setTimeout(() => el.remove(), level === "error" ? 7600 : 4600); } export function errorText(e: unknown): string { return e instanceof Error ? e.message : String(e); } // run executes an action with a "…" toast and a result toast. export async function run(doing: string, done: string, f: () => Promise): Promise { toast(doing + "…"); try { await f(); toast(done, "ok"); return true; } catch (e) { toast(`${doing} failed: ${errorText(e)}`, "error"); return false; } }