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.
- 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/README.md +20 -4
- package/03-patterns/dropdown/dropdown.js +106 -15
- 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 +8 -4
- package/CHANGELOG.md +166 -2
- 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 +10 -10
- package/ai/components.json +12 -21
- 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 +89 -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/COSTS.md +10 -10
- package/docs/A11Y-MANUAL-PROTOCOL.md +84 -23
- package/docs/ACCESSIBILITY.md +11 -6
- 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 ⇒ 兜底定时器 */
|
|
@@ -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
|
-
## 成熟度:
|
|
70
|
+
## 成熟度:stable(行为零欠账)
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
两条历史债都收编了,记账在 `ai/behaviors.json`:
|
|
73
|
+
|
|
74
|
+
| 曾经 | 现在 |
|
|
72
75
|
|---|---|
|
|
73
|
-
| `focus-return:manual` |
|
|
74
|
-
| `typeahead:gap` |
|
|
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
|
-
/*
|
|
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);
|
|
@@ -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 () {
|
|
539
|
+
destroy: function () {
|
|
540
|
+
roving.destroy(); dismiss.destroy(); typeahead.destroy();
|
|
541
|
+
menu.hidden = true;
|
|
542
|
+
},
|
|
452
543
|
};
|
|
453
544
|
}
|
|
454
545
|
|
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);
|