/* 标博士 · 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: '', 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: '', house: '', award: '', wallet: '', "alert-circle": '', info: '', "check-circle": '', eye: '' }; 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 `; } /* ---------- 产品页面框架 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]) => `${icon(i)}${t}`).join(""); const crumbs = (opt.crumbs || []).map((c, i, a) => i < a.length - 1 && c[1] ? `${esc(c[0])}` : `${esc(c[0])}${i < a.length - 1 ? '' : ""}`).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]) => `${name}${left}`; 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", ``); const main = $("main"); main.classList.add("main"); main.insertAdjacentHTML("afterbegin", `
${ORG}${crumbs}
`); 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 === "-" ? "
" : ``).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) => `${k}`; 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) => `${list.map(([k, t]) => ``).join("")}
${k}${t}
`; 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 ? "" : ''}${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(); 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) => `${v}`; 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 += ''; 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 += ``; } const note = opt.note !== undefined ? opt.note : (opt.marks || []).length ? "圆点是本项目的关键日期" : ""; return `
${y} 年 ${m + 1} 月
${WD.map((w) => `${w}`).join("")}${cells}
${opt.time ? `时间 ` : `${note}`}
`; } /* 只有日历、没有输入框的陈列(.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] ---------- *
*
* * *
* 24 小时制
* 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 = `${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(); }); })();