agentcss 0.13.1 → 0.14.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 ⇒ 兜底定时器 */
@@ -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);
@@ -317,6 +309,12 @@
317
309
  menu.hidden = false;
318
310
  btn.setAttribute('aria-expanded', 'true');
319
311
  active = -1;
312
+ /* ⭐ 聚焦首项必须放在 open() 里,不能只写在键盘分支。
313
+ 读屏用户在浏览模式下按 Enter,NVDA 会把它变成**一次点击**,
314
+ 走的是 click 分支 ⇒ 键盘分支里的 `focusAt(0)` 根本没机会执行,
315
+ 焦点留在按钮上,菜单项永远进不去(真机用 NVDA + Edge 实测过)。
316
+ APG 的菜单按钮也是这样:打开即把焦点交给第一项。 */
317
+ focusAt(0);
320
318
  flEmit(root, 'fl-open', null);
321
319
  if (opt.onOpen) opt.onOpen();
322
320
  }
@@ -391,12 +389,11 @@
391
389
  root.addEventListener('keydown', function (e) {
392
390
  var k = e.key;
393
391
 
394
- /* 按钮上:↓ / Enter / Space 打开并聚焦首项 */
392
+ /* 按钮上:↓ / Enter / Space 打开(聚焦首项已由 open() 负责) */
395
393
  if (e.target === btn) {
396
394
  if (k === 'ArrowDown' || k === 'Enter' || k === ' ' || k === 'Spacebar') {
397
395
  e.preventDefault();
398
396
  open();
399
- focusAt(0);
400
397
  }
401
398
  return;
402
399
  }
@@ -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);
@@ -357,6 +357,73 @@
357
357
  };
358
358
  };
359
359
  /* ==== BEHAVIOR INJECT END: focus-return ==== */
