agentcss 0.13.2 → 0.15.0

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.
@@ -26,13 +26,14 @@
26
26
  * var d = flDismissable({
27
27
  * escOn: [box, trigger], // 在这些元素上听 Esc
28
28
  * inside: [box, anchor], // 点在这些里面 ⇒ 不算"外部"
29
+ * outside: false, // 模态:点外部**不关**(默认 true)
29
30
  * when: function () { return opened; }, // 只有开着才响应
30
31
  * onDismiss: function (ev, reason) { close(); } // reason: 'esc' | 'outside'
31
32
  * });
32
33
  * d.destroy(); // 必须成对调用,否则监听器与栈都会泄漏
33
34
  *
34
35
  * ==========================================================================
35
- * ⚠️ 三个必须知道的实现细节(都是实测踩出来的,改之前先读)
36
+ * ⚠️ 五个必须知道的实现细节(都是实测踩出来的,改之前先读)
36
37
  * --------------------------------------------------------------------------
37
38
  * ① **Esc 挂在元素上(冒泡阶段),不是挂在 document 上**:
38
39
  * 冒泡从最内层往外走 ⇒ 最内层先收到 ⇒ 它 stopPropagation 之后
@@ -52,6 +53,29 @@
52
53
  * (本库既有约定,popover 的 open() 会先关掉别的),
53
54
  * 在此前提下"取最后一个活跃的记录"与"取最后打开的那个"等价。
54
55
  *
56
+ * ④ **模态弹层用 `outside: false`,不进栈**(0.14.0 加):
57
+ * 模态的定义就是"点外面不该关",而外部点击只能挂 document(②)。
58
+ * 曾经的结论是"那就不收编"(见 ai/behaviors.json 的 0.8.6 记录)——
59
+ * 于是 drawer / overlay 各留一份手写的 Esc,I-14 的病留在原地。
60
+ * 开关放进核里之后,收编不必改组件的**语义**,只改它的**接线**。
61
+ * 不进栈的后果要写清:模态层不再参与"点外部关最内层"的仲裁,
62
+ * 它压根不该被这条规则选中;Esc 走元素监听器,与栈无关。
63
+ * ⚠️ 模态层里的非模态浮层(对话框里的下拉菜单)仍正常进栈 ——
64
+ * 点菜单外面先关菜单、再点一次才轮到遮罩,层次不会乱。
65
+ *
66
+ * ⑤ **先问 `onDismiss`,再 `preventDefault` / `stopPropagation`**(0.8.8 改):
67
+ * **分级 Esc** 需要核让组件有机会说"这次我没关"。combobox 的 Esc 是三级:
68
+ * ① 列表开着 → 关列表
69
+ * ② 列表关了、输入有字 → 清空输入
70
+ * ③ 都没了 → 什么都不做(此时**不该**阻止默认行为,让 Esc 传给外层)
71
+ * 原实现是"无条件阻止,再调 onDismiss" ⇒ 组件无法表达"这次我没关"。
72
+ * ⚠️ 顺序安全性:stopPropagation 只需在**本次监听器执行期间**被调用即可
73
+ * 阻止冒泡到外层,放在 onDismiss 之后仍然有效。
74
+ *
75
+ * ⚠️ 注入块里只留一行指针,长解释一律写在本文件头(同上,gzip 要算):
76
+ * 这段注释以前在块内,8 个使用点各背 429 字节 ⇒ 收编时被 size-budget
77
+ * 当场红出来(drawer +44%),挪到这里。
78
+ *
55
79
  * ==========================================================================
56
80
  * 约束:ES5 · 零依赖 · 可被整块注入 · 不污染除 __flDismiss 外的任何全局
57
81
  * ==========================================================================
