agentcss 0.0.0-stage → 0.11.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.
- package/01-tokens/README.md +236 -0
- package/01-tokens/behavior/dismissable.js +132 -0
- package/01-tokens/behavior/emit.js +76 -0
- package/01-tokens/behavior/focus-return.js +81 -0
- package/01-tokens/behavior/roving.js +152 -0
- package/01-tokens/behavior/typeahead.js +124 -0
- package/01-tokens/focus-ring-demo.html +133 -0
- package/01-tokens/focus-ring.css +102 -0
- package/01-tokens/page.css +244 -0
- package/01-tokens/polyfill.js +116 -0
- package/01-tokens/responsive-demo.html +171 -0
- package/01-tokens/scrollbar-demo.html +194 -0
- package/01-tokens/scrollbar.css +179 -0
- package/01-tokens/theme-toggle.js +103 -0
- package/01-tokens/tokens.css +972 -0
- package/01-tokens/typography.css +156 -0
- package/02-primitives/badge/README.md +71 -0
- package/02-primitives/badge/badge.css +141 -0
- package/02-primitives/badge/demo.html +101 -0
- package/02-primitives/button/README.md +99 -0
- package/02-primitives/button/button.css +353 -0
- package/02-primitives/button/demo.html +200 -0
- package/02-primitives/card/README.md +145 -0
- package/02-primitives/card/card.css +246 -0
- package/02-primitives/card/demo.html +170 -0
- package/02-primitives/choice/README.md +161 -0
- package/02-primitives/choice/choice.css +395 -0
- package/02-primitives/choice/demo.html +277 -0
- package/02-primitives/combobox/README.md +79 -0
- package/02-primitives/combobox/combobox.css +223 -0
- package/02-primitives/combobox/combobox.js +485 -0
- package/02-primitives/combobox/demo.html +109 -0
- package/02-primitives/date-range/README.md +100 -0
- package/02-primitives/date-range/date-range.css +206 -0
- package/02-primitives/date-range/date-range.js +301 -0
- package/02-primitives/date-range/demo.html +104 -0
- package/02-primitives/input/README.md +170 -0
- package/02-primitives/input/demo.html +246 -0
- package/02-primitives/input/input.css +239 -0
- package/02-primitives/popover/README.md +80 -0
- package/02-primitives/popover/demo.html +150 -0
- package/02-primitives/popover/popover.css +187 -0
- package/02-primitives/popover/popover.js +331 -0
- package/02-primitives/progress/README.md +82 -0
- package/02-primitives/progress/demo.html +154 -0
- package/02-primitives/progress/progress.css +172 -0
- package/02-primitives/select/README.md +95 -0
- package/02-primitives/select/demo.html +121 -0
- package/02-primitives/select/select.css +151 -0
- package/02-primitives/select/select.js +549 -0
- package/02-primitives/separator/README.md +63 -0
- package/02-primitives/separator/demo.html +84 -0
- package/02-primitives/separator/separator.css +73 -0
- package/02-primitives/skeleton/README.md +69 -0
- package/02-primitives/skeleton/demo.html +110 -0
- package/02-primitives/skeleton/skeleton.css +96 -0
- package/02-primitives/switch/README.md +65 -0
- package/02-primitives/switch/demo.html +115 -0
- package/02-primitives/switch/switch.css +370 -0
- package/03-patterns/accordion/README.md +121 -0
- package/03-patterns/accordion/accordion.css +453 -0
- package/03-patterns/accordion/accordion.js +369 -0
- package/03-patterns/accordion/demo.html +223 -0
- package/03-patterns/content/README.md +103 -0
- package/03-patterns/content/content.css +286 -0
- package/03-patterns/content/demo.html +88 -0
- package/03-patterns/drawer/README.md +79 -0
- package/03-patterns/drawer/demo.html +101 -0
- package/03-patterns/drawer/drawer.css +129 -0
- package/03-patterns/drawer/drawer.js +258 -0
- package/03-patterns/dropdown/README.md +78 -0
- package/03-patterns/dropdown/demo.html +101 -0
- package/03-patterns/dropdown/dropdown.css +135 -0
- package/03-patterns/dropdown/dropdown.js +451 -0
- package/03-patterns/form-validation/README.md +147 -0
- package/03-patterns/form-validation/demo.html +280 -0
- package/03-patterns/form-validation/form-validation.css +263 -0
- package/03-patterns/list/README.md +150 -0
- package/03-patterns/list/demo.html +225 -0
- package/03-patterns/list/flip.js +201 -0
- package/03-patterns/list/list.css +162 -0
- package/03-patterns/nav/README.md +225 -0
- package/03-patterns/nav/demo.html +131 -0
- package/03-patterns/nav/nav.css +314 -0
- package/03-patterns/nav/nav.js +430 -0
- package/03-patterns/nav/toc.js +111 -0
- package/03-patterns/overlay/README.md +196 -0
- package/03-patterns/overlay/demo.html +241 -0
- package/03-patterns/overlay/overlay.css +319 -0
- package/03-patterns/overlay/overlay.js +563 -0
- package/03-patterns/pagination/README.md +114 -0
- package/03-patterns/pagination/demo.html +165 -0
- package/03-patterns/pagination/pagination.css +195 -0
- package/03-patterns/pagination/pagination.js +395 -0
- package/03-patterns/reveal/README.md +91 -0
- package/03-patterns/reveal/demo.html +106 -0
- package/03-patterns/reveal/reveal.css +93 -0
- package/03-patterns/reveal/reveal.js +172 -0
- package/03-patterns/states/README.md +141 -0
- package/03-patterns/states/demo.html +240 -0
- package/03-patterns/states/states.css +252 -0
- package/03-patterns/tabs/README.md +84 -0
- package/03-patterns/tabs/demo.html +139 -0
- package/03-patterns/tabs/tabs.css +172 -0
- package/03-patterns/tabs/tabs.js +307 -0
- package/03-patterns/tooltip/README.md +96 -0
- package/03-patterns/tooltip/demo.html +78 -0
- package/03-patterns/tooltip/tooltip.css +89 -0
- package/03-patterns/tooltip/tooltip.js +288 -0
- package/03-patterns/tree/README.md +94 -0
- package/03-patterns/tree/demo.html +138 -0
- package/03-patterns/tree/tree.css +113 -0
- package/03-patterns/tree/tree.js +456 -0
- package/04-recipes/analysis-report/README.md +80 -0
- package/04-recipes/analysis-report/analysis-report.css +187 -0
- package/04-recipes/analysis-report/demo.html +274 -0
- package/04-recipes/data-showcase/README.md +45 -0
- package/04-recipes/data-showcase/demo.html +247 -0
- package/04-recipes/detail-page/README.md +93 -0
- package/04-recipes/detail-page/demo.html +55 -0
- package/04-recipes/detail-page/detail-page.css +190 -0
- package/04-recipes/detail-page/full.html +60 -0
- package/04-recipes/detail-page/null.html +60 -0
- package/04-recipes/detail-page/partial.html +60 -0
- package/04-recipes/detail-page/verify-skeleton.py +19 -0
- package/04-recipes/longform/README.md +93 -0
- package/04-recipes/longform/agc-memory.svg +4 -0
- package/04-recipes/longform/content.html +610 -0
- package/04-recipes/longform/content.md +51 -0
- package/04-recipes/longform/longform.html +627 -0
- package/04-recipes/report/README.md +176 -0
- package/04-recipes/report/demo.html +355 -0
- package/04-recipes/report/report.css +364 -0
- package/04-recipes/table/README.md +103 -0
- package/04-recipes/table/demo.html +455 -0
- package/04-recipes/table/table.css +372 -0
- package/04-recipes/tierA-tokens.css +189 -0
- package/09-assets/README.md +74 -0
- package/09-assets/bar/README.md +94 -0
- package/09-assets/bar/bar.css +166 -0
- package/09-assets/bar/bar.js +320 -0
- package/09-assets/bar/demo.html +176 -0
- package/09-assets/echarts-adapter/demo.html +192 -0
- package/09-assets/echarts-adapter/echarts-adapter.js +277 -0
- package/09-assets/model-viewer/README.md +155 -0
- package/09-assets/model-viewer/demo.html +105 -0
- package/09-assets/model-viewer/model-viewer.css +143 -0
- package/09-assets/model-viewer/model-viewer.js +423 -0
- package/09-assets/scientific-plot/README.md +94 -0
- package/09-assets/scientific-plot/demo.html +252 -0
- package/09-assets/scientific-plot/scientific-plot.css +141 -0
- package/09-assets/scientific-plot/scientific-plot.js +610 -0
- package/09-assets/sparkline/README.md +153 -0
- package/09-assets/sparkline/demo.html +163 -0
- package/09-assets/sparkline/sparkline.css +158 -0
- package/09-assets/sparkline/sparkline.js +508 -0
- package/API.md +332 -0
- package/CHANGELOG.md +3116 -0
- package/CODE_OF_CONDUCT.md +64 -0
- package/CONTRIBUTING.md +89 -0
- package/ERRORS.md +1658 -0
- package/LICENSE +21 -0
- package/README.md +491 -2
- package/SECURITY.md +72 -0
- package/START-HERE.md +164 -0
- package/SUPPORT.md +109 -0
- package/adapters/presentation/README.md +113 -0
- package/adapters/presentation/check.js +101 -0
- package/adapters/presentation/deck.schema.json +74 -0
- package/adapters/presentation/to-doc.js +138 -0
- package/ai/START-HERE.md +104 -0
- package/ai/behaviors.json +380 -0
- package/ai/behaviors.schema.json +61 -0
- package/ai/capabilities.json +56 -0
- package/ai/cli.js +387 -0
- package/ai/components.json +1944 -0
- package/ai/components.meta.json +168 -0
- package/ai/components.schema.json +220 -0
- package/ai/contract.schema.json +147 -0
- package/ai/deprecations.json +55 -0
- package/ai/deprecations.schema.json +85 -0
- package/ai/motion-policy.json +22 -0
- package/ai/patch.js +323 -0
- package/ai/patch.schema.json +63 -0
- package/ai/profiles.json +92 -0
- package/ai/tokens.audience.json +55 -0
- package/ai/tokens.audience.schema.json +73 -0
- package/ai/tokens.json +762 -0
- package/ai/tokens.schema.json +58 -0
- package/ai/tokens.tree.json +752 -0
- package/ai/tokens.tree.schema.json +56 -0
- package/ai/validate.js +383 -0
- package/dist/01-tokens/behavior/dismissable.js +60 -0
- package/dist/01-tokens/behavior/emit.js +21 -0
- package/dist/01-tokens/behavior/focus-return.js +31 -0
- package/dist/01-tokens/behavior/roving.js +77 -0
- package/dist/01-tokens/behavior/typeahead.js +61 -0
- package/dist/01-tokens/focus-ring.css +1 -0
- package/dist/01-tokens/page.css +1 -0
- package/dist/01-tokens/polyfill.js +78 -0
- package/dist/01-tokens/scrollbar.css +1 -0
- package/dist/01-tokens/theme-toggle.js +61 -0
- package/dist/01-tokens/tokens.css +1 -0
- package/dist/01-tokens/typography.css +1 -0
- package/dist/02-primitives/badge/badge.css +1 -0
- package/dist/02-primitives/button/button.css +1 -0
- package/dist/02-primitives/card/card.css +1 -0
- package/dist/02-primitives/choice/choice.css +1 -0
- package/dist/02-primitives/combobox/combobox.css +1 -0
- package/dist/02-primitives/combobox/combobox.js +439 -0
- package/dist/02-primitives/date-range/date-range.css +1 -0
- package/dist/02-primitives/date-range/date-range.js +267 -0
- package/dist/02-primitives/input/input.css +1 -0
- package/dist/02-primitives/popover/popover.css +1 -0
- package/dist/02-primitives/popover/popover.js +257 -0
- package/dist/02-primitives/progress/progress.css +1 -0
- package/dist/02-primitives/select/select.css +1 -0
- package/dist/02-primitives/select/select.js +449 -0
- package/dist/02-primitives/separator/separator.css +1 -0
- package/dist/02-primitives/skeleton/skeleton.css +1 -0
- package/dist/02-primitives/switch/switch.css +1 -0
- package/dist/03-patterns/accordion/accordion.css +1 -0
- package/dist/03-patterns/accordion/accordion.js +219 -0
- package/dist/03-patterns/content/content.css +1 -0
- package/dist/03-patterns/drawer/drawer.css +1 -0
- package/dist/03-patterns/drawer/drawer.js +229 -0
- package/dist/03-patterns/dropdown/dropdown.css +1 -0
- package/dist/03-patterns/dropdown/dropdown.js +365 -0
- package/dist/03-patterns/form-validation/form-validation.css +1 -0
- package/dist/03-patterns/list/flip.js +117 -0
- package/dist/03-patterns/list/list.css +1 -0
- package/dist/03-patterns/nav/nav.css +1 -0
- package/dist/03-patterns/nav/nav.js +322 -0
- package/dist/03-patterns/nav/toc.js +76 -0
- package/dist/03-patterns/overlay/overlay.css +1 -0
- package/dist/03-patterns/overlay/overlay.js +398 -0
- package/dist/03-patterns/pagination/pagination.css +1 -0
- package/dist/03-patterns/pagination/pagination.js +273 -0
- package/dist/03-patterns/reveal/reveal.css +1 -0
- package/dist/03-patterns/reveal/reveal.js +138 -0
- package/dist/03-patterns/states/states.css +1 -0
- package/dist/03-patterns/tabs/tabs.css +1 -0
- package/dist/03-patterns/tabs/tabs.js +242 -0
- package/dist/03-patterns/tooltip/tooltip.css +1 -0
- package/dist/03-patterns/tooltip/tooltip.js +213 -0
- package/dist/03-patterns/tree/tree.css +1 -0
- package/dist/03-patterns/tree/tree.js +383 -0
- package/dist/04-recipes/analysis-report/analysis-report.css +1 -0
- package/dist/04-recipes/detail-page/detail-page.css +1 -0
- package/dist/04-recipes/report/report.css +1 -0
- package/dist/04-recipes/table/table.css +1 -0
- package/dist/04-recipes/tierA-tokens.css +1 -0
- package/dist/09-assets/bar/bar.css +1 -0
- package/dist/09-assets/bar/bar.js +222 -0
- package/dist/09-assets/echarts-adapter/echarts-adapter.js +185 -0
- package/dist/09-assets/model-viewer/model-viewer.css +1 -0
- package/dist/09-assets/model-viewer/model-viewer.js +320 -0
- package/dist/09-assets/scientific-plot/scientific-plot.css +1 -0
- package/dist/09-assets/scientific-plot/scientific-plot.js +568 -0
- package/dist/09-assets/sparkline/sparkline.css +1 -0
- package/dist/09-assets/sparkline/sparkline.js +265 -0
- package/dist/COSTS.md +56 -0
- package/docs/AI-CONTRACT.md +197 -0
- package/docs/BACKLOG.md +127 -0
- package/docs/BENCHMARK.md +252 -0
- package/docs/GUIDANCE.md +154 -0
- package/docs/INVARIANT.md +372 -0
- package/docs/LAYER-BASELINE.md +158 -0
- package/docs/RELEASING.md +275 -0
- package/docs/RFC-THEME-SCOPE.md +141 -0
- package/docs/ROADMAP.md +182 -0
- package/index.html +82 -0
- package/package.json +107 -4
- package/types/index.d.ts +310 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* combobox.js — 组合框 / 标签输入(E2 /
|
|
3
|
+
*
|
|
4
|
+
* 依据:APG Combobox with List Autocomplete
|
|
5
|
+
* ============================================================================
|
|
6
|
+
* ⭐ 三条最容易漏的:
|
|
7
|
+
*
|
|
8
|
+
* ① **焦点管理**:焦点**始终在 input 上**(不是选项上),
|
|
9
|
+
* 靠 aria-activedescendant 告知读屏"当前在第几项"。
|
|
10
|
+
* ⇒ 这与「roving tabindex」(树)的做法**正好相反**。
|
|
11
|
+
* ⇒ 用错会出现"点选项后输入框失焦,打字没反应"。
|
|
12
|
+
*
|
|
13
|
+
* ② **Backspace 删标签**:光标在空输入框时按 Backspace ⇒ 删最后一个标签。
|
|
14
|
+
* ⇒ 这是标签输入的事实标准交互,缺了很别扭。
|
|
15
|
+
*
|
|
16
|
+
* ③ **Enter 加标签**:输入框有文字时按 Enter ⇒ 把当前高亮项加为标签并清空输入。
|
|
17
|
+
*
|
|
18
|
+
* 无依赖 · ES5
|
|
19
|
+
*/
|
|
20
|
+
(function (global) {
|
|
21
|
+
'use strict';
|
|
22
|
+
/* ==== BEHAVIOR INJECT BEGIN: dismissable ==== */
|
|
23
|
+
var flDismissable = function (opts) {
|
|
24
|
+
var opt = opts || {};
|
|
25
|
+
var escOn = opt.escOn || [];
|
|
26
|
+
var inside = opt.inside || [];
|
|
27
|
+
var when = opt.when || function () { return true; };
|
|
28
|
+
var onDismiss = opt.onDismiss || function () {};
|
|
29
|
+
|
|
30
|
+
var shared = window.__flDismiss || (window.__flDismiss = { stack: [], bound: false });
|
|
31
|
+
|
|
32
|
+
function isInside(node) {
|
|
33
|
+
if (!node) return false;
|
|
34
|
+
for (var i = 0; i < inside.length; i++) {
|
|
35
|
+
var el = inside[i];
|
|
36
|
+
if (!el || !el.contains) continue;
|
|
37
|
+
if (el === node || el.contains(node)) return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function onKey(e) {
|
|
43
|
+
if (!when()) return;
|
|
44
|
+
var k = e.key;
|
|
45
|
+
if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
|
|
46
|
+
/* 🔴 为什么 onDismiss 在前、preventDefault/stopPropagation 在后
|
|
47
|
+
(0.8.8 改):**分级 Esc** 需要核让组件有机会说"这次我没关"。
|
|
48
|
+
combobox 的 Esc 是三级的:
|
|
49
|
+
① 列表开着 → 关列表
|
|
50
|
+
② 列表关了、输入有字 → 清空输入
|
|
51
|
+
③ 都没了 → 什么都不做(此时**不该**阻止默认行为,
|
|
52
|
+
让 Esc 能继续传给外层浮层)
|
|
53
|
+
而原实现是「无条件 preventDefault + stopPropagation,再调 onDismiss」
|
|
54
|
+
⇒ 组件根本无法表达"这次我没关"。
|
|
55
|
+
⇒ 改成:先问onDismiss,它返回 false 就**不干预**默认行为。
|
|
56
|
+
⚠️ 顺序安全性:stopPropagation 只需在**本次监听器执行期间**被调用即可
|
|
57
|
+
阻止冒泡到外层,放在 onDismiss 之后仍然有效。
|
|
58
|
+
*/
|
|
59
|
+
var closed = onDismiss(e, 'esc');
|
|
60
|
+
if (closed === false) return;
|
|
61
|
+
if (e.preventDefault) e.preventDefault();
|
|
62
|
+
if (e.stopPropagation) e.stopPropagation();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function onDocClick(e) {
|
|
66
|
+
var top = null;
|
|
67
|
+
for (var i = shared.stack.length - 1; i >= 0; i--) {
|
|
68
|
+
if (shared.stack[i].when()) { top = shared.stack[i]; break; }
|
|
69
|
+
}
|
|
70
|
+
if (!top || top.isInside(e.target)) return;
|
|
71
|
+
top.onDismiss(e, 'outside');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
|
|
75
|
+
|
|
76
|
+
for (var j = 0; j < escOn.length; j++) {
|
|
77
|
+
if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
|
|
78
|
+
}
|
|
79
|
+
shared.stack.push(rec);
|
|
80
|
+
if (!shared.bound) {
|
|
81
|
+
shared.bound = true;
|
|
82
|
+
document.addEventListener('click', onDocClick, true);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
destroy: function () {
|
|
87
|
+
for (var j = 0; j < escOn.length; j++) {
|
|
88
|
+
if (escOn[j]) escOn[j].removeEventListener('keydown', onKey);
|
|
89
|
+
}
|
|
90
|
+
var k = shared.stack.indexOf(rec);
|
|
91
|
+
if (k >= 0) shared.stack.splice(k, 1);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/* ==== BEHAVIOR INJECT END: dismissable ==== */
|
|
96
|
+
/* ==== BEHAVIOR INJECT BEGIN: emit ==== */
|
|
97
|
+
var flEmit = function (el, name, detail) {
|
|
98
|
+
if (!el || !el.dispatchEvent) return null;
|
|
99
|
+
var type = name.indexOf('fl-') === 0 ? name : 'fl-' + name;
|
|
100
|
+
var ev = null;
|
|
101
|
+
/* 老 WebView 没有 CustomEvent 构造函数 ⇒ 兜底走 createEvent(理由见文件头)*/
|
|
102
|
+
if (typeof window.CustomEvent === 'function') {
|
|
103
|
+
try {
|
|
104
|
+
ev = new window.CustomEvent(type, {
|
|
105
|
+
detail: detail || null, bubbles: true, cancelable: false
|
|
106
|
+
});
|
|
107
|
+
} catch (e) { ev = null; }
|
|
108
|
+
}
|
|
109
|
+
if (!ev) {
|
|
110
|
+
try {
|
|
111
|
+
ev = document.createEvent('CustomEvent');
|
|
112
|
+
ev.initCustomEvent(type, true, false, detail || null);
|
|
113
|
+
} catch (e2) { return null; }
|
|
114
|
+
}
|
|
115
|
+
el.dispatchEvent(ev);
|
|
116
|
+
return ev;
|
|
117
|
+
};
|
|
118
|
+
/* ==== BEHAVIOR INJECT END: emit ==== */
|
|
119
|
+
/* ---- 旧环境兼容:Element.closest 在老 WebView 上不存在 ----
|
|
120
|
+
直接调用会在真机上抛 `is not a function`(本地完全正常,
|
|
121
|
+
只在老环境炸 —— 属静默失效)。这里做模块内兜底,
|
|
122
|
+
不污染全局,也不需要使用者额外引入 polyfill。 */
|
|
123
|
+
function closest(el, sel) {
|
|
124
|
+
if (!el) return null;
|
|
125
|
+
if (el.closest) return el.closest(sel);
|
|
126
|
+
while (el && el.nodeType === 1) {
|
|
127
|
+
if (elMatches(el, sel)) return el;
|
|
128
|
+
el = el.parentElement;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function elMatches(el, sel) {
|
|
133
|
+
var m = sel.match(/^([a-zA-Z][\w-]*)/);
|
|
134
|
+
if (m && el.tagName.toLowerCase() !== m[1].toLowerCase()) return false;
|
|
135
|
+
var cls = sel.match(/\.([\w-]+)/g);
|
|
136
|
+
if (cls) {
|
|
137
|
+
for (var i = 0; i < cls.length; i++) {
|
|
138
|
+
var c = cls[i].slice(1);
|
|
139
|
+
if ((' ' + (el.className || '') + ' ').indexOf(' ' + c + ' ') < 0) return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
var id = sel.match(/#([\w-]+)/);
|
|
143
|
+
if (id && el.id !== id[1]) return false;
|
|
144
|
+
var at = sel.match(/\[([\w-]+)(?:=["']?([^\]"']*)["']?)?\]/);
|
|
145
|
+
if (at) {
|
|
146
|
+
var v = el.getAttribute(at[1]);
|
|
147
|
+
if (v === null) return false;
|
|
148
|
+
if (at[2] !== undefined && v !== at[2]) return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
var uid = 0;
|
|
154
|
+
|
|
155
|
+
function create(root, opt) {
|
|
156
|
+
opt = opt || {};
|
|
157
|
+
var input = root.querySelector('[data-combo-input]');
|
|
158
|
+
var list = root.querySelector('[data-combo-list]');
|
|
159
|
+
var tagBox = root.querySelector('[data-combo-tags]');
|
|
160
|
+
if (!input || !list) throw new Error('Combobox: 缺少 [data-combo-input] 或 [data-combo-list]');
|
|
161
|
+
|
|
162
|
+
/* 「关得掉」核(0.8.8 收编)。
|
|
163
|
+
🔴 本组件的 Esc 是**分级**的,核原,表达不了(见 onDismiss):
|
|
164
|
+
① 列表开着 → 关列表(核接管)
|
|
165
|
+
② 列表已关、输入有字 → 清空输入(核接管)
|
|
166
|
+
③ 都没了 → **不关**,返回 false 让核不干预默认行为
|
|
167
|
+
(此时 Esc 该继续传给外层浮层,而不是被吃掉)
|
|
168
|
+
⇒ 0.8.8 给核加了返回值语义:onDismiss 返回 false ⇒ 核不抢preventDefault。 */
|
|
169
|
+
var dismiss = flDismissable({
|
|
170
|
+
escOn: [input],
|
|
171
|
+
inside: [root],
|
|
172
|
+
when: function () { return true; }, /* 三级 Esc 都要响应,见下 */
|
|
173
|
+
onDismiss: function (ev, reason) {
|
|
174
|
+
if (reason === 'outside') { close(); return true; }
|
|
175
|
+
/* Esc:按「列表 → 输入 → 放弃」的顺序处理 */
|
|
176
|
+
if (!list.hidden) { close(); return true; }
|
|
177
|
+
if (input.value) { input.value = ''; paintList(); return true; }
|
|
178
|
+
return false;/* ③ 什么都不做 ⇒ 核不干预,Esc 继续往外传 */
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
if (!list.id) list.id = 'combo-list-' + (++uid);
|
|
182
|
+
|
|
183
|
+
/* 🔴 每个实例进来就领一个序号:
|
|
184
|
+
以前只在 `!list.id` 时递增 ⇒ 调用方给了 list id 时多个实例共用一个号。 */
|
|
185
|
+
var seq = ++uid;
|
|
186
|
+
|
|
187
|
+
var ALL = opt.options || []; /* [{value, label}] */
|
|
188
|
+
var tags = []; /* 已选 */
|
|
189
|
+
var activeIdx = -1;
|
|
190
|
+
var shown = []; /* 当前过滤后的选项 */
|
|
191
|
+
|
|
192
|
+
list.setAttribute('role', 'listbox');
|
|
193
|
+
list.setAttribute('aria-label', opt.label || '建议');
|
|
194
|
+
input.setAttribute('role', 'combobox');
|
|
195
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
196
|
+
input.setAttribute('aria-expanded', 'false');
|
|
197
|
+
input.setAttribute('aria-controls', list.id);
|
|
198
|
+
if (!input.id) input.id = 'combo-input-' + seq;
|
|
199
|
+
if (!input.getAttribute('aria-label') && !root.querySelector('label[for="' + input.id + '"]')) {
|
|
200
|
+
input.setAttribute('aria-label', opt.label || '输入');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ---------- 过滤(含高亮命中片段)---------- */
|
|
204
|
+
function esc(s) {
|
|
205
|
+
return String(s).replace(/[&<>"]/g, function (c) {
|
|
206
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 候选项过滤。
|
|
211
|
+
*
|
|
212
|
+
* 若调用方提供了 `filter`,就用它(自定义匹配逻辑:拼音、模糊、分组…)。
|
|
213
|
+
* 否则退回默认的标签/值子串匹配。
|
|
214
|
+
*
|
|
215
|
+
* ⚠️ 自定义函数必须**同步返回布尔值**。它会被逐项调用,
|
|
216
|
+
* 所以不要在里面做异步过滤。
|
|
217
|
+
*/
|
|
218
|
+
function filter(q) {
|
|
219
|
+
q = (q || '').trim().toLowerCase();
|
|
220
|
+
if (!q) return ALL.slice();
|
|
221
|
+
if (typeof opt.filter === 'function') {
|
|
222
|
+
return ALL.filter(function (o) {
|
|
223
|
+
return !!opt.filter(q, o);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return ALL.filter(function (o) {
|
|
227
|
+
return (o.label || '').toLowerCase().indexOf(q) >= 0 ||
|
|
228
|
+
(o.value || '').toLowerCase().indexOf(q) >= 0;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function paintList() {
|
|
232
|
+
var q = input.value.trim();
|
|
233
|
+
shown = filter(q).slice(0, 50);
|
|
234
|
+
if (!shown.length) {
|
|
235
|
+
list.innerHTML = '<li class="combo__empty" role="presentation">' +
|
|
236
|
+
(q ? '没有匹配「' + esc(q) + '」的选项' : '暂无可选') + '</li>';
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
var ql = q.toLowerCase();
|
|
240
|
+
list.innerHTML = shown.map(function (o, i) {
|
|
241
|
+
var picked = tags.indexOf(o.value) >= 0;
|
|
242
|
+
var label = esc(o.label);
|
|
243
|
+
/* ⭐ 高亮命中片段:让用户知道**为什么**这条匹配 */
|
|
244
|
+
if (ql) {
|
|
245
|
+
var raw = o.label, k = raw.toLowerCase().indexOf(ql);
|
|
246
|
+
if (k >= 0) {
|
|
247
|
+
label = esc(raw.slice(0, k)) + '<mark>' + esc(raw.slice(k, k + q.length)) +
|
|
248
|
+
'</mark>' + esc(raw.slice(k + q.length));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/* ⭐ 必须真写出来:`setActive()` 在读它,读屏也需要它*/
|
|
252
|
+
return '<li class="combo__opt" role="option" id="' + list.id + '-o' + i + '"' +
|
|
253
|
+
' data-value="' + esc(o.value) + '"' +
|
|
254
|
+
' aria-selected="' + (picked ? 'true' : 'false') + '"' +
|
|
255
|
+
(o.disabled ? ' aria-disabled="true"' : '') + '>' +
|
|
256
|
+
'<span class="combo__opt-text">' + label + '</span></li>';
|
|
257
|
+
}).join('');
|
|
258
|
+
activeIdx = -1;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ---------- 标签 ---------- */
|
|
262
|
+
function paintTags() {
|
|
263
|
+
if (!tagBox) return;
|
|
264
|
+
tagBox.innerHTML = tags.map(function (v) {
|
|
265
|
+
var o = ALL.filter(function (x) { return x.value === v; })[0] || { label: v };
|
|
266
|
+
/* ⭐ 删除按钮有 aria-label(否则读屏只念"×")*/
|
|
267
|
+
return '<span class="combo__tag">' +
|
|
268
|
+
'<span class="combo__tag-text">' + esc(o.label) + '</span>' +
|
|
269
|
+
'<button class="combo__tag-del" type="button" data-del="' + esc(v) + '"' +
|
|
270
|
+
' aria-label="移除 ' + esc(o.label) + '">×</button></span>';
|
|
271
|
+
}).join('');
|
|
272
|
+
var hidden = root.querySelector('input[type="hidden"]');
|
|
273
|
+
if (hidden) hidden.value = tags.join(',');
|
|
274
|
+
flEmit(root, 'fl-change', { value: tags.slice() });
|
|
275
|
+
if (opt.onChange) opt.onChange(tags.slice());
|
|
276
|
+
}
|
|
277
|
+
function isDisabled(v) {
|
|
278
|
+
for (var i = 0; i < ALL.length; i++) {
|
|
279
|
+
if (ALL[i].value === v) return !!ALL[i].disabled;
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ⭐ max 的**唯一入口**:点击 / Enter / 公开 add 全走这里。
|
|
285
|
+
以前只在 commitActive 里"加完发现满了才关列表" ⇒ 另外两条路能绕过。 */
|
|
286
|
+
function addTag(v) {
|
|
287
|
+
if (v == null || v === '') return false;
|
|
288
|
+
if (isDisabled(v)) return false; /* 禁用项 */
|
|
289
|
+
if (tags.indexOf(v) >= 0) return false; /* 不重复 */
|
|
290
|
+
if (opt.max && tags.length >= opt.max) return false; /* 🔴 上限 */
|
|
291
|
+
tags.push(v);
|
|
292
|
+
paintTags();
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
function removeTag(v) {
|
|
296
|
+
var i = tags.indexOf(v);
|
|
297
|
+
if (i < 0) return false;
|
|
298
|
+
tags.splice(i, 1);
|
|
299
|
+
paintTags();
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ---------- 游标 ---------- */
|
|
304
|
+
function opts() {
|
|
305
|
+
return [].slice.call(list.querySelectorAll('[role="option"]'));
|
|
306
|
+
}
|
|
307
|
+
function setActive(i, dir) {
|
|
308
|
+
var os = opts();
|
|
309
|
+
if (!os.length) { activeIdx = -1; input.removeAttribute('aria-activedescendant'); return; }
|
|
310
|
+
dir = dir || 1;
|
|
311
|
+
var n = os.length;
|
|
312
|
+
var idx = ((i % n) + n) % n;
|
|
313
|
+
/* 🔴 全禁用时不许落在禁用项上:
|
|
314
|
+
以前跑满 n 次无条件 break,停哪算哪。APG:没有可选项 ⇒ 没有"当前项"。 */
|
|
315
|
+
var found = false;
|
|
316
|
+
for (var k = 0; k < n; k++) {
|
|
317
|
+
if (os[idx].getAttribute('aria-disabled') !== 'true') { found = true; break; }
|
|
318
|
+
idx = ((idx + dir) % n + n) % n;
|
|
319
|
+
}
|
|
320
|
+
if (!found) {
|
|
321
|
+
activeIdx = -1;
|
|
322
|
+
os.forEach(function (o) { o.removeAttribute('data-state'); });
|
|
323
|
+
input.removeAttribute('aria-activedescendant');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
activeIdx = idx;
|
|
327
|
+
os.forEach(function (o) { o.removeAttribute('data-state'); });
|
|
328
|
+
os[idx].setAttribute('data-state', 'active');
|
|
329
|
+
/* ⭐ 焦点留在 input 上,靠 aria-activedescendant 告知读屏 */
|
|
330
|
+
input.setAttribute('aria-activedescendant', os[idx].id);
|
|
331
|
+
if (os[idx].scrollIntoView) os[idx].scrollIntoView({ block: 'nearest' });
|
|
332
|
+
}
|
|
333
|
+
function open() {
|
|
334
|
+
paintList();
|
|
335
|
+
list.hidden = false;
|
|
336
|
+
input.setAttribute('aria-expanded', 'true');
|
|
337
|
+
}
|
|
338
|
+
function close() {
|
|
339
|
+
list.hidden = true;
|
|
340
|
+
input.setAttribute('aria-expanded', 'false');
|
|
341
|
+
activeIdx = -1;
|
|
342
|
+
input.removeAttribute('aria-activedescendant');
|
|
343
|
+
}
|
|
344
|
+
function commitActive() {
|
|
345
|
+
if (activeIdx < 0) return false;
|
|
346
|
+
var o = opts()[activeIdx];
|
|
347
|
+
if (!o) return false;
|
|
348
|
+
var v = o.getAttribute('data-value');
|
|
349
|
+
var ok = addTag(v);
|
|
350
|
+
input.value = '';
|
|
351
|
+
paintList();
|
|
352
|
+
/* 标签满时保持列表开着(还能继续加),否则关掉 */
|
|
353
|
+
if (opt.max && tags.length >= opt.max) close();
|
|
354
|
+
return ok;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ---------- 键盘(焦点常驻 input)----------
|
|
358
|
+
⭐ 具名函数:`destroy()` 要按同一个引用摘除,匿名的摘不掉。 */
|
|
359
|
+
function onKeydown(e) {
|
|
360
|
+
var k = e.key;
|
|
361
|
+
|
|
362
|
+
/* ↑↓ 在建议间移动 */
|
|
363
|
+
if (k === 'ArrowDown' || k === 'ArrowUp') {
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
if (list.hidden) { open(); return; }
|
|
366
|
+
var dir = k === 'ArrowDown' ? 1 : -1;
|
|
367
|
+
var base = activeIdx >= 0 ? activeIdx : (dir > 0 ? -1 : opts().length);
|
|
368
|
+
setActive(base + dir, dir);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
/* Enter:加当前高亮项;没有高亮则加"输入的文字" */
|
|
372
|
+
if (k === 'Enter') {
|
|
373
|
+
if (list.hidden) return;
|
|
374
|
+
e.preventDefault();
|
|
375
|
+
if (activeIdx >= 0) { commitActive(); return; }
|
|
376
|
+
/* 没有高亮 ⇒ 精确匹配一个 */
|
|
377
|
+
var q = input.value.trim();
|
|
378
|
+
var hit = shown.filter(function (o) {
|
|
379
|
+
return (o.label || '').toLowerCase() === q.toLowerCase() ||
|
|
380
|
+
(o.value || '').toLowerCase() === q.toLowerCase();
|
|
381
|
+
})[0];
|
|
382
|
+
if (hit) { addTag(hit.value); input.value = ''; paintList(); }
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
/* Esc 已由 dismissable 核接管(见 create 里的 flDismissable)。
|
|
386
|
+
核按onDismiss 的返回值决定要不要 preventDefault/stopPropagation,
|
|
387
|
+
所以三级 Esc(关列表 / 清输入 / 放弃)能正确区分。 */
|
|
388
|
+
/* ② Backspace:空输入时删最后一个标签 */
|
|
389
|
+
if (k === 'Backspace' && !input.value && tags.length) {
|
|
390
|
+
e.preventDefault();
|
|
391
|
+
removeTag(tags[tags.length - 1]);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
/* 逗号/顿号也算"确认"(中文用户习惯)*/
|
|
395
|
+
if ((k === ',' || k === ',') && input.value.trim()) {
|
|
396
|
+
e.preventDefault();
|
|
397
|
+
var q2 = input.value.trim();
|
|
398
|
+
var h2 = shown.filter(function (o) {
|
|
399
|
+
return (o.label || '').indexOf(q2) >= 0 || (o.value || '').indexOf(q2) >= 0;
|
|
400
|
+
})[0];
|
|
401
|
+
if (h2) { addTag(h2.value); input.value = ''; paintList(); }
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (k === 'Home' && !list.hidden) { e.preventDefault(); setActive(0, 1); return; }
|
|
405
|
+
if (k === 'End' && !list.hidden) { e.preventDefault(); setActive(opts().length - 1, -1); return; }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function onInput() { open(); }
|
|
409
|
+
function onFocus() { open(); }
|
|
410
|
+
function onClick() { open(); }
|
|
411
|
+
|
|
412
|
+
/* ---------- 标签删除 ---------- */
|
|
413
|
+
function onTagClick(e) {
|
|
414
|
+
/* 🔴 原来是 `e.target.closest ? closest(...) : null` ⇒ 没有原生 closest
|
|
415
|
+
时直接返回 null,兜底永远跑不到。直接调,它内部自己判。 */
|
|
416
|
+
var b = closest(e.target, '[data-del]');
|
|
417
|
+
if (!b) return;
|
|
418
|
+
removeTag(b.getAttribute('data-del'));
|
|
419
|
+
input.focus(); /* 删除后焦点回输入框 */
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/* ---------- 点选项 ---------- */
|
|
423
|
+
function onListClick(e) {
|
|
424
|
+
var o = closest(e.target, '[role="option"]');
|
|
425
|
+
if (!o) return;
|
|
426
|
+
addTag(o.getAttribute('data-value'));
|
|
427
|
+
input.value = '';
|
|
428
|
+
paintList();
|
|
429
|
+
input.focus();
|
|
430
|
+
}
|
|
431
|
+
input.addEventListener('keydown', onKeydown);
|
|
432
|
+
input.addEventListener('input', onInput);
|
|
433
|
+
input.addEventListener('focus', onFocus);
|
|
434
|
+
input.addEventListener('click', onClick);
|
|
435
|
+
if (tagBox) tagBox.addEventListener('click', onTagClick);
|
|
436
|
+
list.addEventListener('click', onListClick);
|
|
437
|
+
/* 点外部关闭已由 dismissable 核接管(document 捕获阶段判定 + 自动摘除)。 */
|
|
438
|
+
|
|
439
|
+
/* 🔴 禁用态同步:CSS 不用 :has()(ES6+ 兼容问题,见 combobox.css 注释)
|
|
440
|
+
⇒ 由这里根据 input.disabled 给容器加 class。 */
|
|
441
|
+
function syncDisabled() {
|
|
442
|
+
var field = root.querySelector('.combo__field') ||
|
|
443
|
+
input.parentElement;
|
|
444
|
+
if (!field) return;
|
|
445
|
+
if (input.disabled) field.setAttribute('data-state', 'disabled');
|
|
446
|
+
else field.removeAttribute('data-state');
|
|
447
|
+
}
|
|
448
|
+
input.addEventListener('change', syncDisabled);
|
|
449
|
+
syncDisabled();
|
|
450
|
+
|
|
451
|
+
/* ---------- 销毁----------
|
|
452
|
+
以前没有 destroy,而上面注册了 document 级监听 ⇒ 实例没了监听还在。
|
|
453
|
+
⇒ 具名注册(见上)+ 这里逐个摘除;**幂等**(重复调用不抛)。 */
|
|
454
|
+
var destroyed = false;
|
|
455
|
+
function destroy() {
|
|
456
|
+
if (destroyed) return;
|
|
457
|
+
destroyed = true;
|
|
458
|
+
input.removeEventListener('keydown', onKeydown);
|
|
459
|
+
input.removeEventListener('input', onInput);
|
|
460
|
+
input.removeEventListener('focus', onFocus);
|
|
461
|
+
input.removeEventListener('click', onClick);
|
|
462
|
+
input.removeEventListener('change', syncDisabled);
|
|
463
|
+
if (tagBox) tagBox.removeEventListener('click', onTagClick);
|
|
464
|
+
list.removeEventListener('click', onListClick);
|
|
465
|
+
/* 🔴 0.8.8 修一个真 bug(门禁 combobox-inst 当场抓到):
|
|
466
|
+
收编 dismissable 核时我把 `onDocClick` 的**定义**删了,
|
|
467
|
+
却漏了这里的手动摘除 ⇒ `destroy()` 一调就抛
|
|
468
|
+
「onDocClick is not defined」⇒ 实例销毁路径直接崩。
|
|
469
|
+
⇒ 正确做法是调**核的** destroy(),它自己会摘 document 监听
|
|
470
|
+
并且把自己从共享栈里移除。 */
|
|
471
|
+
dismiss.destroy();
|
|
472
|
+
close();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
paintTags();
|
|
476
|
+
return {
|
|
477
|
+
get tags() { return tags.slice(); },
|
|
478
|
+
add: addTag, remove: removeTag,
|
|
479
|
+
setOptions: function (o) { ALL = o || []; paintList(); },
|
|
480
|
+
destroy: destroy,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
global.Combobox = { create: create };
|
|
485
|
+
})(window);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<link rel="icon" href="data:,">
|
|
7
|
+
<title>combobox · 组合框 · frontend-lib</title>
|
|
8
|
+
<link rel="stylesheet" href="../../01-tokens/tokens.css">
|
|
9
|
+
<link rel="stylesheet" href="../../01-tokens/typography.css">
|
|
10
|
+
<link rel="stylesheet" href="../../01-tokens/focus-ring.css">
|
|
11
|
+
<link rel="stylesheet" href="../../01-tokens/page.css">
|
|
12
|
+
<link rel="stylesheet" href="combobox.css">
|
|
13
|
+
<style>
|
|
14
|
+
.pad { padding: var(--sp-3) 0 var(--sp-6); }
|
|
15
|
+
label.f { display: block; margin-bottom: var(--sp-1); font-size: var(--fs-sm); }
|
|
16
|
+
.log {
|
|
17
|
+
margin-top: var(--sp-3); padding: var(--sp-3);
|
|
18
|
+
background: var(--surface-sunken); border-radius: var(--r-sm);
|
|
19
|
+
font-family: var(--mono); font-size: var(--fs-xs);
|
|
20
|
+
color: var(--text-secondary); min-height: 2.8em;
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body class="page page">
|
|
25
|
+
<main class="wrap">
|
|
26
|
+
|
|
27
|
+
<h1>combobox · 组合框 / 标签输入</h1>
|
|
28
|
+
<p class="note">
|
|
29
|
+
依据 <strong>APG Combobox with List Autocomplete</strong>。三条最容易漏:
|
|
30
|
+
<br>① <strong>焦点始终在 input 上</strong>(靠 <code>aria-activedescendant</code> 告知读屏)
|
|
31
|
+
—— ⚠️ 与树(roving tabindex)<strong>正好相反</strong>,用错会出现"点选项后输入框失焦"
|
|
32
|
+
<br>② <code>Backspace</code> 在空输入时删最后一个标签
|
|
33
|
+
<br>③ <code>Enter</code> 加当前高亮项
|
|
34
|
+
<br>另:命中片段会<strong>高亮</strong>,让你知道为什么这条匹配
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
<h2>1 · 板块多选(最多 3 个)</h2>
|
|
38
|
+
<div class="pad">
|
|
39
|
+
<label class="f" for="cb1">关注板块</label>
|
|
40
|
+
<div class="combo" id="c1">
|
|
41
|
+
<div class="combo__field">
|
|
42
|
+
<div class="combo__tags" data-combo-tags></div>
|
|
43
|
+
<!-- 🔴 input 必须在 .combo__field **内部**(:focus-within 才生效);
|
|
44
|
+
放在外面会变成兄弟节点 ⇒ 焦点环永远不出现。 -->
|
|
45
|
+
<!-- 🔴 补 id:label for="cb1" 之前指向一个**不存在的元素**
|
|
46
|
+
⇒ 点标签聚焦不到输入框,读屏也关联不上(WCAG 1.3.1 / 4.1.2) -->
|
|
47
|
+
<input class="combo__input" data-combo-input type="text" id="cb1"
|
|
48
|
+
placeholder="输入后回车添加,或用逗号分隔" autocomplete="off">
|
|
49
|
+
</div>
|
|
50
|
+
<ul class="combo__list" data-combo-list hidden></ul>
|
|
51
|
+
<input type="hidden" name="sectors">
|
|
52
|
+
</div>
|
|
53
|
+
<div class="log" id="log">(已选标签会显示在这里,也会写进 hidden input)</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<h2>2 · 单个(可清除)</h2>
|
|
57
|
+
<div class="pad">
|
|
58
|
+
<label class="f" for="cb2">对标公司</label>
|
|
59
|
+
<div class="combo" id="c2">
|
|
60
|
+
<div class="combo__field">
|
|
61
|
+
<div class="combo__tags" data-combo-tags></div>
|
|
62
|
+
<input class="combo__input" data-combo-input type="text"
|
|
63
|
+
placeholder="输入公司名" autocomplete="off" id="cb2">
|
|
64
|
+
</div>
|
|
65
|
+
<ul class="combo__list" data-combo-list hidden></ul>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
</main>
|
|
70
|
+
|
|
71
|
+
<script src="combobox.js"></script>
|
|
72
|
+
<script>
|
|
73
|
+
(function () {
|
|
74
|
+
var log = document.getElementById('log');
|
|
75
|
+
var SECTORS = [
|
|
76
|
+
{ value: 'semi', label: '半导体' },
|
|
77
|
+
{ value: 'ai', label: '人工智能' },
|
|
78
|
+
{ value: 'newenergy', label: '新能源' },
|
|
79
|
+
{ value: 'pharma', label: '医药生物' },
|
|
80
|
+
{ value: 'consume', label: '消费' },
|
|
81
|
+
{ value: 'finance', label: '金融' },
|
|
82
|
+
{ value: 'ai2', label: 'AI 智能驾驶' },
|
|
83
|
+
];
|
|
84
|
+
var FIRMS = [
|
|
85
|
+
{ value: '600519', label: '贵州茅台' },
|
|
86
|
+
{ value: '300750', label: '宁德时代' },
|
|
87
|
+
{ value: '002594', label: '比亚迪' },
|
|
88
|
+
{ value: '601318', label: '招商银行' },
|
|
89
|
+
{ value: '000333', label: '美的集团' },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/* 🔴 挂到 window:给契约脚本当探针用
|
|
93
|
+
(否则契约没法直接调 add/remove 验证"不重复添加"这类逻辑) */
|
|
94
|
+
window.__cb1 = Combobox.create(document.getElementById('c1'), {
|
|
95
|
+
label: '关注板块', options: SECTORS, max: 3,
|
|
96
|
+
onChange: function (t) {
|
|
97
|
+
var hidden = document.querySelector('#c1 input[type=hidden]');
|
|
98
|
+
log.textContent = '已选 ' + t.length + ' 个:' + t.join(', ') +
|
|
99
|
+
' hidden=' + (hidden ? hidden.value : '(无)');
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
Combobox.create(document.getElementById('c2'), {
|
|
104
|
+
label: '对标公司', options: FIRMS, max: 1,
|
|
105
|
+
});
|
|
106
|
+
})();
|
|
107
|
+
</script>
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|