dsh-question 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ // 把 src/*.js 片段 + lib/dsh-question.css 拼成宿主可直接 serve 的 CJS 工厂 bundle。
2
+ // 运行:node scripts/build.cjs
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const root = path.resolve(__dirname, "..");
7
+ const read = (rel) => fs.readFileSync(path.join(root, rel), "utf8");
8
+
9
+ const css = read("lib/dsh-question.css");
10
+ const helpers = read("src/10-helpers.js").replace("__CSS_PLACEHOLDER__", JSON.stringify(css));
11
+ const tooltip = read("src/20-tooltip.js");
12
+ const mount = read("src/30-mount.js");
13
+ const keys = read("src/40-keys.js");
14
+
15
+ const header = [
16
+ "/* dsh-question — 浏览器侧 bundle(CJS 工厂,供 DSH Web 客户端 ModuleLoader 注入)。",
17
+ " *",
18
+ " * 纯呈现层插件:不改模型可见的工具 schema,也不替换提问卡片的 React 组件,只做两件事:",
19
+ " * 1. 样式:选项行只显示重点(label),详细说明(description)收进悬停浮窗,「其他:」替掉铅笔图标;",
20
+ " * 2. 事件委托 + 固定定位浮窗:悬停/聚焦选项时按需读取 DOM 里仍在的 description 文本。",
21
+ " * 不在 React 树里插节点,所以选择、翻页、重渲染都不会把增强冲掉;",
22
+ " * apply 幂等(window.__DSH_QUESTION_CARD__ 记录上一代 disposer),HMR 重载不会叠加监听器。",
23
+ " *",
24
+ " * ⚠️ 本文件由 scripts/build.cjs 生成,不要手改;改 src/ 下的片段后重新 build。",
25
+ " */",
26
+ "window.__ModuleLoader__.load({",
27
+ ' id: "dsh-question",',
28
+ " factory: function (require) {",
29
+ " var module = { exports: {} };",
30
+ " var exports = module.exports;",
31
+ ].join("\n");
32
+
33
+ const footer = [
34
+ ' module.exports = { name: "question", inject: [], apply: apply };',
35
+ " return module.exports;",
36
+ " },",
37
+ "});",
38
+ "",
39
+ ].join("\n");
40
+
41
+ const bundle = [header, helpers, tooltip, mount, keys, footer].join("\n");
42
+ const target = path.join(root, "lib/client.js");
43
+ fs.writeFileSync(target, bundle);
44
+ console.log("built", target, bundle.length, "bytes");
@@ -0,0 +1,60 @@
1
+
2
+ "use strict";
3
+
4
+ var GLOBAL_KEY = "__DSH_QUESTION_CARD__";
5
+ var STYLE_NAME = "dsh-question/dsh-question.css";
6
+ var TIP_CLASS = "dsh-tq-tip";
7
+ var SHOW_DELAY_MS = 160;
8
+ var CSS = __CSS_PLACEHOLDER__;
9
+
10
+ function classNameOf(el) {
11
+ try {
12
+ var value = el && el.className;
13
+ return typeof value === "string" ? value : "";
14
+ } catch (error) {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ function hasToken(el, suffix) {
20
+ var classes = classNameOf(el).split(/\s+/);
21
+ for (var i = 0; i < classes.length; i += 1) {
22
+ var token = classes[i];
23
+ if (token === suffix) return true;
24
+ if (token.slice(-(suffix.length + 1)) === "_" + suffix) return true;
25
+ }
26
+ return false;
27
+ }
28
+
29
+ function textOf(el) {
30
+ return (el && el.textContent ? String(el.textContent) : "").replace(/\s+/g, " ").trim();
31
+ }
32
+
33
+ /**
34
+ * The “other answer” prefix in the active UI language. DSH keeps <html lang> in
35
+ * sync with the locale service, so the plugin follows the GUI without config.
36
+ */
37
+ function otherPrefix() {
38
+ var language = "";
39
+ try {
40
+ language = (document.documentElement.getAttribute("lang") || "").toLowerCase();
41
+ } catch (error) {
42
+ language = "";
43
+ }
44
+ if (language === "") {
45
+ try {
46
+ language = (navigator.language || "").toLowerCase();
47
+ } catch (error) {
48
+ language = "";
49
+ }
50
+ }
51
+ return language.indexOf("zh") === 0 ? "\u5176\u4ed6\uff1a" : "Other: ";
52
+ }
53
+
54
+ function closestOption(node) {
55
+ var el = node && node.nodeType === 3 ? node.parentElement : node;
56
+ if (!el || typeof el.closest !== "function") return null;
57
+ if (!el.closest("[data-question-key]")) return null;
58
+ var button = el.closest('button[role="radio"], button[role="checkbox"]');
59
+ return button && hasToken(button, "option") ? button : null;
60
+ }
@@ -0,0 +1,72 @@
1
+
2
+ function readOption(button) {
3
+ var spans = button.querySelectorAll("span");
4
+ var labelEl = null;
5
+ var descEl = null;
6
+ for (var i = 0; i < spans.length; i += 1) {
7
+ var span = spans[i];
8
+ if (labelEl === null && hasToken(span, "optionLabel")) {
9
+ labelEl = span;
10
+ } else if (descEl === null && hasToken(span, "description")) {
11
+ descEl = span;
12
+ }
13
+ }
14
+ if (labelEl === null) return null;
15
+ var clamped = labelEl.scrollHeight > labelEl.clientHeight + 1;
16
+ var description = descEl === null ? "" : textOf(descEl);
17
+ if (!description && !clamped) return null;
18
+ return { label: textOf(labelEl), description: description, clamped: clamped };
19
+ }
20
+
21
+ function createTip() {
22
+ var tip = document.createElement("div");
23
+ tip.className = TIP_CLASS;
24
+ tip.setAttribute("role", "tooltip");
25
+ tip.setAttribute("data-show", "0");
26
+ return tip;
27
+ }
28
+
29
+ function renderTip(tip, data) {
30
+ tip.textContent = "";
31
+ if (data.clamped && data.label) {
32
+ var title = document.createElement("div");
33
+ title.className = "dsh-tq-tip__title";
34
+ title.textContent = data.label;
35
+ tip.appendChild(title);
36
+ }
37
+ if (data.description) {
38
+ var body = document.createElement("div");
39
+ body.className = "dsh-tq-tip__body";
40
+ body.textContent = data.description;
41
+ tip.appendChild(body);
42
+ }
43
+ }
44
+
45
+ function placeTip(tip, button) {
46
+ tip.style.left = "0px";
47
+ tip.style.top = "0px";
48
+ var rect = button.getBoundingClientRect();
49
+ var tipRect = tip.getBoundingClientRect();
50
+ var gap = 8;
51
+ var viewportWidth = window.innerWidth || document.documentElement.clientWidth;
52
+ var viewportHeight = window.innerHeight || document.documentElement.clientHeight;
53
+ var top = rect.top - tipRect.height - gap;
54
+ if (top < 8) top = rect.bottom + gap;
55
+ if (top + tipRect.height > viewportHeight - 8) top = Math.max(8, viewportHeight - tipRect.height - 8);
56
+ var left = Math.min(rect.left, viewportWidth - tipRect.width - 12);
57
+ if (left < 12) left = 12;
58
+ tip.style.left = Math.round(left) + "px";
59
+ tip.style.top = Math.round(top) + "px";
60
+ }
61
+
62
+ function injectStyle() {
63
+ var selector = "style[data-plugin-css=" + JSON.stringify(STYLE_NAME) + "]";
64
+ var existing = document.querySelector(selector);
65
+ if (existing !== null) return existing;
66
+ var tag = document.createElement("style");
67
+ tag.dataset.plugin = "dsh-question";
68
+ tag.dataset.pluginCss = STYLE_NAME;
69
+ tag.textContent = CSS;
70
+ document.head.appendChild(tag);
71
+ return tag;
72
+ }
@@ -0,0 +1,156 @@
1
+
2
+ function apply(ctx) {
3
+ var previous = window[GLOBAL_KEY];
4
+ if (previous && typeof previous.dispose === "function") {
5
+ try {
6
+ previous.dispose();
7
+ } catch (error) {
8
+ /* a previous generation is already gone */
9
+ }
10
+ }
11
+
12
+ var disposers = [];
13
+ var disposed = false;
14
+ var tip = createTip();
15
+ var styleTag = injectStyle();
16
+ var currentButton = null;
17
+ var visible = false;
18
+ var showTimer = 0;
19
+
20
+ var mountRoot = document.body || document.documentElement;
21
+ mountRoot.appendChild(tip);
22
+
23
+ function syncOtherPrefix() {
24
+ try {
25
+ document.documentElement.style.setProperty("--dsh-question-other-prefix", JSON.stringify(otherPrefix()));
26
+ } catch (error) {
27
+ /* non-browser environments: the CSS fallback still applies */
28
+ }
29
+ }
30
+ syncOtherPrefix();
31
+ var languageObserver = null;
32
+ if (typeof MutationObserver === "function") {
33
+ languageObserver = new MutationObserver(syncOtherPrefix);
34
+ languageObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] });
35
+ }
36
+ disposers.push(function () {
37
+ if (languageObserver !== null) languageObserver.disconnect();
38
+ document.documentElement.style.removeProperty("--dsh-question-other-prefix");
39
+ });
40
+ disposers.push(function () {
41
+ if (tip.parentNode !== null) tip.parentNode.removeChild(tip);
42
+ });
43
+ disposers.push(function () {
44
+ if (styleTag.parentNode !== null) styleTag.parentNode.removeChild(styleTag);
45
+ });
46
+
47
+ function cancel() {
48
+ if (showTimer !== 0) {
49
+ window.clearTimeout(showTimer);
50
+ showTimer = 0;
51
+ }
52
+ currentButton = null;
53
+ if (visible) {
54
+ visible = false;
55
+ tip.setAttribute("data-show", "0");
56
+ }
57
+ }
58
+
59
+ function showFor(button, immediate) {
60
+ if (disposed || button === currentButton) return;
61
+ cancel();
62
+ var data = readOption(button);
63
+ if (data === null) return;
64
+ currentButton = button;
65
+ var fire = function () {
66
+ showTimer = 0;
67
+ if (disposed || currentButton !== button || !button.isConnected) return;
68
+ renderTip(tip, data);
69
+ placeTip(tip, button);
70
+ visible = true;
71
+ tip.setAttribute("data-show", "1");
72
+ };
73
+ if (immediate) fire();
74
+ else showTimer = window.setTimeout(fire, SHOW_DELAY_MS);
75
+ }
76
+
77
+ function onMouseOver(event) {
78
+ var button = closestOption(event.target);
79
+ if (button === null) {
80
+ if (currentButton !== null && !(event.relatedTarget && currentButton.contains(event.relatedTarget))) cancel();
81
+ return;
82
+ }
83
+ showFor(button, false);
84
+ }
85
+
86
+ function onMouseOut(event) {
87
+ var button = closestOption(event.target);
88
+ if (button === null) return;
89
+ if (event.relatedTarget && button.contains(event.relatedTarget)) return;
90
+ if (button === currentButton) cancel();
91
+ }
92
+
93
+ function onFocusIn(event) {
94
+ var button = closestOption(event.target);
95
+ if (button !== null) showFor(button, true);
96
+ }
97
+
98
+ function onFocusOut(event) {
99
+ var button = closestOption(event.target);
100
+ if (button !== null && button === currentButton) cancel();
101
+ }
102
+
103
+ function onClick(event) {
104
+ if (closestOption(event.target) !== null) cancel();
105
+ }
106
+
107
+ function onViewportChange() {
108
+ cancel();
109
+ }
110
+
111
+ document.addEventListener("mouseover", onMouseOver, true);
112
+ document.addEventListener("mouseout", onMouseOut, true);
113
+ document.addEventListener("focusin", onFocusIn, true);
114
+ document.addEventListener("focusout", onFocusOut, true);
115
+ document.addEventListener("click", onClick, true);
116
+ document.addEventListener("scroll", onViewportChange, true);
117
+ window.addEventListener("resize", onViewportChange, true);
118
+
119
+ disposers.push(installKeyboard());
120
+
121
+ disposers.push(function () {
122
+ document.removeEventListener("mouseover", onMouseOver, true);
123
+ document.removeEventListener("mouseout", onMouseOut, true);
124
+ document.removeEventListener("focusin", onFocusIn, true);
125
+ document.removeEventListener("focusout", onFocusOut, true);
126
+ document.removeEventListener("click", onClick, true);
127
+ document.removeEventListener("scroll", onViewportChange, true);
128
+ window.removeEventListener("resize", onViewportChange, true);
129
+ });
130
+
131
+ function dispose() {
132
+ if (disposed) return;
133
+ disposed = true;
134
+ cancel();
135
+ for (var i = disposers.length - 1; i >= 0; i -= 1) {
136
+ try {
137
+ disposers[i]();
138
+ } catch (error) {
139
+ /* keep releasing the rest */
140
+ }
141
+ }
142
+ disposers.length = 0;
143
+ if (window[GLOBAL_KEY] && window[GLOBAL_KEY].dispose === dispose) delete window[GLOBAL_KEY];
144
+ }
145
+
146
+ if (ctx && typeof ctx.effect === "function") {
147
+ try {
148
+ ctx.effect(function () {
149
+ return dispose;
150
+ }, "dsh-question: hover tooltip");
151
+ } catch (error) {
152
+ /* older client runners: the window guard still keeps HMR idempotent */
153
+ }
154
+ }
155
+ window[GLOBAL_KEY] = { dispose: dispose };
156
+ }
package/src/40-keys.js ADDED
@@ -0,0 +1,125 @@
1
+
2
+ /** Whether a node is a text-entry surface where plain keys must stay untouched. */
3
+ function isTypingTarget(node) {
4
+ var el = node && node.nodeType === 3 ? node.parentElement : node;
5
+ if (!el || typeof el.closest !== "function") return false;
6
+ return el.closest("input, textarea, select, [contenteditable=\"true\"], [contenteditable=\"\"]") !== null;
7
+ }
8
+
9
+ /** The visible question card, or null when none is mounted. */
10
+ function visibleQuestionCard() {
11
+ var cards = document.querySelectorAll("[data-question-key]");
12
+ for (var i = 0; i < cards.length; i += 1) {
13
+ var card = cards[i];
14
+ if (card.isConnected && card.getClientRects().length > 0) return card;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ /** Option buttons in display order (single-select radios or multi-select checkboxes). */
20
+ function optionButtonsOf(card) {
21
+ return card.querySelectorAll('button[role="radio"], button[role="checkbox"]');
22
+ }
23
+
24
+ /** Pager buttons in order: [previous, next]; empty when the batch has one question. */
25
+ function pagerButtonsOf(card) {
26
+ var pager = card.querySelector('[class*="_pager"]');
27
+ if (pager === null) return [];
28
+ return pager.querySelectorAll("button");
29
+ }
30
+
31
+ /** Move one question backward (-1) or forward (1) through the card's own pager. */
32
+ function stepQuestion(card, delta) {
33
+ var buttons = pagerButtonsOf(card);
34
+ if (buttons.length === 0) return false;
35
+ var button = delta < 0 ? buttons[0] : buttons[buttons.length - 1];
36
+ if (button.disabled) return false;
37
+ button.click();
38
+ return true;
39
+ }
40
+
41
+ /** The primary footer action (submit / next). Last button inside the action group. */
42
+ function primaryActionOf(card) {
43
+ var group = card.querySelector('[class*="_footerActions"]');
44
+ if (group === null) return null;
45
+ var buttons = group.querySelectorAll("button");
46
+ return buttons.length === 0 ? null : buttons[buttons.length - 1];
47
+ }
48
+
49
+ /** The collapse control: the header icon button carrying aria-expanded. */
50
+ function collapseControlOf(card) {
51
+ var header = card.querySelector("header");
52
+ if (header === null) return null;
53
+ var buttons = header.querySelectorAll("button");
54
+ for (var i = 0; i < buttons.length; i += 1) {
55
+ if (buttons[i].hasAttribute("aria-expanded")) return buttons[i];
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /** Select the nth option by clicking it, so every native side effect stays intact. */
61
+ function clickOption(card, index) {
62
+ var options = optionButtonsOf(card);
63
+ if (index < 0 || index >= options.length) return false;
64
+ var button = options[index];
65
+ if (button.disabled) return false;
66
+ button.click();
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * Keyboard shortcuts for the open question card: 1-9 pick an option, Enter runs
72
+ * the primary footer action, Escape collapses the card (never cancels a question).
73
+ * Text entry surfaces are left completely alone, so typing a digit or Enter into
74
+ * the custom answer behaves natively.
75
+ */
76
+ function installKeyboard() {
77
+ function onKeyDown(event) {
78
+ if (event.defaultPrevented || event.isComposing) return;
79
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
80
+ if (isTypingTarget(event.target)) return;
81
+ var card = visibleQuestionCard();
82
+ if (card === null) return;
83
+ var key = event.key;
84
+ if (key !== null && key.length === 1 && key >= "1" && key <= "9") {
85
+ if (clickOption(card, key.charCodeAt(0) - 49)) {
86
+ event.preventDefault();
87
+ event.stopPropagation();
88
+ }
89
+ return;
90
+ }
91
+ if (key === "ArrowLeft" || key === "PageUp") {
92
+ if (stepQuestion(card, -1)) {
93
+ event.preventDefault();
94
+ event.stopPropagation();
95
+ }
96
+ return;
97
+ }
98
+ if (key === "ArrowRight" || key === "PageDown") {
99
+ if (stepQuestion(card, 1)) {
100
+ event.preventDefault();
101
+ event.stopPropagation();
102
+ }
103
+ return;
104
+ }
105
+ if (key === "Enter") {
106
+ var action = primaryActionOf(card);
107
+ if (action === null || action.disabled) return;
108
+ event.preventDefault();
109
+ event.stopPropagation();
110
+ action.click();
111
+ return;
112
+ }
113
+ if (key === "Escape") {
114
+ var collapse = collapseControlOf(card);
115
+ if (collapse === null || collapse.disabled) return;
116
+ event.preventDefault();
117
+ event.stopPropagation();
118
+ collapse.click();
119
+ }
120
+ }
121
+ document.addEventListener("keydown", onKeyDown, true);
122
+ return function () {
123
+ document.removeEventListener("keydown", onKeyDown, true);
124
+ };
125
+ }