360
+
361
+ /* ==== BEHAVIOR INJECT BEGIN: dismissable ==== */
362
+ var flDismissable = function (opts) {
363
+ var opt = opts || {};
364
+ var escOn = opt.escOn || [];
365
+ var inside = opt.inside || [];
366
+ var outside = opt.outside !== false;
367
+ var when = opt.when || function () { return true; };
368
+ var onDismiss = opt.onDismiss || function () {};
369
+
370
+ var shared = window.__flDismiss || (window.__flDismiss = { stack: [], bound: false });
371
+
372
+ function isInside(node) {
373
+ if (!node) return false;
374
+ for (var i = 0; i < inside.length; i++) {
375
+ var el = inside[i];
376
+ if (!el || !el.contains) continue;
377
+ if (el === node || el.contains(node)) return true;
378
+ }
379
+ return false;
380
+ }
381
+
382
+ function onKey(e) {
383
+ if (!when()) return;
384
+ var k = e.key;
385
+ if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
386
+ /* 先问 onDismiss,它返回 false 就不干预(分级 Esc,见文件头 ⑤) */
387
+ var closed = onDismiss(e, 'esc');
388
+ if (closed === false) return;
389
+ if (e.preventDefault) e.preventDefault();
390
+ if (e.stopPropagation) e.stopPropagation();
391
+ }
392
+
393
+ function onDocClick(e) {
394
+ var top = null;
395
+ for (var i = shared.stack.length - 1; i >= 0; i--) {
396
+ if (shared.stack[i].when()) { top = shared.stack[i]; break; }
397
+ }
398
+ if (!top || top.isInside(e.target)) return;
399
+ top.onDismiss(e, 'outside');
400
+ }
401
+
402
+ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
403
+
404
+ for (var j = 0; j < escOn.length; j++) {
405
+ if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
406
+ }
407
+ /* 只有"点外部要关"的层才进栈:栈是给 onDocClick 挑最内层用的,
408
+ 模态层不该被这条规则选中(见文件头 ④)。
409
+ ⚠️ Esc 不依赖栈(它挂在元素上),所以不进栈不影响 Esc。 */
410
+ if (outside) shared.stack.push(rec);
411
+ if (!shared.bound) {
412
+ shared.bound = true;
413
+ document.addEventListener('click', onDocClick, true);
414
+ }
415
+
416
+ return {
417
+ destroy: function () {
418
+ for (var j = 0; j < escOn.length; j++) {
419
+ if (escOn[j]) escOn[j].removeEventListener('keydown', onKey);
420
+ }
421
+ var k = shared.stack.indexOf(rec);
422
+ if (k >= 0) shared.stack.splice(k, 1);
423
+ },
424
+ };
425
+ };
426
+ /* ==== BEHAVIOR INJECT END: dismissable ==== */
360
427
  function dialog(opts) {
361
428
  opts = opts || {};
362
429
 
@@ -432,26 +499,25 @@
432
499
  openStack.push(stackEntry);
433
500
  if (!useInert) uninstallIsolation = installIsolation(stackEntry);
434
501
 
502
+ /* ① Esc 走 dismissable 核(0.14.0 收编)。
503
+ `outside: false` 是模态的定义:点外部**不该关**。核里这个开关专门
504
+ 为它加的(不进栈 ⇒ 不参与"点外部关最内层"的仲裁)。
505
+ ⚠️ 点遮罩关闭是**另一件事**,不归核管:它带 danger / closeOnBackdrop
506
+ 两个前置条件,是组件自己的语义(见下方 mousedown)。
507
+ 关于"为什么不再手写 stopPropagation":核在 onDismiss 之后统一
508
+ preventDefault + stopPropagation,一次 Esc 只关最内层的语义与
509
+ 以前实测一致(两个 backdrop 是兄弟节点,本来也冒泡不到外层)。 */
510
+ var dm = flDismissable({
511
+ escOn: [box],
512
+ outside: false,
513
+ when: function () { return !closed; },
514
+ onDismiss: function () { close(); },
515
+ });
516
+
435
517
  /* ------------------------------------------------------------ 焦点 */
436
518
  function focusables() { return focusableIn(box); }
437
519
 
438
520
  box.addEventListener('keydown', function (e) {
439
- if (e.key === 'Escape') {
440
- e.preventDefault();
441
- /* ⚠️ 防御性 stopPropagation —— 保留,但**当前结构下它不改变任何行为**。
442
- * 等价突变体实测:删掉这一行,一次 Esc 仍然只关最内层。
443
- * 原因:两个 `.dialog-backdrop` 是 `body` 的**兄弟**节点,外层 box 不是
444
- * 内层的祖先,所以内层按钮的 keydown 冒泡路径是
445
- * button → .dialog → .dialog-backdrop → body
446
- * —— 根本到不了外层的 `.dialog`(实测 `outer.contains(inner) === false`)。
447
- * 原注释写「不拦住会一次关两层」,那是**假设了 DOM 嵌套**,与实现不符。
448
- * 保留理由:若将来改成「内层放进外层 box」的真嵌套,这行就必需。
449
- * 判据 `pattern-behavior-check.js ⑬` 验证的是**行为**(只关最内层),
450
- * 它抓不到删除本行的突变 —— 那是等价突变体,不是判据失效。 */
451
- e.stopPropagation();
452
- close();
453
- return;
454
- }
455
521
  if (e.key !== 'Tab') return;
456
522
 
457
523
  /* focus trap:Tab 在弹层内循环,绝不跑到背后的页面去 */
@@ -527,6 +593,9 @@
527
593
  function close() {
528
594
  if (closed) return;
529
595
  closed = true;
596
+ /* 核的 Esc 监听器要摘(outside:false ⇒ 它本来就没进栈,
597
+ 但 destroy 仍然必须调用:摘监听器与摘栈是同一件事的两半)。 */
598
+ dm.destroy();
530
599
 
531
600
  /* 🔴 焦点归还前先看清**焦点现在在谁手里**(必须在移除 backdrop 之前读,
532
601
  移除之后 activeElement 会变成 body)。
@@ -60,6 +60,7 @@ var flDismissable = function (opts) {
60
60
  var opt = opts || {};
61
61
  var escOn = opt.escOn || [];
62
62
  var inside = opt.inside || [];
63
+ var outside = opt.outside !== false;
63
64
  var when = opt.when || function () { return true; };
64
65
  var onDismiss = opt.onDismiss || function () {};
65
66
 
@@ -79,19 +80,7 @@ var flDismissable = function (opts) {
79
80
  if (!when()) return;
80
81
  var k = e.key;
81
82
  if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
82
- /* 🔴 为什么 onDismiss 在前、preventDefault/stopPropagation 在后
83
- (0.8.8 改):**分级 Esc** 需要核让组件有机会说"这次我没关"。
84
- combobox 的 Esc 是三级的:
85
- ① 列表开着 → 关列表
86
- ② 列表关了、输入有字 → 清空输入
87
- ③ 都没了 → 什么都不做(此时**不该**阻止默认行为,
88
- 让 Esc 能继续传给外层浮层)
89
- 而原实现是「无条件 preventDefault + stopPropagation,再调 onDismiss」
90
- ⇒ 组件根本无法表达"这次我没关"。
91
- ⇒ 改成:先问onDismiss,它返回 false 就**不干预**默认行为。
92
- ⚠️ 顺序安全性:stopPropagation 只需在**本次监听器执行期间**被调用即可
93
- 阻止冒泡到外层,放在 onDismiss 之后仍然有效。
94
- */
83
+ /* 先问 onDismiss,它返回 false 就不干预(分级 Esc,见文件头 ⑤) */
95
84
  var closed = onDismiss(e, 'esc');
96
85
  if (closed === false) return;
97
86
  if (e.preventDefault) e.preventDefault();
@@ -112,7 +101,10 @@ var flDismissable = function (opts) {
112
101
  for (var j = 0; j < escOn.length; j++) {
113
102
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
114
103
  }
115
- shared.stack.push(rec);
104
+ /* 只有"点外部要关"的层才进栈:栈是给 onDocClick 挑最内层用的,
105
+ 模态层不该被这条规则选中(见文件头 ④)。
106
+ ⚠️ Esc 不依赖栈(它挂在元素上),所以不进栈不影响 Esc。 */
107
+ if (outside) shared.stack.push(rec);
116
108
  if (!shared.bound) {
117
109
  shared.bound = true;
118
110
  document.addEventListener('click', onDocClick, true);
@@ -72,10 +72,25 @@
72
72
  outline-offset: var(--focus-ring-offset);
73
73
  }
74
74
 
75
+ /* ⭐ 排序指示符改用 mask 画,不再用文字字形。
76
+ 原因:浏览器会把 ::after 的 `content` 文本算进**可访问名**(name-from-contents,
77
+ `<button>` / `<a>` 这类),读屏于是念出「公司 · ↕ · 按钮」—— 这个符号对用户毫无意义,
78
+ 而且自动检查抓不到(名字非空即过)。换成图像后字形不进无障碍树。
79
+ ⚠️ 不要改回 `content: "↕"` 的字形写法,那会把符号念出来。
80
+ ⚠️ 也先别改成 `content: "↕" / ""` 的替代文本语法:三引擎实测**都支持**、也都能清掉 ↕,
81
+ 但它要 Chromium 108 / Firefox 128 / Safari 17.4 起才认,更老的引擎会丢掉整条声明
82
+ ⇒ 箭头彻底消失(支持矩阵里有 Chromium 97,正好在门槛外)。
83
+ 实测矩阵见 docs/A11Y-MANUAL-PROTOCOL.md。 */
75
84
  .table__sort::after {
76
- content: "↕"; /* 未排序:上下双向箭头 */
85
+ content: ""; /* 空内容 ⇒ 不进可访问名 */
86
+ inline-size: .85em;
87
+ block-size: .85em;
88
+ flex: none;
89
+ background-color: currentColor; /* 跟着文字颜色走,暗色自动适配 */
77
90
  opacity: .45;
78
- font-size: .9em;
91
+ -webkit-mask: var(--sort-glyph) center / contain no-repeat;
92
+ mask: var(--sort-glyph) center / contain no-repeat;
93
+ --sort-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1.4 5.2 4.6h1.7v2.6h2.2V4.6h1.7z'/%3E%3Cpath d='M8 14.6 10.8 11.4h-1.7V8.8H6.9v2.6H5.2z'/%3E%3C/svg%3E");
79
94
  }
80
95
 
81
96
  /* 🔴🔴 排序指示符:三态必须**同时**用形状 + 颜色表达(WCAG 1.4.1)
@@ -95,13 +110,13 @@
95
110
  ⇒ 两条选择器并存,任何一种写法都能把箭头点亮。 */
96
111
  .table th[aria-sort="ascending"] .table__sort::after,
97
112
  .table__sort[aria-sort="ascending"]::after {
98
- content: "↑";
113
+ --sort-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1.6 4.6 6h2.1v8h2.6V6h2.1z'/%3E%3C/svg%3E");
99
114
  opacity: 1;
100
115
  }
101
116
 
102
117
  .table th[aria-sort="descending"] .table__sort::after,
103
118
  .table__sort[aria-sort="descending"]::after {
104
- content: "↓";
119
+ --sort-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 14.4 11.4 10h-2.1V2H6.7v8H4.6z'/%3E%3C/svg%3E");
105
120
  opacity: 1;
106
121
  }
107
122