Files
bid-doctor/docs/design/ui-demo/demo.js
YANG JIANKUAN e1a9017cd1 docs(design): 新增 UI demo 页面,供 UI 精修评审
- docs/design/ui-demo/:控件总览、表单、卡片、列表与表格,以及工作台、读标两个组合页
- 只用设计规范的变量和组件;新增组件样式放在 demo.css,清单见 index
- index 列出 UI 精修待定问题 Q1–Q12
- design/README 与 AGENTS.md 进度同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:46:27 +08:00

276 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 标博士 · UI demo 公共脚本:图标、页面框架、轻提示、对话框、下拉、日期、开关、标签页、排序。
* 只演示交互,不连后端。正式实现里这些交互件由 Reka UI 打底(AR10)。 */
(function () {
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
/* Lucide 图标(ISC 许可)路径 */
const ICONS = {
grid: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
cert: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h10M7 13h6"/><circle cx="16" cy="15" r="2"/>',
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8M16 13H8M16 17H8"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
"chev-down": '<path d="m6 9 6 6 6-6"/>',
"chev-left": '<path d="m15 18-6-6 6-6"/>',
"chev-right": '<path d="m9 18 6-6-6-6"/>',
sort: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
"arrow-up": '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
"arrow-down": '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
more: '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
filter: '<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/>',
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
pencil: '<path d="M21.17 6.81a1 1 0 0 0-3.98-3.98L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/>',
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
keyboard: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/>',
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.64l-19 6.5a.5.5 0 0 0-.03.94l7.93 3.18a2 2 0 0 1 1.11 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
list: '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8M13 12h8M13 18h8"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
book: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
archive: '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
layers: '<path d="m12.83 2.18 8.58 3.91a1 1 0 0 1 0 1.83l-8.58 3.9a2 2 0 0 1-1.66 0L2.6 7.92a1 1 0 0 1 0-1.83l8.58-3.9a2 2 0 0 1 1.66 0Z"/><path d="m22 12-9.17 4.17a2 2 0 0 1-1.66 0L2 12"/><path d="m22 17-9.17 4.17a2 2 0 0 1-1.66 0L2 17"/>'
};
const icon = (n, cls = "") => `<svg class="ic ${cls}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[n] || ""}</svg>`;
function hydrate(root = document) {
$$("i[data-ic]", root).forEach((el) => { el.outerHTML = icon(el.dataset.ic, el.className); });
}
/* ---------- demo 顶部导航(控件页) ---------- */
const PAGES = [
["index.html", "目录"], ["controls.html", "① 控件"], ["forms.html", "② 表单"], ["cards.html", "③ 卡片"],
["lists.html", "④ 列表与表格"], ["page-workbench.html", "⑤ 工作台"], ["page-reading.html", "⑥ 读标"]
];
function demoTop(cur) {
const h = $("[data-demo-top]");
if (!h) return;
h.className = "d-top";
h.innerHTML = `<a class="brand" href="index.html"><img src="../logo/svg/logo-horizontal.svg" alt="标博士"></a><span class="tag">UI demo</span>
<nav class="d-nav" aria-label="demo 页面">${PAGES.map(([u, t]) => `<a href="${u}" class="${u === cur ? "on" : ""}">${t}</a>`).join("")}</nav>`;
}
/* ---------- 产品页面框架(组合页) ---------- */
function shell(opt) {
const nav = [["page-workbench.html", "grid", "工作台", "workbench"], ["#", "cert", "资质库", "quals"], ["#", "gauge", "额度", "quota"]]
.map(([h, i, t, k]) => `<a href="${h}" class="${opt.nav === k ? "on" : ""}" ${opt.nav === k ? 'aria-current="page"' : ""}>${icon(i, "lg")}${t}</a>`).join("");
const crumbs = (opt.crumbs || []).map((c, i, a) => i < a.length - 1 ? `<a href="${c[1]}">${c[0]}</a><span>/</span>` : `<b>${c[0]}</b>`).join("");
document.body.classList.add("app");
document.body.insertAdjacentHTML("afterbegin", `<aside class="sidebar">
<a class="brand" href="page-workbench.html"><img src="../logo/svg/logo-horizontal.svg" alt="标博士"></a>
<nav class="nav" aria-label="主导航">${nav}<div class="sub">UI demo</div><a href="index.html">${icon("book", "lg")}demo 目录</a></nav>
<div class="sidebar-foot"><b>王敏</b>南京某信息技术有限公司<br>专业版 · 至 2027-10-31</div></aside>`);
const main = $("main");
main.classList.add("main");
main.insertAdjacentHTML("afterbegin", `<header class="top"><div class="crumbs">${crumbs}</div>
<div class="top-right">
<a class="quota-chip tip-below" href="#" data-tip="本期剩余额度">解析<span class="fig">37</span>检查<span class="fig">12</span></a>
<button class="icon-btn tip-below" aria-label="通知" data-tip="通知" data-notice>${icon("bell", "lg")}<span class="dot"></span></button>
<button class="icon-btn tip-below tip-left" aria-label="快捷键说明" data-tip="快捷键说明(?)" data-keys>${icon("keyboard", "lg")}</button>
</div></header>`);
$("[data-notice]").onclick = () => toast("解析完成:补遗 1 已并入清单(新增 2 条,更新 1 条)。");
$("[data-keys]").onclick = keysDialog;
}
function keysDialog() {
dialog({ title: "快捷键", cancel: null, ok: "知道了",
body: `<table class="data"><tbody>
<tr><td><span class="kbd">J</span> / <span class="kbd">K</span></td><td>下一条 / 上一条</td></tr>
<tr><td><span class="kbd">O</span> 或 <span class="kbd">Enter</span></td><td>在原文查看器里打开当前出处</td></tr>
<tr><td><span class="kbd">1</span> <span class="kbd">2</span> <span class="kbd">3</span></td><td>确认无误 / 已修改 / 忽略(3 会打开原因输入框)</td></tr>
<tr><td><span class="kbd">U</span></td><td>撤销当前条的处理</td></tr>
<tr><td><span class="kbd">?</span></td><td>显示本说明</td></tr></tbody></table>` });
}
/* ---------- 轻提示:4 秒消失,悬停暂停 ---------- */
function toast(msg, opt = {}) {
let box = $(".toasts");
if (!box) { box = document.createElement("div"); box.className = "toasts"; box.setAttribute("role", "status"); box.setAttribute("aria-live", "polite"); document.body.appendChild(box); }
const t = document.createElement("div");
t.className = "toast";
t.innerHTML = `${opt.ok === false ? "" : '<span class="ok" aria-hidden="true">✓</span>'}<span>${msg}</span>${opt.undo ? '<span class="sep"></span><button class="lnk" data-undo>撤销</button>' : ""}`;
box.appendChild(t);
let left = 4000, start = Date.now(), timer = setTimeout(() => t.remove(), left);
t.addEventListener("mouseenter", () => { clearTimeout(timer); left -= Date.now() - start; });
t.addEventListener("mouseleave", () => { start = Date.now(); timer = setTimeout(() => t.remove(), Math.max(left, 1200)); });
if (opt.undo) $("[data-undo]", t).onclick = () => { t.remove(); opt.undo(); };
}
/* ---------- 对话框:Esc 关闭、焦点回到触发处 ---------- */
function dialog({ title, body, ok = "确定", cancel = "取消", wide = false, onOk, onOpen, footLeft = "" }) {
const back = document.activeElement;
const s = document.createElement("div");
s.className = "scrim";
s.innerHTML = `<div class="dialog ${wide ? "wide" : ""}" role="dialog" aria-modal="true" aria-labelledby="dlg-t">
<h2 id="dlg-t">${title}</h2><button class="icon-btn sm x" aria-label="关闭" data-x>${icon("x")}</button>
<div class="body">${body}</div>
<div class="foot">${footLeft ? `<span class="left">${footLeft}</span>` : ""}${cancel ? `<button class="btn" data-cancel>${cancel}</button>` : ""}<button class="btn primary" data-ok>${ok}</button></div></div>`;
document.body.appendChild(s);
document.body.style.overflow = "hidden";
const close = () => { s.remove(); document.body.style.overflow = ""; document.removeEventListener("keydown", onKey, true); if (back && back.focus) back.focus(); };
const onKey = (e) => {
if (e.key === "Escape") { e.stopPropagation(); close(); }
if (e.key === "Tab") { // 焦点锁定在对话框内
const f = $$("button:not([disabled]), input:not([disabled]), textarea, select, a[href]", s);
if (!f.length) return;
if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
}
};
document.addEventListener("keydown", onKey, true);
s.addEventListener("mousedown", (e) => { if (e.target === s) close(); });
$("[data-x]", s).onclick = close;
if (cancel) $("[data-cancel]", s).onclick = close;
$("[data-ok]", s).onclick = () => { if (!onOk || onOk(s) !== false) close(); };
hydrate(s); init(s);
if (onOpen) onOpen(s);
const f = $("textarea, input:not([type=checkbox]):not([type=radio]), [data-ok]", s);
if (f) f.focus();
return s;
}
/* ---------- 下拉选择(listbox)与下拉菜单 ---------- */
function closePops(except) {
$$(".pop.open").forEach((p) => { if (p !== except) { p.classList.remove("open"); const t = $("[aria-expanded]", p); if (t) t.setAttribute("aria-expanded", "false"); const m = $(".menu,.cal", p); if (m) m.hidden = true; } });
}
function initSelect(p) {
const trg = $(".select, [data-menu-trigger]", p), menu = $(".menu", p);
if (!trg || !menu || p.dataset.ready) return;
p.dataset.ready = 1;
menu.hidden = true;
const opts = () => $$('[role=option]:not([aria-disabled="true"]), [role=menuitem]:not([aria-disabled="true"])', menu);
let ai = -1;
const mark = (i) => { opts().forEach((o, k) => o.classList.toggle("is-active", k === i)); ai = i; const o = opts()[i]; if (o) o.scrollIntoView({ block: "nearest" }); };
const open = () => { closePops(p); p.classList.add("open"); menu.hidden = false; trg.setAttribute("aria-expanded", "true"); const sel = opts().findIndex((o) => o.getAttribute("aria-selected") === "true"); mark(sel < 0 ? 0 : sel); };
const close = () => { p.classList.remove("open"); menu.hidden = true; trg.setAttribute("aria-expanded", "false"); };
const pick = (o) => {
if (o.getAttribute("role") === "option") {
$$("[role=option]", menu).forEach((x) => x.setAttribute("aria-selected", x === o ? "true" : "false"));
const v = $(".val", trg); if (v) v.innerHTML = o.dataset.label || o.firstChild.textContent;
p.closest(".field")?.classList.remove("err");
p.dispatchEvent(new CustomEvent("change", { detail: o.dataset.value || o.textContent }));
} else if (o.dataset.toast) toast(o.dataset.toast);
close(); trg.focus();
};
trg.addEventListener("click", (e) => { e.stopPropagation(); p.classList.contains("open") ? close() : open(); });
trg.addEventListener("keydown", (e) => {
const n = opts().length;
if (["ArrowDown", "ArrowUp"].includes(e.key)) { e.preventDefault(); if (!p.classList.contains("open")) return open(); mark((ai + (e.key === "ArrowDown" ? 1 : n - 1)) % n); }
else if (e.key === "Enter" || e.key === " ") { if (p.classList.contains("open") && ai >= 0) { e.preventDefault(); pick(opts()[ai]); } }
else if (e.key === "Escape" && p.classList.contains("open")) { e.stopPropagation(); close(); }
});
menu.addEventListener("click", (e) => { const o = e.target.closest('[role=option], [role=menuitem]'); if (o && o.getAttribute("aria-disabled") !== "true") { e.stopPropagation(); pick(o); } });
}
/* ---------- 日期选择 ---------- */
const WD = ["一", "二", "三", "四", "五", "六", "日"];
function calHTML(y, m, sel, opt = {}) {
const first = new Date(y, m, 1), start = (first.getDay() + 6) % 7, days = new Date(y, m + 1, 0).getDate();
const prevDays = new Date(y, m, 0).getDate();
let cells = "";
for (let i = 0; i < 42; i++) {
const d = i - start + 1;
let dd, out = false, mm = m;
if (d < 1) { dd = prevDays + d; out = true; mm = m - 1; } else if (d > days) { dd = d - days; out = true; mm = m + 1; } else dd = d;
const date = new Date(y, mm, dd), key = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
const cls = [out ? "out" : "", key === opt.today ? "today" : "", (opt.marks || []).includes(key) ? "mark" : ""].join(" ");
cells += `<button type="button" class="${cls}" data-d="${key}" aria-selected="${key === sel}" ${opt.min && date < opt.min ? "disabled" : ""} tabindex="-1">${dd}</button>`;
if (i === 34 && d >= days) break;
}
return `<div class="cal-h"><button type="button" class="icon-btn sm" data-cm="-1" aria-label="上个月">${icon("chev-left")}</button><b><span class="fig">${y}</span> 年 <span class="fig">${m + 1}</span> 月</b><button type="button" class="icon-btn sm" data-cm="1" aria-label="下个月">${icon("chev-right")}</button></div>
<div class="cal-grid" role="grid">${WD.map((w) => `<span class="wd">${w}</span>`).join("")}${cells}</div>
<div class="cal-f">${opt.time ? `时间 <input class="input time" value="${opt.time}" aria-label="时间">` : '<span>点圆点的日期是本项目的关键日期</span>'}<button type="button" class="btn sm text" data-today>今天</button></div>`;
}
function initDate(p) {
if (p.dataset.ready) return;
p.dataset.ready = 1;
const trg = $(".select", p), cal = $(".cal", p);
let [y, m] = (p.dataset.month || "2026-11").split("-").map(Number); m -= 1;
let sel = p.dataset.value || "";
const opt = { today: "2026-11-3", marks: (p.dataset.marks || "").split(",").filter(Boolean), time: p.dataset.time, min: p.dataset.min ? new Date(...p.dataset.min.split("-").map((v, i) => i === 1 ? v - 1 : +v)) : null };
const render = () => { cal.innerHTML = calHTML(y, m, sel, opt); };
const fmt = (k) => { const [a, b, c] = k.split("-"); return `${a}-${String(b).padStart(2, "0")}-${String(c).padStart(2, "0")}`; };
render();
if (!p.classList.contains("d-open")) cal.hidden = true;
trg?.addEventListener("click", (e) => { e.stopPropagation(); const o = !p.classList.contains("open"); closePops(p); p.classList.toggle("open", o); cal.hidden = !o; trg.setAttribute("aria-expanded", o); });
cal.addEventListener("click", (e) => {
e.stopPropagation();
const b = e.target.closest("button"); if (!b) return;
if (b.dataset.cm) { m += +b.dataset.cm; if (m < 0) { m = 11; y--; } if (m > 11) { m = 0; y++; } render(); return; }
if (b.dataset.today) { y = 2026; m = 10; sel = opt.today; }
else if (b.dataset.d) sel = b.dataset.d;
else return;
render();
if (trg) { $(".val", trg).innerHTML = fmt(sel); p.closest(".field")?.classList.remove("err"); if (!p.classList.contains("d-open")) { p.classList.remove("open"); cal.hidden = true; trg.setAttribute("aria-expanded", "false"); trg.focus(); } }
p.dispatchEvent(new CustomEvent("change", { detail: fmt(sel) }));
});
}
/* ---------- 开关、分段、标签页、半选、排序、加载按钮 ---------- */
function init(root = document) {
$$(".pop[data-select], .pop[data-menu]", root).forEach(initSelect);
$$(".pop[data-date]", root).forEach(initDate);
$$(".switch:not([data-ready])", root).forEach((s) => {
s.dataset.ready = 1;
const upd = () => { const st = $(".st", s); if (st) st.textContent = s.getAttribute("aria-checked") === "true" ? "开" : "关"; };
upd();
s.addEventListener("click", () => { if (s.disabled) return; s.setAttribute("aria-checked", s.getAttribute("aria-checked") === "true" ? "false" : "true"); upd(); });
});
$$(".seg:not([data-ready])", root).forEach((g) => {
g.dataset.ready = 1;
g.addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; $$("button", g).forEach((x) => x.setAttribute("aria-pressed", x === b)); g.dispatchEvent(new CustomEvent("change", { detail: b.dataset.v })); });
});
$$("[role=tablist]:not([data-ready])", root).forEach((tl) => {
tl.dataset.ready = 1;
const tabs = $$("[role=tab]", tl);
const sel = (t) => { tabs.forEach((x) => { const on = x === t; x.setAttribute("aria-selected", on); x.tabIndex = on ? 0 : -1; const pn = x.getAttribute("aria-controls"); if (pn) document.getElementById(pn).hidden = !on; }); };
tabs.forEach((t) => t.addEventListener("click", () => { if (t.getAttribute("aria-disabled") !== "true") sel(t); }));
tl.addEventListener("keydown", (e) => {
const i = tabs.indexOf(document.activeElement); if (i < 0) return;
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; if (!d) return;
e.preventDefault(); let j = i; do { j = (j + d + tabs.length) % tabs.length; } while (tabs[j].getAttribute("aria-disabled") === "true");
tabs[j].focus(); sel(tabs[j]);
});
});
$$("input[data-indeterminate]", root).forEach((c) => { c.indeterminate = true; });
$$("[data-loading]:not([data-ready])", root).forEach((b) => {
b.dataset.ready = 1;
b.addEventListener("click", () => {
if (b.classList.contains("is-loading")) return;
b.classList.add("is-loading"); b.setAttribute("aria-busy", "true");
setTimeout(() => { b.classList.remove("is-loading"); b.removeAttribute("aria-busy"); if (b.dataset.loading) toast(b.dataset.loading); }, 2200);
});
});
}
document.addEventListener("click", () => closePops());
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closePops(); });
/* 可排序表格:th[data-sort] 里放 button.th-sort;按 data-sort 指定的列取 td 的 data-v 或文字 */
function sortable(table, onSort) {
$$("th[data-sort]", table).forEach((th) => {
const b = $(".th-sort", th);
b.insertAdjacentHTML("beforeend", icon("sort"));
b.addEventListener("click", () => {
const dir = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending";
$$("th[aria-sort]", table).forEach((x) => { x.removeAttribute("aria-sort"); $("svg", x).outerHTML = icon("sort"); });
th.setAttribute("aria-sort", dir);
$("svg", th).outerHTML = icon(dir === "ascending" ? "arrow-up" : "arrow-down");
onSort(th.dataset.sort, dir);
});
});
}
window.Demo = { $, $$, esc, icon, hydrate, demoTop, shell, toast, dialog, init, sortable, keysDialog, calHTML };
document.addEventListener("DOMContentLoaded", () => { hydrate(); init(); });
})();