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.
- package/01-tokens/behavior/dismissable.js +31 -15
- package/02-primitives/combobox/combobox.js +6 -14
- package/02-primitives/popover/popover.js +6 -14
- package/02-primitives/select/README.md +5 -0
- package/02-primitives/select/select.js +6 -14
- package/03-patterns/drawer/drawer.js +86 -8
- package/03-patterns/dropdown/dropdown.js +13 -16
- package/03-patterns/nav/nav.js +6 -14
- package/03-patterns/overlay/overlay.js +85 -16
- package/03-patterns/tooltip/tooltip.js +6 -14
- package/04-recipes/table/table.css +19 -4
- package/CHANGELOG.md +149 -0
- package/CONTRIBUTING.md +1 -1
- package/README.zh-CN.md +1 -1
- package/START-HERE.md +10 -10
- package/SUPPORT.md +7 -1
- package/ai/START-HERE.md +1 -1
- package/ai/behaviors.json +8 -8
- package/ai/components.json +11 -17
- package/dist/01-tokens/behavior/dismissable.js +3 -1
- package/dist/02-primitives/combobox/combobox.js +3 -1
- package/dist/02-primitives/popover/popover.js +3 -1
- package/dist/02-primitives/select/select.js +3 -1
- package/dist/03-patterns/drawer/drawer.js +73 -7
- package/dist/03-patterns/dropdown/dropdown.js +5 -2
- package/dist/03-patterns/nav/nav.js +3 -1
- package/dist/03-patterns/overlay/overlay.js +72 -7
- package/dist/03-patterns/tooltip/tooltip.js +3 -1
- package/dist/04-recipes/table/table.css +1 -1
- package/dist/COSTS.md +9 -9
- package/docs/A11Y-MANUAL-PROTOCOL.md +104 -20
- package/docs/ACCESSIBILITY.md +26 -12
- package/docs/CONVENTIONS.md +9 -0
- package/index.html +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
/*
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
}
|
package/03-patterns/nav/nav.js
CHANGED
|
@@ -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
|
-
/*
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|