/**
* Minimal DOM kit every view builds on: h() element helper, toast(), sheet().
* No framework, no virtual DOM — views own their subtree and re-render it.
*/
type Attrs = Record<string, string | number | boolean | EventListener | undefined>;
type Child = Node | string | null | undefined | false;
/** Create an element: h("button", { class: "btn", onclick: fn }, "Save"). */
export function h<K extends keyof HTMLElementTagNameMap>(
tag: K,
attrs: Attrs = {},
...children: Child[]
): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v === undefined || v === false) continue;
if (k.startsWith("on") && typeof v === "function") {
el.addEventListener(k.slice(2).toLowerCase(), v as EventListener);
} else if (v === true) {
el.setAttribute(k, "");
} else {
el.setAttribute(k, String(v));
}
}
for (const c of children) {
if (c === null || c === undefined || c === false) continue;
el.append(typeof c === "string" ? document.createTextNode(c) : c);
}
return el;
}
/** Inline SVG from a path spec (24×24 viewBox); used for tab-bar icons. */
export function svgIcon(d: string, cls = "icon"): SVGSVGElement {
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("class", cls);
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS(NS, "path");
path.setAttribute("d", d);
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.8");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.append(path);
return svg;
}
/** Transient notification above the tab bar; auto-dismisses. */
export function toast(msg: string, ms = 3000): void {
document.querySelector(".toast")?.remove();
const el = h("div", { class: "toast", role: "status" }, msg);
document.body.append(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => {
el.classList.remove("show");
setTimeout(() => el.remove(), 250);
}, ms);
}
export interface SheetHandle {
close: () => void;
/**
* Resolves when the sheet is gone — whether via close() or a backdrop tap.
* Promise-based flows MUST settle on this (treat an un-settled close as
* reject/dismiss) so a backdrop dismiss can never deadlock them.
*/
closed: Promise<void>;
}
/** Bottom sheet with backdrop; close() is idempotent, backdrop tap closes. */
export function sheet(content: HTMLElement): SheetHandle {
const panel = h("div", { class: "sheet", role: "dialog", "aria-modal": "true" }, content);
const backdrop = h("div", { class: "sheet-backdrop" }, panel);
let resolveClosed!: () => void;
const closed = new Promise<void>((r) => (resolveClosed = r));
let done = false;
const close = (): void => {
if (done) return;
done = true;
panel.classList.remove("show");
backdrop.classList.remove("show");
setTimeout(() => backdrop.remove(), 250);
resolveClosed();
};
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
document.body.append(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("show");
panel.classList.add("show");
});
return { close, closed };
}