/* 标博士 · 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); /* Lucide 图标(ISC 许可)路径 */ const ICONS = { grid: '', folder: '', cert: '', gauge: '', bell: '', help: '', x: '', plus: '', upload: '', download: '', file: '', search: '', "chev-down": '', "chev-left": '', "chev-right": '', sort: '', "arrow-up": '', "arrow-down": '', calendar: '', check: '', more: '', filter: '', inbox: '', clock: '', trash: '', pencil: '', history: '', keyboard: '', refresh: '', send: '', list: '', paperclip: '', book: '', archive: '', layers: '' }; const icon = (n, cls = "") => ``; 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 = `标博士UI demo `; } /* ---------- 产品页面框架(组合页) ---------- */ function shell(opt) { const nav = [["page-workbench.html", "grid", "工作台", "workbench"], ["#", "cert", "资质库", "quals"], ["#", "gauge", "额度", "quota"]] .map(([h, i, t, k]) => `${icon(i, "lg")}${t}`).join(""); const crumbs = (opt.crumbs || []).map((c, i, a) => i < a.length - 1 ? `${c[0]}/` : `${c[0]}`).join(""); document.body.classList.add("app"); document.body.insertAdjacentHTML("afterbegin", ``); const main = $("main"); main.classList.add("main"); main.insertAdjacentHTML("afterbegin", `
${crumbs}
解析37检查12
`); $("[data-notice]").onclick = () => toast("解析完成:补遗 1 已并入清单(新增 2 条,更新 1 条)。"); $("[data-keys]").onclick = keysDialog; } function keysDialog() { dialog({ title: "快捷键", cancel: null, ok: "知道了", body: `
J / K下一条 / 上一条
O 或 Enter在原文查看器里打开当前出处
1 2 3确认无误 / 已修改 / 忽略(3 会打开原因输入框)
U撤销当前条的处理
?显示本说明
` }); } /* ---------- 轻提示: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 ? "" : ''}${msg}${opt.undo ? '' : ""}`; 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 = ``; 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 += ``; if (i === 34 && d >= days) break; } return `
${y} 年 ${m + 1} 月
${WD.map((w) => `${w}`).join("")}${cells}
${opt.time ? `时间 ` : '点圆点的日期是本项目的关键日期'}
`; } 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(); }); })();