- 13 页全部重刷:96px 墨色渐变侧栏、透明顶栏、项目头与标签页、对照布局;查标交互与 UI demo 一致(行内说明、U 撤销当前条、批量仅提示级) - assets/app.js、app.css 重写为 v0.2 框架与共用组件(查看器上一条 / 下一条与 clear、日期输入、浮动菜单、快捷键说明等);data.js 补任务与最近处理数据 - 额度页:Q1 墨色「+」、Q13 免扣行、续费申请按 G8;补遗 1 条数统一为「更新 2 条」 - 报告页脚与免责声明改为「辅助检查结果,仅供投标前自查参考」,design-system §7.17 与 FR-M3-09 同步;components.css 的 .scope 改名 .report-scope - README 升 v0.2;AGENTS.md 同步进度与 VI 手册待办 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
517 lines
38 KiB
JavaScript
517 lines
38 KiB
JavaScript
/* 标博士 · UI demo 公共脚本 v0.2:图标、页面框架、轻提示、对话框、下拉、浮动菜单、日期、开关、标签页、排序。
|
||
* 只演示交互,不连后端。正式实现里这些交互件由 Reka UI 打底(AR10)。
|
||
* v0.2:Demo.shell() 生成定稿样张 explore/workbench-d5.html 的框架(96px 墨色渐变侧栏 + 透明顶栏 + 内容区圆角)。
|
||
* 收尾(2026-11-03):日期输入 Demo.dateInput()(.dt[data-dt] 自动初始化)、字段错误 Demo.fieldError()、
|
||
* 浮动菜单支持禁用项、快捷键说明按页面传入(Demo.KEYS)、对照布局拖动宽度 Demo.cmp()(.cmp[data-cmp] 自动初始化)。 */
|
||
(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: '<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"/>',
|
||
house: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||
award: '<path d="m15.477 12.89 1.515 8.526a.5.5 0 0 1-.81.47l-3.58-2.687a1 1 0 0 0-1.197 0l-3.586 2.686a.5.5 0 0 1-.81-.469l1.514-8.526"/><circle cx="12" cy="8" r="6"/>',
|
||
wallet: '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
|
||
"alert-circle": '<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>',
|
||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
|
||
"check-circle": '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
||
eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>'
|
||
};
|
||
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>`;
|
||
}
|
||
|
||
/* ---------- 产品页面框架 v0.2(组合页) ----------
|
||
* opt.nav:当前菜单 workbench / quals / quota
|
||
* opt.crumbs:[[文字, 链接], …],最后一项是当前页;顶栏左侧显示「公司 / 页面」
|
||
* opt.search:{ placeholder, onInput(q) };不传 onInput 时回车只出演示提示。Ctrl K 聚焦
|
||
* opt.quota:{ parse: [剩余, 总数], check: [剩余, 总数] };URL 带 ?quota=warn / ?quota=out 可预览 80% 和用完(Q9)
|
||
* opt.plan:free / pro / ent(也可用 ?plan=) */
|
||
const ORG = "南京某信息技术有限公司";
|
||
function shell(opt = {}) {
|
||
const qs = new URLSearchParams(location.search);
|
||
const nav = [["page-workbench.html", "house", "工作台", "workbench"], ["#", "award", "资质库", "quals"], ["#", "wallet", "额度", "quota"]]
|
||
.map(([h, i, t, k]) => `<a href="${h}" ${opt.nav === k ? 'aria-current="page"' : ""} ${h === "#" ? "data-demo-nav" : ""}>${icon(i)}<span>${t}</span></a>`).join("");
|
||
const crumbs = (opt.crumbs || []).map((c, i, a) => i < a.length - 1 && c[1] ? `<a href="${c[1]}" title="${esc(c[0])}">${esc(c[0])}</a><span aria-hidden="true">/</span>` : `<b title="${esc(c[0])}">${esc(c[0])}</b>${i < a.length - 1 ? '<span aria-hidden="true">/</span>' : ""}`).join("");
|
||
const PLAN = { free: "免费版", pro: "专业版", ent: "企业版" };
|
||
const plan = PLAN[qs.get("plan")] ? qs.get("plan") : (opt.plan || "pro");
|
||
const quota = Object.assign({ parse: [37, 50], check: [12, 20] }, opt.quota || {});
|
||
if (qs.get("quota") === "warn") quota.check = [3, 20];
|
||
if (qs.get("quota") === "out") quota.check = [0, 20];
|
||
const qItem = (name, [left, total]) => `<span class="q ${(total - left) / total >= 0.8 ? "warn" : ""}">${name}<span class="fig">${left}</span></span>`;
|
||
const out = [["解析", quota.parse], ["检查", quota.check]].filter(([, [l]]) => l <= 0).map(([n]) => n);
|
||
const qTip = `本期剩余额度:解析 ${quota.parse[0]} / ${quota.parse[1]} 次,检查 ${quota.check[0]} / ${quota.check[1]} 次` +
|
||
(out.length ? `。本期${out.join("、")}额度已用完,可在额度页提交续费申请,或等下期发放` : "");
|
||
|
||
document.body.classList.add("app");
|
||
document.body.insertAdjacentHTML("afterbegin", `<aside class="sidebar">
|
||
<a class="brand" href="page-workbench.html" aria-label="标博士 · 工作台"><img src="../logo/svg/logo-vertical-reverse.svg" alt=""></a>
|
||
<nav class="nav" aria-label="主导航">${nav}<span class="sep" role="separator"></span><a href="index.html">${icon("book")}<span>demo 目录</span></a></nav>
|
||
<div class="me"><span class="avatar" aria-hidden="true">王</span><b class="uname">王敏</b><span class="plan" data-plan="${plan}">${PLAN[plan]}</span></div></aside>`);
|
||
const main = $("main");
|
||
main.classList.add("main");
|
||
main.insertAdjacentHTML("afterbegin", `<header class="top">
|
||
<div class="crumbs"><span class="org">${ORG}</span><span aria-hidden="true">/</span>${crumbs}</div>
|
||
<form class="top-search" role="search" data-gsearch>${icon("search")}<input type="search" placeholder="${esc((opt.search && opt.search.placeholder) || "搜索项目、采购人")}" aria-label="${esc((opt.search && opt.search.placeholder) || "搜索项目、采购人")}" aria-keyshortcuts="Control+K" data-gq><span class="kbd" aria-hidden="true">Ctrl K</span></form>
|
||
<span class="vsep" aria-hidden="true"></span>
|
||
<div class="top-right">
|
||
<a class="quota-chip tip-below tip-left" href="#" data-tip="${qTip}" aria-label="${qTip}" data-demo-nav>${qItem("解析", quota.parse)}<span class="sep"></span>${qItem("检查", quota.check)}</a>
|
||
<button class="icon-btn tip-below tip-left" aria-label="通知(1 条未读)" data-tip="通知" data-notice>${icon("bell", "lg")}<span class="dot"></span></button>
|
||
</div></header>`);
|
||
const top = $(".top", main), gq = $("[data-gq]", main);
|
||
const onScroll = () => top.classList.toggle("scrolled", (main.scrollTop || window.scrollY) > 4);
|
||
addEventListener("scroll", onScroll, { passive: true }); main.addEventListener("scroll", onScroll, { passive: true }); onScroll();
|
||
$("[data-gsearch]", main).addEventListener("submit", (e) => { e.preventDefault(); if (!(opt.search && opt.search.onInput)) toast("(演示)全局搜索:这里只做了样式", { ok: false }); });
|
||
if (opt.search && opt.search.onInput) gq.addEventListener("input", () => opt.search.onInput(gq.value.trim()));
|
||
document.addEventListener("keydown", (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); gq.focus(); }
|
||
else if (e.key === "Escape" && document.activeElement === gq) gq.blur();
|
||
});
|
||
document.addEventListener("click", (e) => { if (e.target.closest("[data-demo-nav]")) { e.preventDefault(); toast("(演示)这一页没有做", { ok: false }); } });
|
||
$("[data-notice]").onclick = () => toast("解析完成:补遗 1 已并入清单(更新 2 条)。");
|
||
}
|
||
|
||
/* ---------- 浮动菜单:列表行的「更多」等(role=menu,↑ ↓ 移动,Esc 关闭,焦点回到触发按钮) ----------
|
||
* items:[{ label, sub, act, disabled }] 或 "-"(分隔线)
|
||
* sub:第二行说明,可以是 HTML(如扣点说明);
|
||
* disabled:不可用的原因(如「递交后可用」),该项变禁用灰、右侧写原因,点了不触发;
|
||
* onPick(act, item)。Demo.menuHTML(items) 生成同样的菜单项,供陈列页静态展示。 */
|
||
function menuHTML(items) {
|
||
return items.map((it, i) => it === "-" ? "<hr>" : `<button type="button" role="menuitem" class="${it.sub ? "two" : ""} ${it.active ? "is-active" : ""}" data-i="${i}"${it.disabled ? ' aria-disabled="true"' : ""}>${esc(it.label)}${it.sub ? `<small>${it.sub}</small>` : it.disabled ? `<small>${esc(it.disabled)}</small>` : ""}</button>`).join("");
|
||
}
|
||
let fm = null;
|
||
function closeFloatMenu(focusBack) {
|
||
if (!fm) return;
|
||
fm.el.remove(); fm.btn.setAttribute("aria-expanded", "false");
|
||
if (focusBack) fm.btn.focus();
|
||
fm = null;
|
||
}
|
||
function floatMenu(btn, items, onPick) {
|
||
if (fm && fm.btn === btn) return closeFloatMenu();
|
||
closeFloatMenu();
|
||
const el = document.createElement("div");
|
||
el.className = "menu float"; el.setAttribute("role", "menu");
|
||
el.innerHTML = menuHTML(items);
|
||
document.body.appendChild(el);
|
||
const r = btn.getBoundingClientRect();
|
||
const below = r.bottom + 4 + el.offsetHeight < innerHeight;
|
||
el.style.top = `${below ? r.bottom + 4 : r.top - 4 - el.offsetHeight}px`;
|
||
el.style.left = `${Math.max(8, r.right - el.offsetWidth)}px`;
|
||
btn.setAttribute("aria-expanded", "true");
|
||
fm = { el, btn };
|
||
el.addEventListener("click", (e) => { const b = e.target.closest("[data-i]"); if (!b) return; e.stopPropagation(); const it = items[+b.dataset.i]; if (it.disabled) return; closeFloatMenu(true); if (onPick) onPick(it.act || it.label, it); });
|
||
el.addEventListener("keydown", (e) => {
|
||
const its = $$("[role=menuitem]", el), k = its.indexOf(document.activeElement);
|
||
if (e.key === "ArrowDown") { e.preventDefault(); its[(k + 1) % its.length].focus(); }
|
||
if (e.key === "ArrowUp") { e.preventDefault(); its[(k - 1 + its.length) % its.length].focus(); }
|
||
if (e.key === "Escape") { e.stopPropagation(); closeFloatMenu(true); }
|
||
if (e.key === "Tab") closeFloatMenu();
|
||
});
|
||
$("[role=menuitem]", el).focus();
|
||
}
|
||
document.addEventListener("click", (e) => { if (fm && !fm.el.contains(e.target) && !fm.btn.contains(e.target)) closeFloatMenu(); });
|
||
addEventListener("scroll", () => closeFloatMenu(), { passive: true, capture: true });
|
||
addEventListener("resize", () => closeFloatMenu());
|
||
|
||
/* ---------- 快捷键说明(§8.2):按页面传入,只列本页能用的键 ----------
|
||
* Demo.keysDialog(Demo.KEYS.reading) 读标:J / K、Enter、?
|
||
* Demo.keysDialog(Demo.KEYS.check) 查标(问题列表):J / K、O / Enter、1 / 2 / 3、X、U、?
|
||
* 也可以传自己的 [[键的 HTML, 说明], …];Demo.keysTable(list) 生成同样的表格供静态展示。 */
|
||
const kb = (k) => `<span class="kbd">${k}</span>`;
|
||
const KEY = {
|
||
jk: [`${kb("J")} / ${kb("K")}`, "下一条 / 上一条"],
|
||
enter: [kb("Enter"), "在原文查看器里打开当前条的出处"],
|
||
open: [`${kb("O")} 或 ${kb("Enter")}`, "在原文查看器里打开当前出处"],
|
||
act: [`${kb("1")} ${kb("2")} ${kb("3")}`, "确认无误 / 已修改 / 忽略(忽略、致命的确认无误在行内写说明)"],
|
||
x: [kb("X"), "勾选当前条(只限「提示」)"],
|
||
undo: [kb("U"), "撤销刚才的处理"],
|
||
help: [kb("?"), "显示本说明"]
|
||
};
|
||
const KEYS = { reading: [KEY.jk, KEY.enter, KEY.help], check: [KEY.jk, KEY.open, KEY.act, KEY.x, KEY.undo, KEY.help] };
|
||
const keysTable = (list) => `<table class="data keys"><tbody>${list.map(([k, t]) => `<tr><td class="nw">${k}</td><td>${t}</td></tr>`).join("")}</tbody></table>`;
|
||
function keysDialog(list = KEYS.check) {
|
||
dialog({ title: "快捷键", cancel: null, ok: "知道了", body: keysTable(list) });
|
||
}
|
||
|
||
/* ---------- 轻提示: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();
|
||
const op = $(".pop.open", s); // 先收起对话框里展开的下拉 / 日期选择器
|
||
if (op) { closePops(); ($("input, .select", op) || op).focus(); return; }
|
||
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); } });
|
||
}
|
||
|
||
/* ---------- 日期选择(§7.22) ----------
|
||
* 周一开始;非本月的格子留空;不可选日期(opt.min 之前)禁用灰、无悬停;今天描边;关键日期(opt.marks)下方小圆点。
|
||
* 日期键统一写成不补零的「2026-11-3」。 */
|
||
const WD = ["一", "二", "三", "四", "五", "六", "日"];
|
||
const TODAY = new Date(2026, 10, 3), TODAY_KEY = "2026-11-3"; // 演示里的「今天」
|
||
const pad2 = (n) => String(n).padStart(2, "0");
|
||
const fig = (v) => `<span class="fig">${v}</span>`;
|
||
const keyOf = (d) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||
function calHTML(y, m, sel, opt = {}) {
|
||
const start = (new Date(y, m, 1).getDay() + 6) % 7, days = new Date(y, m + 1, 0).getDate();
|
||
let cells = "";
|
||
for (let i = 0, n = Math.ceil((start + days) / 7) * 7; i < n; i++) {
|
||
const d = i - start + 1;
|
||
if (d < 1 || d > days) { cells += '<span class="pad" aria-hidden="true"></span>'; continue; }
|
||
const date = new Date(y, m, d), key = keyOf(date);
|
||
const cls = [key === (opt.today || TODAY_KEY) ? "today" : "", (opt.marks || []).includes(key) ? "mark" : ""].join(" ").trim();
|
||
cells += `<button type="button" class="${cls}" data-d="${key}" aria-selected="${key === sel}" ${opt.min && date < opt.min ? "disabled" : ""} tabindex="-1">${d}</button>`;
|
||
}
|
||
const note = opt.note !== undefined ? opt.note : (opt.marks || []).length ? "圆点是本项目的关键日期" : "";
|
||
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="时间(24 小时制)" inputmode="numeric">` : `<span>${note}</span>`}<button type="button" class="btn sm text" data-today>今天</button></div>`;
|
||
}
|
||
/* 只有日历、没有输入框的陈列(.pop.d-open[data-date]) */
|
||
function initDate(p) {
|
||
if (p.dataset.ready) return;
|
||
p.dataset.ready = 1;
|
||
const cal = $(".cal", p);
|
||
let [y, m] = (p.dataset.month || p.dataset.value || "2026-11").split("-").map(Number); m -= 1;
|
||
let sel = p.dataset.value ? keyOf(parseDT(p.dataset.value, false).dt) : "";
|
||
const opt = { marks: (p.dataset.marks || "").split(",").filter(Boolean).map((k) => keyOf(parseDT(k, false).dt)), time: p.dataset.time, min: p.dataset.min ? parseDT(p.dataset.min, false).dt : null };
|
||
const render = () => { cal.innerHTML = calHTML(y, m, sel, opt); };
|
||
render();
|
||
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 = TODAY_KEY; }
|
||
else if (b.dataset.d) sel = b.dataset.d;
|
||
else return;
|
||
render();
|
||
});
|
||
}
|
||
|
||
/* ---------- 日期输入(Q10):.dt[data-dt] ----------
|
||
* <div class="field"><label for="x">投标截止 <small>(必填)</small></label>
|
||
* <div class="dt" data-dt data-time="09:30" data-left data-req="请填写投标截止时间">
|
||
* <input class="input" id="x" placeholder="例如:2026-11-20 09:30" autocomplete="off">
|
||
* <button type="button" class="icon-btn sm" aria-label="打开日期选择器" aria-haspopup="dialog" aria-expanded="false"><i data-ic="calendar"></i></button>
|
||
* <div class="cal" role="dialog" aria-label="选择日期" hidden></div></div>
|
||
* <span class="msg" data-dt-msg>24 小时制</span></div>
|
||
* data-time:带时间(24 小时制,选择器底部填时间);data-left:下方写「· 剩 N 天」;data-req:必填,空着时的错误说明;
|
||
* data-marks / data-min / data-note:关键日期圆点 / 最早可选日期 / 选择器底部的圆点说明;类名加 d-open:选择器常开(陈列用)。
|
||
* 可直接键入,接受「2026/11/20 9:30」「2026.11.20 9:30」「2026年11月20日」等写法,离开时整理成「2026-11-20 09:30」,
|
||
* 下方 [data-dt-msg] 显示界面写法「11月20日 09:30 · 剩 17 天」。没碰过的字段离开时不报错;报错之后边输入边复查。
|
||
* 每次整理后在 .dt 上派发 dt-change(detail:{ ok, empty, val, dt, days });p.dt.commit({ required }) 供提交时调用。 */
|
||
function parseDT(raw, withTime) {
|
||
const s = String(raw || "").trim().replace(/[\/.年月]/g, "-").replace(/日/g, "").replace(/:/g, ":").replace(/\s+/g, " ");
|
||
const eg = withTime ? "2026-11-20 09:30" : "2026-11-20";
|
||
const m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?: ?(\d{1,2}):(\d{1,2}))?$/);
|
||
if (!m) return { msg: `请按「${eg}」的格式填写` };
|
||
const [, y, mo, d, hh, mi] = m.map((v) => v === undefined ? v : +v);
|
||
const dt = new Date(y, mo - 1, d);
|
||
if (dt.getMonth() !== mo - 1 || dt.getDate() !== d) return { msg: `${y} 年 ${mo} 月没有 ${d} 日,请核对日期` };
|
||
if (withTime && hh === undefined) return { msg: `请补上时间,例如「${y}-${pad2(mo)}-${pad2(d)} 09:30」` };
|
||
if (withTime && hh > 23) return { msg: "时间用 24 小时制,小时在 00–23 之间" };
|
||
if (withTime && mi > 59) return { msg: "分钟在 00–59 之间" };
|
||
const time = withTime ? `${pad2(hh)}:${pad2(mi)}` : "";
|
||
const val = `${y}-${pad2(mo)}-${pad2(d)}` + (withTime ? ` ${time}` : "");
|
||
const days = Math.round((dt - TODAY) / 864e5);
|
||
const disp = `${y !== TODAY.getFullYear() ? fig(y) + "年" : ""}${fig(mo)}月${fig(d)}日` + (withTime ? ` ${fig(time)}` : "");
|
||
return { val, dt, days, disp, key: keyOf(dt), time };
|
||
}
|
||
/* 字段错误(§7.13):赭色边框 + 下方「!」说明;其他说明和提醒暂时隐藏。msg 为空时清除。返回是否无错 */
|
||
let uid = 0;
|
||
function fieldError(f, msg) {
|
||
if (!f) return !msg;
|
||
f.classList.toggle("err", !!msg);
|
||
const inp = $("input:not([type=checkbox]):not([type=radio]), textarea", f); if (inp) inp.toggleAttribute("aria-invalid", !!msg);
|
||
let m = $(":scope > .msg.e", f);
|
||
if (msg) {
|
||
if (!m) { m = document.createElement("span"); m.className = "msg e"; m.id = (f.id || "fld" + ++uid) + "-err"; f.appendChild(m); }
|
||
m.innerHTML = `<span class="ico w" aria-hidden="true">!</span>${msg}`;
|
||
$$(":scope > .msg:not(.e), :scope > .hint-warn", f).forEach((x) => { x.hidden = true; });
|
||
if (inp) inp.setAttribute("aria-describedby", m.id);
|
||
} else {
|
||
if (m) m.remove();
|
||
$$(":scope > .msg, :scope > .hint-warn:not([data-off])", f).forEach((x) => { x.hidden = false; });
|
||
const d = $(":scope > .msg[id]", f); if (inp) { if (d) inp.setAttribute("aria-describedby", d.id); else inp.removeAttribute("aria-describedby"); }
|
||
}
|
||
return !msg;
|
||
}
|
||
function dateInput(p) {
|
||
if (p.dataset.ready) return;
|
||
p.dataset.ready = 1; p.classList.add("pop");
|
||
const inp = $("input", p), btn = $(":scope > .icon-btn", p), cal = $(".cal", p), f = p.closest(".field");
|
||
const msg = f && $("[data-dt-msg]", f), def = msg ? msg.innerHTML : "";
|
||
if (msg && !msg.id) msg.id = (inp.id || "dt" + ++uid) + "-msg";
|
||
if (msg) inp.setAttribute("aria-describedby", msg.id);
|
||
const withTime = p.dataset.time !== undefined, still = p.classList.contains("d-open");
|
||
const opt = { marks: (p.dataset.marks || "").split(",").filter(Boolean).map((k) => keyOf(parseDT(k, false).dt)), min: p.dataset.min ? parseDT(p.dataset.min, false).dt : null, note: p.dataset.note };
|
||
let y = TODAY.getFullYear(), m = TODAY.getMonth(), sel = "", time = p.dataset.time || "09:00", dirty = false;
|
||
const left = (r) => p.dataset.left === undefined ? "" : r.days > 0 ? ` · 剩 ${fig(r.days)} 天` : r.days === 0 ? " · 今天截止" : " · 已截止";
|
||
const sync = () => { const r = parseDT(inp.value, withTime); if (r.val) { sel = r.key; y = r.dt.getFullYear(); m = r.dt.getMonth(); if (r.time) time = r.time; } };
|
||
const render = () => { cal.innerHTML = calHTML(y, m, sel, { ...opt, time: withTime ? time : undefined }); };
|
||
function commit(o = {}) {
|
||
if (inp.disabled) { fieldError(f, ""); return true; }
|
||
const v = inp.value.trim(), r = v ? parseDT(v, withTime) : {};
|
||
let err = "";
|
||
if (!v) { err = o.required && p.dataset.req ? p.dataset.req : ""; if (msg) msg.innerHTML = def; }
|
||
else if (r.msg) err = r.msg;
|
||
else { inp.value = r.val; if (msg) msg.innerHTML = r.disp + left(r); }
|
||
fieldError(f, err);
|
||
p.dispatchEvent(new CustomEvent("dt-change", { bubbles: true, detail: { ok: !err, empty: !v, ...r } }));
|
||
return !err;
|
||
}
|
||
p.dt = { commit, parse: () => parseDT(inp.value, withTime) };
|
||
const close = (focus) => { p.classList.remove("open", "up"); cal.hidden = true; btn.setAttribute("aria-expanded", "false"); if (focus) inp.focus(); };
|
||
const open = () => {
|
||
closePops(p); closeFloatMenu();
|
||
sync(); render(); p.classList.add("open"); cal.hidden = false; btn.setAttribute("aria-expanded", "true");
|
||
const r = p.getBoundingClientRect();
|
||
p.classList.toggle("up", r.bottom + cal.offsetHeight + 8 > innerHeight && r.top > cal.offsetHeight + 8);
|
||
($("[aria-selected=true]", cal) || $(".today", cal) || $(".cal-grid button:not([disabled])", cal)).focus();
|
||
};
|
||
const write = () => { if (!sel) return; const [a, b, c] = sel.split("-"); inp.value = `${a}-${pad2(b)}-${pad2(c)}` + (withTime ? ` ${time}` : ""); dirty = true; commit(); };
|
||
if (still) { sync(); render(); } else cal.hidden = true;
|
||
if (inp.value.trim()) commit();
|
||
btn.addEventListener("click", (e) => { e.stopPropagation(); if (still) return; p.classList.contains("open") ? close(true) : open(); });
|
||
cal.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const b = e.target.closest("button"); if (!b || b.disabled) 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 = TODAY.getFullYear(); m = TODAY.getMonth(); sel = TODAY_KEY; }
|
||
else if (b.dataset.d) sel = b.dataset.d; else return;
|
||
render(); write();
|
||
if (!still && !withTime) close(true);
|
||
else ($(`[data-d="${sel}"]`, cal) || b).focus();
|
||
});
|
||
cal.addEventListener("input", (e) => {
|
||
if (!e.target.classList.contains("time")) return;
|
||
const t = e.target.value.trim().replace(":", ":").match(/^(\d{1,2}):(\d{2})$/);
|
||
if (t && +t[1] < 24 && +t[2] < 60) { time = `${pad2(t[1])}:${t[2]}`; write(); }
|
||
});
|
||
cal.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && !still) { e.stopPropagation(); close(true); return; }
|
||
if (e.key === "Enter" && e.target.classList.contains("time")) { e.preventDefault(); if (!still) close(true); return; }
|
||
const cells = $$(".cal-grid button:not([disabled])", cal), i = cells.indexOf(document.activeElement);
|
||
const d = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
|
||
if (i >= 0 && d) { e.preventDefault(); (cells[i + d] || cells[i]).focus(); }
|
||
});
|
||
inp.addEventListener("keydown", (e) => { if (e.key === "ArrowDown" && e.altKey) { e.preventDefault(); open(); } });
|
||
inp.addEventListener("input", () => {
|
||
dirty = true;
|
||
if (f && f.classList.contains("err")) { const v = inp.value.trim(); if (v ? !parseDT(v, withTime).msg : !p.dataset.req) fieldError(f, ""); }
|
||
});
|
||
inp.addEventListener("blur", (e) => {
|
||
if (e.relatedTarget && p.contains(e.relatedTarget)) return; // 焦点进了日期选择器
|
||
if (dirty || (f && f.classList.contains("err"))) commit({ required: true });
|
||
});
|
||
}
|
||
|
||
/* ---------- 对照布局(§5.3):拖动或用 ← → 调整原文查看器宽度;清单不窄于 --list-min,查看器不窄于 --viewer-min ---------- */
|
||
function cmp(el) {
|
||
if (el.dataset.ready) return;
|
||
el.dataset.ready = 1;
|
||
const r = $(":scope > .rsz", el); if (!r) return;
|
||
const num = (v) => parseFloat(getComputedStyle(el).getPropertyValue(v)) || 0;
|
||
const box = () => { const b = el.getBoundingClientRect(), cs = getComputedStyle(el); return { right: b.right - parseFloat(cs.paddingRight), w: b.width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), gap: r.offsetWidth }; };
|
||
const cur = () => $(":scope > .viewer", el).offsetWidth;
|
||
function setW(px) {
|
||
const { w, gap } = box(), max = w - gap - num("--list-min");
|
||
px = Math.round(Math.max(num("--viewer-min"), Math.min(max, px)));
|
||
el.style.setProperty("--vw", px + "px");
|
||
r.setAttribute("aria-valuenow", px);
|
||
}
|
||
r.setAttribute("aria-valuemin", num("--viewer-min"));
|
||
r.addEventListener("pointerdown", (e) => {
|
||
e.preventDefault(); r.setPointerCapture(e.pointerId); r.classList.add("on"); document.body.classList.add("dragging");
|
||
const mv = (ev) => { const { right, gap } = box(); setW(right - ev.clientX - gap / 2); };
|
||
const up = () => { r.classList.remove("on"); document.body.classList.remove("dragging"); r.removeEventListener("pointermove", mv); r.removeEventListener("pointerup", up); };
|
||
r.addEventListener("pointermove", mv); r.addEventListener("pointerup", up);
|
||
});
|
||
r.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); setW(cur() + (e.key === "ArrowLeft" ? 32 : -32)); } });
|
||
addEventListener("resize", () => { if (el.style.getPropertyValue("--vw")) setW(cur()); });
|
||
}
|
||
|
||
/* ---------- 开关、分段、标签页、半选、排序、加载按钮 ---------- */
|
||
function init(root = document) {
|
||
$$(".pop[data-select], .pop[data-menu]", root).forEach(initSelect);
|
||
$$(".pop[data-date]", root).forEach(initDate);
|
||
$$(".dt[data-dt]", root).forEach(dateInput);
|
||
$$(".cmp[data-cmp]", root).forEach(cmp);
|
||
$$(".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, keysTable, KEYS, calHTML, parseDT, fieldError, dateInput, cmp, floatMenu, closeFloatMenu, menuHTML };
|
||
document.addEventListener("DOMContentLoaded", () => { hydrate(); init(); });
|
||
})();
|