@@ -61,6 +85,7 @@ var flDismissable = function (opts) {
61
85
  var opt = opts || {};
62
86
  var escOn = opt.escOn || [];
63
87
  var inside = opt.inside || [];
88
+ var outside = opt.outside !== false;
64
89
  var when = opt.when || function () { return true; };
65
90
  var onDismiss = opt.onDismiss || function () {};
66
91
 
@@ -80,19 +105,7 @@ var flDismissable = function (opts) {
80
105
  if (!when()) return;
81
106
  var k = e.key;
82
107
  if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
83
- /* 🔴 为什么 onDismiss 在前、preventDefault/stopPropagation 在后
84
- (0.8.8 改):**分级 Esc** 需要核让组件有机会说"这次我没关"。
85
- combobox 的 Esc 是三级的:
86
- ① 列表开着 → 关列表
87
- ② 列表关了、输入有字 → 清空输入
88
- ③ 都没了 → 什么都不做(此时**不该**阻止默认行为,
89
- 让 Esc 能继续传给外层浮层)
90
- 而原实现是「无条件 preventDefault + stopPropagation,再调 onDismiss」
91
- ⇒ 组件根本无法表达"这次我没关"。
92
- ⇒ 改成:先问onDismiss,它返回 false 就**不干预**默认行为。
93
- ⚠️ 顺序安全性:stopPropagation 只需在**本次监听器执行期间**被调用即可
94
- 阻止冒泡到外层,放在 onDismiss 之后仍然有效。
95
- */
108
+ /* 先问 onDismiss,它返回 false 就不干预(分级 Esc,见文件头 ⑤) */
96
109
  var closed = onDismiss(e, 'esc');
97
110
  if (closed === false) return;
98
111
  if (e.preventDefault) e.preventDefault();
@@ -113,7 +126,10 @@ var flDismissable = function (opts) {
113
126
  for (var j = 0; j < escOn.length; j++) {
114
127
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
115
128
  }
116
- shared.stack.push(rec);
129
+ /* 只有"点外部要关"的层才进栈:栈是给 onDocClick 挑最内层用的,
130
+ 模态层不该被这条规则选中(见文件头 ④)。
131
+ ⚠️ Esc 不依赖栈(它挂在元素上),所以不进栈不影响 Esc。 */
132
+ if (outside) shared.stack.push(rec);
117
133
  if (!shared.bound) {
118
134
  shared.bound = true;
119
135
  document.addEventListener('click', onDocClick, true);
@@ -24,6 +24,7 @@
24
24
  var opt = opts || {};
25
25
  var escOn = opt.escOn || [];
26
26
  var inside = opt.inside || [];
27
+ var outside = opt.outside !== false;
27
28
  var when = opt.when || function () { return true; };
28
29
  var onDismiss = opt.onDismiss || function () {};
29
30
 
@@ -43,19 +44,7 @@
43
44
  if (!when()) return;
44
45
  var k = e.key;
45
46
  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
- */
47
+ /* 先问 onDismiss,它返回 false 就不干预(分级 Esc,见文件头 ⑤) */
59
48
  var closed = onDismiss(e, 'esc');
60
49
  if (closed === false) return;
61
50
  if (e.preventDefault) e.preventDefault();
@@ -76,7 +65,10 @@
76
65
  for (var j = 0; j < escOn.length; j++) {
77
66
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
78
67
  }
79
- shared.stack.push(rec);
68
+ /* 只有"点外部要关"的层才进栈:栈是给 onDocClick 挑最内层用的,
69
+ 模态层不该被这条规则选中(见文件头 ④)。
70
+ ⚠️ Esc 不依赖栈(它挂在元素上),所以不进栈不影响 Esc。 */
71
+ if (outside) shared.stack.push(rec);
80
72
  if (!shared.bound) {
81
73
  shared.bound = true;
82
74
  document.addEventListener('click', onDocClick, true);
@@ -18,6 +18,7 @@
18
18
  var opt = opts || {};
19
19
  var escOn = opt.escOn || [];
20
20
  var inside = opt.inside || [];
21
+ var outside = opt.outside !== false;
21
22
  var when = opt.when || function () { return true; };
22
23
  var onDismiss = opt.onDismiss || function () {};
23
24
 
@@ -37,19 +38,7 @@
37
38
  if (!when()) return;
38
39
  var k = e.key;
39
40
  if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
40
- /* 🔴 为什么 onDismiss 在前、preventDefault/stopPropagation 在后
41
- (0.8.8 改):**分级 Esc** 需要核让组件有机会说"这次我没关"。
42
- combobox 的 Esc 是三级的:
43
- ① 列表开着 → 关列表
44
- ② 列表关了、输入有字 → 清空输入
45
- ③ 都没了 → 什么都不做(此时**不该**阻止默认行为,
46
- 让 Esc 能继续传给外层浮层)
47
- 而原实现是「无条件 preventDefault + stopPropagation,再调 onDismiss」
48
- ⇒ 组件根本无法表达"这次我没关"。
49
- ⇒ 改成:先问onDismiss,它返回 false 就**不干预**默认行为。
50
- ⚠️ 顺序安全性:stopPropagation 只需在**本次监听器执行期间**被调用即可
51
- 阻止冒泡到外层,放在 onDismiss 之后仍然有效。
52
- */
41
+ /* 先问 onDismiss,它返回 false 就不干预(分级 Esc,见文件头 ⑤) */
53
42
  var closed = onDismiss(e, 'esc');
54
43
  if (closed === false) return;
55
44
  if (e.preventDefault) e.preventDefault();
@@ -70,7 +59,10 @@
70
59
  for (var j = 0; j < escOn.length; j++) {
71
60
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
72
61
  }
73
- shared.stack.push(rec);
62
+ /* 只有"点外部要关"的层才进栈:栈是给 onDocClick 挑最内层用的,
63
+ 模态层不该被这条规则选中(见文件头 ④)。
64
+ ⚠️ Esc 不依赖栈(它挂在元素上),所以不进栈不影响 Esc。 */
65
+ if (outside) shared.stack.push(rec);
74
66
  if (!shared.bound) {
75
67
  shared.bound = true;
76
68
  document.addEventListener('click', onDocClick, true);
@@ -68,6 +68,11 @@ const sel = Select.create(元素, { multiple: false, value: 'b', label: '选项'
68
68
  **① 为什么强调"落在选中项"**:这是 APG 明确要求的,也是最常做错的一条。
69
69
  打开后焦点在第一项,用户按一次`↓` 就跳到第二项 —— 原来选的那项被跳过了。
70
70
 
71
+ ⚠️ 这里的"焦点"是**活动项**:用 `aria-activedescendant` 指到选中项,DOM 焦点仍留在
72
+ 按钮上。这是 APG「select-only combobox」的合法形态 —— 拿 `document.activeElement`
73
+ 去要求它落进 `<ul>` 会判成缺陷,实际不是。本库的判据也是按前一种断的
74
+ (`opener-path-check` 里这条组件的 `focus` 声明为 `activedesc`)。
75
+
71
76
  ## 类型搜索(typeahead)
72
77
 
73
78
  连续打字跳到匹配项。**原生 `<select>` 有这个能力,所以自建的必须有**。
@@ -26,6 +26,7 @@
26
26
  var opt = opts || {};
27
27
  var escOn = opt.escOn || [];
28
28
  var inside = opt.inside || [];
29
+ var outside = opt.outside !== false;
29
30
  var when = opt.when || function () { return true; };
30
31
  var onDismiss = opt.onDismiss || function () {};
31
32
 
@@ -45,19 +46,7 @@
45
46
  if (!when()) return;
46
47
  var k = e.key;
47
48
  if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
48
- /* 🔴 为什么 onDismiss 在前、preventDefault/stopPropagation 在后
49
- (0.8.8 改):**分级 Esc** 需要核让组件有机会说"这次我没关"。
50
- combobox 的 Esc 是三级的:
51
- ① 列表开着 → 关列表
52
- ② 列表关了、输入有字 → 清空输入
53
- ③ 都没了 → 什么都不做(此时**不该**阻止默认行为,
54
- 让 Esc 能继续传给外层浮层)
55
- 而原实现是「无条件 preventDefault + stopPropagation,再调 onDismiss」
56
- ⇒ 组件根本无法表达"这次我没关"。
57
- ⇒ 改成:先问onDismiss,它返回 false 就**不干预**默认行为。
58
- ⚠️ 顺序安全性:stopPropagation 只需在**本次监听器执行期间**被调用即可
59
- 阻止冒泡到外层,放在 onDismiss 之后仍然有效。
60
- */
49
+ /* 先问 onDismiss,它返回 false 就不干预(分级 Esc,见文件头 ⑤) */
61
50
  var closed = onDismiss(e, 'esc');
62
51
  if (closed === false) return;
63
52
  if (e.preventDefault) e.preventDefault();
@@ -78,7 +67,10 @@
78
67
  for (var j = 0; j < escOn.length; j++) {
79
68
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
80
69
  }
81
- shared.stack.push(rec);
70
+ /* 只有"点外部要关"的层才进栈:栈是给 onDocClick 挑最内层用的,
71
+ 模态层不该被这条规则选中(见文件头 ④)。
72
+ ⚠️ Esc 不依赖栈(它挂在元素上),所以不进栈不影响 Esc。 */
73
+ if (outside) shared.stack.push(rec);
82
74
  if (!shared.bound) {
83
75
  shared.bound = true;
84
76
  document.addEventListener('click', onDocClick, true);
@@ -74,6 +74,73 @@
74
74
  };
75
75
  /* ==== BEHAVIOR INJECT END: focus-return ==== */
76
76
 
77
+ /* ==== BEHAVIOR INJECT BEGIN: dismissable ==== */
78
+ var flDismissable = function (opts) {
79
+ var opt = opts || {};
80
+ var escOn = opt.escOn || [];
81
+ var inside = opt.inside || [];
82
+ var outside = opt.outside !== false;
83
+ var when = opt.when || function () { return true; };
84
+ var onDismiss = opt.onDismiss || function () {};
85
+
86
+ var shared = window.__flDismiss || (window.__flDismiss = { stack: [], bound: false });
87
+
88
+ function isInside(node) {
89
+ if (!node) return false;
90
+ for (var i = 0; i < inside.length; i++) {
91
+ var el = inside[i];
92
+ if (!el || !el.contains) continue;
93
+ if (el === node || el.contains(node)) return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function onKey(e) {
99
+ if (!when()) return;
100
+ var k = e.key;
101
+ if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
102
+ /* 先问 onDismiss,它返回 false 就不干预(分级 Esc,见文件头 ⑤) */
103
+ var closed = onDismiss(e, 'esc');
104
+ if (closed === false) return;
105
+ if (e.preventDefault) e.preventDefault();
106
+ if (e.stopPropagation) e.stopPropagation();
107
+ }
108
+
109
+ function onDocClick(e) {
110
+ var top = null;
111
+ for (var i = shared.stack.length - 1; i >= 0; i--) {
112
+ if (shared.stack[i].when()) { top = shared.stack[i]; break; }
113
+ }
114
+ if (!top || top.isInside(e.target)) return;
115
+ top.onDismiss(e, 'outside');
116
+ }
117
+
118
+ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
119
+
120
+ for (var j = 0; j < escOn.length; j++) {
121
+ if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
122
+ }
123
+ /* 只有"点外部要关"的层才进栈:栈是给 onDocClick 挑最内层用的,
124
+ 模态层不该被这条规则选中(见文件头 ④)。
125
+ ⚠️ Esc 不依赖栈(它挂在元素上),所以不进栈不影响 Esc。 */
126
+ if (outside) shared.stack.push(rec);
127
+ if (!shared.bound) {
128
+ shared.bound = true;
129
+ document.addEventListener('click', onDocClick, true);
130
+ }
131
+
132
+ return {
133
+ destroy: function () {
134
+ for (var j = 0; j < escOn.length; j++) {
135
+ if (escOn[j]) escOn[j].removeEventListener('keydown', onKey);
136
+ }
137
+ var k = shared.stack.indexOf(rec);
138
+ if (k >= 0) shared.stack.splice(k, 1);
139
+ },
140
+ };
141
+ };
142
+ /* ==== BEHAVIOR INJECT END: dismissable ==== */
143
+
77
144
  var FOCUSABLE = [
78
145
  'a[href]',
79
146
  'button:not([disabled])',
@@ -204,12 +271,23 @@
204
271
  (f[0] || d).focus();
205
272
  }
206
273
 
274
+ /* ① Esc 与「点抽屉之外关闭」走 dismissable 核(0.14.0 收编)。
275
+ 0.8.6 曾**拒绝收编**,理由写得很实在:核当时只有"点外部一律关",
276
+ 而 drawer 是模态。现在核有了 `outside` 开关(模态层不进栈),
277
+ 收编不再需要改组件的语义 —— 只改接线。
278
+ ⚠️ inside 只放抽屉本体,**不能**把 host(遮罩)也放进去:
279
+ 放进去等于声明"点遮罩算内部","点遮罩关闭"立刻变成谎话
280
+ (nav 那一格踩过这个坑,见 ai/behaviors.json)。 */
281
+ var dm = flDismissable({
282
+ escOn: [d],
283
+ inside: [d],
284
+ when: function () { return !closed; },
285
+ onDismiss: function () { close(); },
286
+ });
287
+
207
288
  function onKey(e) {
208
- var k = e.key;
209
- /* ② Esc 关闭 */
210
- if (k === 'Escape' || k === 'Esc') { e.preventDefault(); close(); return; }
211
- if (k !== 'Tab') return;
212
- /* ① 焦点陷阱:Tab / Shift+Tab 在抽屉内循环 */
289
+ if (e.key !== 'Tab') return;
290
+ /* ② 焦点陷阱:Tab / Shift+Tab 在抽屉内循环(Esc 已交给核) */
213
291
  var list = [].slice.call(d.querySelectorAll(FOCUSABLE))
214
292
  .filter(function (x) { return x.offsetParent !== null; });
215
293
  if (!list.length) { e.preventDefault(); d.focus(); return; }
@@ -220,10 +298,8 @@
220
298
  e.preventDefault(); first.focus();
221
299
  }
222
300
  }
223
- function onBackdropClick(e) { if (e.target === host) close(); }
224
301
 
225
302
  d.addEventListener('keydown', onKey);
226
- host.addEventListener('click', onBackdropClick);
227
303
  closeBtn.addEventListener('click', function () { close(); });
228
304
 
229
305
  var closed = false;
@@ -232,7 +308,9 @@
232
308
  closed = true;
233
309
  d.setAttribute('data-state', 'leaving');
234
310
  d.removeEventListener('keydown', onKey);
235
- host.removeEventListener('click', onBackdropClick);
311
+ /* 核的监听器与栈记录必须一起摘:只摘 Esc 不摘栈的话,
312
+ 点外部仍会关到一个已经没了的抽屉(dismissable 门禁 ⑥ 守这条)。 */
313
+ dm.destroy();
236
314
  setBackgroundInert(false);
237
315
  unlockScroll();
238
316
  /* 动画结束再移除;reduced-motion 下没有 transition ⇒ 兜底定时器 */
@@ -10,6 +10,7 @@
10
10
  | `Esc` | 关闭,并把焦点**还给按钮** |
11
11
  | `Tab` | 关闭菜单(焦点移到下一个元素)|
12
12
  | `Enter` / `Space` | 激活当前项 |
13
+ | 字母 / 数字(**可选**) | 跳到名字以该前缀开头的项(APG 标为 `(Optional)`;见下) |
13
14
 
14
15
  ## ⚠️ 另有两条容易漏的
15
16
 
@@ -66,12 +67,27 @@ menu.setAttribute('role', 'menu');
66
67
  `data-state` / `data-value`,**不用 class 切换** —— 这是全库 INVARIANT I-8
67
68
  (状态必须活在 DOM 属性上),也是门禁会查的东西。
68
69
 
69
- ## 成熟度:beta(两条行为债,如实列出)
70
+ ## 成熟度:stable(行为零欠账)
70
71
 
71
- | 债 | 现在的后果 |
72
+ 两条历史债都收编了,记账在 `ai/behaviors.json`:
73
+
74
+ | 曾经 | 现在 |
72
75
  |---|---|
73
- | `focus-return:manual` | 关闭后归位是手写的,不是统一机制 |
74
- | `typeahead:gap` | **没有类型搜索** —— APG 的 Menu Button 不硬性要求,但列表长时体验缺失 |
76
+ | `focus-return:manual` | 0.8.6 收编:`open()` 里 `save()`、`close()` 里 `restore()`,替掉手写的 `btn.focus()` |
77
+ | `typeahead:gap` | 0.15.0 收编:打字跳项走 typeahead 核(见下) |
78
+
79
+ ### 打字跳项(APG 的 `(Optional)` 项)
80
+
81
+ 打一个字母 ⇒ 跳到**下一个**以它开头的项;快速连打 ⇒ 前缀累积;连打同一个字母 ⇒
82
+ 在匹配项之间循环;不匹配 ⇒ **什么都不做**(APG 不要求"没找到"有反馈)。
83
+
84
+ ⚠️ **它天然只对拉丁字母标签有效**:中文标签靠输入法,而 IME 组字时 `keydown`
85
+ 给的是 `Process` / `Unidentified`,拿不到字符。所以判据用一张英文夹具测
86
+ (`dropdown-check` ⑦),demo 的中文菜单不受影响、也不会因此变慢。
87
+
88
+ ⚠️ 匹配前会**剥掉开头的装饰符号**:带勾选的项文本是「✓ 按日期」,
89
+ 不剥的话打「按」永远匹配不到。这和 table 列头把 `↕` 念出来是同一类问题 ——
90
+ 装饰符号不该混进"名字"里。
75
91
 
76
92
  ## 反例
77
93
 
@@ -161,6 +161,7 @@
161
161
  var opt = opts || {};
162
162
  var escOn = opt.escOn || [];
163
163
  var inside = opt.inside || [];
164
+ var outside = opt.outside !== false;
164
165
  var when = opt.when || function () { return true; };
165
166
  var onDismiss = opt.onDismiss || function () {};
166
167
 
@@ -180,19 +181,7 @@
180
181
  if (!when()) return;
181
182
  var k = e.key;
182
183
  if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
183
- /* 🔴 为什么 onDismiss 在前、preventDefault/stopPropagation 在后
184
- (0.8.8 改):**分级 Esc** 需要核让组件有机会说"这次我没关"。
185
- combobox 的 Esc 是三级的:
186
- ① 列表开着 → 关列表
187
- ② 列表关了、输入有字 → 清空输入
188
- ③ 都没了 → 什么都不做(此时**不该**阻止默认行为,
189
- 让 Esc 能继续传给外层浮层)
190
- 而原实现是「无条件 preventDefault + stopPropagation,再调 onDismiss」
191
- ⇒ 组件根本无法表达"这次我没关"。
192
- ⇒ 改成:先问onDismiss,它返回 false 就**不干预**默认行为。
193
- ⚠️ 顺序安全性:stopPropagation 只需在**本次监听器执行期间**被调用即可
194
- 阻止冒泡到外层,放在 onDismiss 之后仍然有效。
195
- */
184
+ /* 先问 onDismiss,它返回 false 就不干预(分级 Esc,见文件头 ⑤) */
196
185
  var closed = onDismiss(e, 'esc');
197
186
  if (closed === false) return;
198
187
  if (e.preventDefault) e.preventDefault();
@@ -213,7 +202,10 @@
213
202
  for (var j = 0; j < escOn.length; j++) {
214
203
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
215
204
  }
216
- shared.stack.push(rec);
205
+ /* 只有"点外部要关"的层才进栈:栈是给 onDocClick 挑最内层用的,
206
+ 模态层不该被这条规则选中(见文件头 ④)。
207
+ ⚠️ Esc 不依赖栈(它挂在元素上),所以不进栈不影响 Esc。 */
208
+ if (outside) shared.stack.push(rec);
217
209
  if (!shared.bound) {
218
210
  shared.bound = true;
219
211
  document.addEventListener('click', onDocClick, true);
@@ -231,6 +223,72 @@
231
223
  };
232
224
  /* ==== BEHAVIOR INJECT END: dismissable ==== */
233
225
 
226
+ /* ==== BEHAVIOR INJECT BEGIN: typeahead ==== */
227
+ var flTypeahead = function (opts) {
228
+ var opt = opts || {};
229
+ var items = opt.items || function () { return []; };
230
+ var textOf = opt.textOf || function (x) { return String((x && x.textContent) || ''); };
231
+ var usable = opt.usable || function () { return true; };
232
+ var timeout = opt.timeout || 500;
233
+ var onHit = opt.onHit || function () {};
234
+ var buf = '';
235
+ var timer = null;
236
+ var last = -1;
237
+
238
+ function stop() {
239
+ if (timer) { clearTimeout(timer); timer = null; }
240
+ }
241
+
242
+ /* 连打同一字符 ⇒ 只按首字符匹配(理由见文件头 ③④)*/
243
+ function repeated(s) {
244
+ if (s.length < 2) return false;
245
+ for (var i = 1; i < s.length; i++) {
246
+ if (s.charAt(i) !== s.charAt(0)) return false;
247
+ }
248
+ return true;
249
+ }
250
+
251
+ function find(needle) {
252
+ var l = items() || [];
253
+ var n = l.length;
254
+ if (!n) return -1;
255
+ for (var k = 0; k < n; k++) {
256
+ var i = (last + 1 + k) % n; /* ① 从当前项之后开始 ⇒ 会绕回 */
257
+ if (!usable(l[i])) continue; /* ⑤ */
258
+ if (textOf(l[i]).toLowerCase().indexOf(needle) === 0) return i;
259
+ }
260
+ return -1; /* ⑥ */
261
+ }
262
+
263
+ function type(ch) {
264
+ if (!ch) return -1;
265
+ buf += String(ch).toLowerCase(); /* ② */
266
+ stop();
267
+ timer = setTimeout(clear, timeout); /* ④ */
268
+ var needle = repeated(buf) ? buf.charAt(0) : buf; /* ③ */
269
+ var i = find(needle);
270
+ if (i >= 0) {
271
+ last = i;
272
+ onHit(i, (items() || [])[i], buf);
273
+ }
274
+ return i;
275
+ }
276
+
277
+ /* 只清缓冲,不清 last(理由见文件头)*/
278
+ function clear() {
279
+ buf = '';
280
+ stop();
281
+ }
282
+
283
+ return {
284
+ type: type,
285
+ clear: clear,
286
+ buffer: function () { return buf; },
287
+ destroy: function () { stop(); },
288
+ };
289
+ };
290
+ /* ==== BEHAVIOR INJECT END: typeahead ==== */
291
+
234
292
  /* ---- 旧环境兼容:Element.closest 在老 WebView 上不存在 ----
235
293
  直接调用会在真机上抛 `is not a function`(本地完全正常,
236
294
  只在老环境炸 —— 属静默失效)。这里做模块内兜底,
@@ -323,6 +381,8 @@
323
381
  焦点留在按钮上,菜单项永远进不去(真机用 NVDA + Edge 实测过)。
324
382
  APG 的菜单按钮也是这样:打开即把焦点交给第一项。 */
325
383
  focusAt(0);
384
+ /* 每次打开都从空缓冲开始:上次关菜单前打了一半的前缀不该留到这次 */
385
+ typeahead.clear();
326
386
  flEmit(root, 'fl-open', null);
327
387
  if (opt.onOpen) opt.onOpen();
328
388
  }
@@ -332,6 +392,7 @@
332
392
  menu.hidden = true;
333
393
  btn.setAttribute('aria-expanded', 'false');
334
394
  active = -1;
395
+ typeahead.clear();
335
396
  if (restoreFocus !== false) fr.restore();
336
397
  flEmit(root, 'fl-close', null);
337
398
  if (opt.onClose) opt.onClose();
@@ -367,6 +428,22 @@
367
428
  onMove: function (i) { focusAt(i); },
368
429
  });
369
430
 
431
+ /* ⑥ 打字跳项 —— APG Menu 的 `(Optional)` 项,走 typeahead 核。
432
+ 匹配**可见文本**,但先剥掉开头的装饰符号:带勾选的项文本是
433
+ "✓ 按日期",不剥的话打"按"永远匹配不到(同 table 列头 ↕ 那一类问题)。
434
+ ⚠️ 中文标签实际上打不出来:IME 走 keydown 时 key 是 Process / Unidentified。
435
+ 这条是给拉丁字母标签的菜单用的 —— APG 的 typeahead 本来就是这个前提,
436
+ 所以判据用一张英文夹具页测(见 dropdown-check ⑥)。 */
437
+ var typeahead = flTypeahead({
438
+ items: function () { return itemsOf(menu); },
439
+ textOf: function (el) {
440
+ var s = String((el && el.textContent) || '').replace(/\s+/g, ' ').trim();
441
+ return s.replace(/^[^0-9A-Za-z\u4e00-\u9fa5]+/, '');
442
+ },
443
+ usable: function (el) { return el.getAttribute('aria-disabled') !== 'true'; },
444
+ onHit: function (i) { focusAt(i); },
445
+ });
446
+
370
447
  /* ---------- 按钮 ---------- */
371
448
  /* 🔴 修:原来先 `setAttribute('aria-controls', menu.id || '')`
372
449
  **再**给 menu.id ⇒ aria-controls 永远拿到空串。
@@ -424,6 +501,17 @@
424
501
  留在原地会**关两次**(同一元素上的两个监听器,stopPropagation 挡不住
425
502
  同级的另一个监听器,那是 stopImmediatePropagation 的活)⇒ onClose 被调两遍。 */
426
503
 
504
+ /* ③b 可打印字符 ⇒ 跳到名字以它开头的项(APG 的 (Optional) 项)
505
+ · 空格排除:它的语义是"激活当前项"(下面 ④)
506
+ · 带 Ctrl / Meta / Alt 排除:那是浏览器快捷键
507
+ · isComposing 时排除:中文输入法正在组字,不能抢 */
508
+ if (k.length === 1 && k !== ' ' &&
509
+ !e.ctrlKey && !e.metaKey && !e.altKey && !e.isComposing) {
510
+ e.preventDefault();
511
+ typeahead.type(k);
512
+ return;
513
+ }
514
+
427
515
  /* ③ Tab 关闭(不拦截,让焦点自然走到下一个)*/
428
516
  if (k === 'Tab') { close(false); return; }
429
517
 
@@ -448,7 +536,10 @@
448
536
  return {
449
537
  open: open,
450
538
  close: close,
451
- destroy: function () { roving.destroy(); dismiss.destroy(); menu.hidden = true; },
539
+ destroy: function () {
540
+ roving.destroy(); dismiss.destroy(); typeahead.destroy();
541
+ menu.hidden = true;
542
+ },
452
543
  };
453
544
  }
454
545
 
@@ -126,6 +126,7 @@
126
126
  var opt = opts || {};
127
127
  var escOn = opt.escOn || [];
128
128
  var inside = opt.inside || [];
129
+ var outside = opt.outside !== false;
129
130
  var when = opt.when || function () { return true; };
130
131
  var onDismiss = opt.onDismiss || function () {};
131
132
 
@@ -145,19 +146,7 @@
145
146
  if (!when()) return;
146
147
  var k = e.key;
147
148
  if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
148
- /* 🔴 为什么 onDismiss 在前、preventDefault/stopPropagation 在后
149
- (0.8.8 改):**分级 Esc** 需要核让组件有机会说"这次我没关"。
150
- combobox 的 Esc 是三级的:
151
- ① 列表开着 → 关列表
152
- ② 列表关了、输入有字 → 清空输入
153
- ③ 都没了 → 什么都不做(此时**不该**阻止默认行为,
154
- 让 Esc 能继续传给外层浮层)
155
- 而原实现是「无条件 preventDefault + stopPropagation,再调 onDismiss」
156
- ⇒ 组件根本无法表达"这次我没关"。
157
- ⇒ 改成:先问onDismiss,它返回 false 就**不干预**默认行为。
158
- ⚠️ 顺序安全性:stopPropagation 只需在**本次监听器执行期间**被调用即可
159
- 阻止冒泡到外层,放在 onDismiss 之后仍然有效。
160
- */
149
+ /* 先问 onDismiss,它返回 false 就不干预(分级 Esc,见文件头 ⑤) */
161
150
  var closed = onDismiss(e, 'esc');
162
151
  if (closed === false) return;
163
152
  if (e.preventDefault) e.preventDefault();
@@ -178,7 +167,10 @@
178
167
  for (var j = 0; j < escOn.length; j++) {
179
168
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
180
169
  }
181
- shared.stack.push(rec);
170
+ /* 只有"点外部要关"的层才进栈:栈是给 onDocClick 挑最内层用的,
171
+ 模态层不该被这条规则选中(见文件头 ④)。
172
+ ⚠️ Esc 不依赖栈(它挂在元素上),所以不进栈不影响 Esc。 */
173
+ if (outside) shared.stack.push(rec);
182
174
  if (!shared.bound) {
183
175
  shared.bound = true;
184
176
  document.addEventListener('click', onDocClick, true);