docs(design): 按 v0.2 重刷全部 UI demo,确认 Q1–Q13 与 U 键规则

- ui-demo:控件、表单、卡片、列表、工作台、读标六页按 v0.2 重刷;侧栏与顶栏框架、项目行、署名行、可键入日期输入、对照布局等收回 demo.css / demo.js 共用
- system:tokens 补悬停 / 按下 / 遮罩色、数字字阶与原文排版变量;components.css 按 v0.2 改写并清除裸色值
- design-system.md:写入 Q1–Q13 答案,新增开关、分页、下拉、日期选择等组件规则,类别标签、待确认区、选中态、对照布局按 v0.2 修订
- Q13:额度流水记录 0 点的「免扣」行,FR-M6-03 与领域模型同步;U 键改为撤销当前条
- decisions.md 记录 Q1–Q12、Q13 与 U 键;AGENTS.md 同步进度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-03 16:07:15 +08:00
parent 067cad2120
commit 7f9592ad16
16 changed files with 2625 additions and 1252 deletions

View File

@@ -1,5 +1,8 @@
/* 标博士 · UI demo 公共脚本:图标、页面框架、轻提示、对话框、下拉、日期、开关、标签页、排序。
* 只演示交互,不连后端。正式实现里这些交互件由 Reka UI 打底(AR10)。 */
/* 标博士 · 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)];
@@ -41,7 +44,14 @@
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"/>'
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) {
@@ -61,35 +71,116 @@
<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("");
/* ---------- 产品页面框架 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"><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>`);
<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">${crumbs}</div>
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" 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>
<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 条,更新 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>` });
/* ---------- 浮动菜单:列表行的「更多」等(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 秒消失,悬停暂停 ---------- */
@@ -119,7 +210,12 @@
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 === "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;
@@ -172,54 +268,199 @@
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 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();
const start = (new Date(y, m, 1).getDay() + 6) % 7, days = new Date(y, m + 1, 0).getDate();
let cells = "";
for (let i = 0; i < 42; i++) {
for (let i = 0, n = Math.ceil((start + days) / 7) * 7; i < n; 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;
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="时间">` : '<span>点圆点的日期是本项目的关键日期</span>'}<button type="button" class="btn sm text" data-today>今天</button></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 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 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); };
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; }
if (b.dataset.today) { y = 2026; m = 10; sel = TODAY_KEY; }
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) }));
});
}
/* ---------- 日期输入(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" ? "开" : "关"; };
@@ -270,6 +511,6 @@
});
}
window.Demo = { $, $$, esc, icon, hydrate, demoTop, shell, toast, dialog, init, sortable, keysDialog, calHTML };
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(); });
})();