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.
@@ -5,6 +5,7 @@ var flDismissable = function (opts) {
5
5
  var opt = opts || {};
6
6
  var escOn = opt.escOn || [];
7
7
  var inside = opt.inside || [];
8
+ var outside = opt.outside !== false;
8
9
  var when = opt.when || function () { return true; };
9
10
  var onDismiss = opt.onDismiss || function () {};
10
11
 
@@ -45,7 +46,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
45
46
  for (var j = 0; j < escOn.length; j++) {
46
47
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
47
48
  }
48
- shared.stack.push(rec);
49
+
50
+ if (outside) shared.stack.push(rec);
49
51
  if (!shared.bound) {
50
52
  shared.bound = true;
51
53
  document.addEventListener('click', onDocClick, true);
@@ -5,6 +5,7 @@ var flDismissable = function (opts) {
5
5
  var opt = opts || {};
6
6
  var escOn = opt.escOn || [];
7
7
  var inside = opt.inside || [];
8
+ var outside = opt.outside !== false;
8
9
  var when = opt.when || function () { return true; };
9
10
  var onDismiss = opt.onDismiss || function () {};
10
11
 
@@ -45,7 +46,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
45
46
  for (var j = 0; j < escOn.length; j++) {
46
47
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
47
48
  }
48
- shared.stack.push(rec);
49
+
50
+ if (outside) shared.stack.push(rec);
49
51
  if (!shared.bound) {
50
52
  shared.bound = true;
51
53
  document.addEventListener('click', onDocClick, true);
@@ -5,6 +5,7 @@ var flDismissable = function (opts) {
5
5
  var opt = opts || {};
6
6
  var escOn = opt.escOn || [];
7
7
  var inside = opt.inside || [];
8
+ var outside = opt.outside !== false;
8
9
  var when = opt.when || function () { return true; };
9
10
  var onDismiss = opt.onDismiss || function () {};
10
11
 
@@ -45,7 +46,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
45
46
  for (var j = 0; j < escOn.length; j++) {
46
47
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
47
48
  }
48
- shared.stack.push(rec);
49
+
50
+ if (outside) shared.stack.push(rec);
49
51
  if (!shared.bound) {
50
52
  shared.bound = true;
51
53
  document.addEventListener('click', onDocClick, true);
@@ -55,6 +55,69 @@ clear: function () { saved = null; },
55
55
  };
56
56
  };
57
57
 
58
+ var flDismissable = function (opts) {
59
+ var opt = opts || {};
60
+ var escOn = opt.escOn || [];
61
+ var inside = opt.inside || [];
62
+ var outside = opt.outside !== false;
63
+ var when = opt.when || function () { return true; };
64
+ var onDismiss = opt.onDismiss || function () {};
65
+
66
+ var shared = window.__flDismiss || (window.__flDismiss = { stack: [], bound: false });
67
+
68
+ function isInside(node) {
69
+ if (!node) return false;
70
+ for (var i = 0; i < inside.length; i++) {
71
+ var el = inside[i];
72
+ if (!el || !el.contains) continue;
73
+ if (el === node || el.contains(node)) return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ function onKey(e) {
79
+ if (!when()) return;
80
+ var k = e.key;
81
+ if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
82
+
83
+ var closed = onDismiss(e, 'esc');
84
+ if (closed === false) return;
85
+ if (e.preventDefault) e.preventDefault();
86
+ if (e.stopPropagation) e.stopPropagation();
87
+ }
88
+
89
+ function onDocClick(e) {
90
+ var top = null;
91
+ for (var i = shared.stack.length - 1; i >= 0; i--) {
92
+ if (shared.stack[i].when()) { top = shared.stack[i]; break; }
93
+ }
94
+ if (!top || top.isInside(e.target)) return;
95
+ top.onDismiss(e, 'outside');
96
+ }
97
+
98
+ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
99
+
100
+ for (var j = 0; j < escOn.length; j++) {
101
+ if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
102
+ }
103
+
104
+ if (outside) shared.stack.push(rec);
105
+ if (!shared.bound) {
106
+ shared.bound = true;
107
+ document.addEventListener('click', onDocClick, true);
108
+ }
109
+
110
+ return {
111
+ destroy: function () {
112
+ for (var j = 0; j < escOn.length; j++) {
113
+ if (escOn[j]) escOn[j].removeEventListener('keydown', onKey);
114
+ }
115
+ var k = shared.stack.indexOf(rec);
116
+ if (k >= 0) shared.stack.splice(k, 1);
117
+ },
118
+ };
119
+ };
120
+
58
121
  var FOCUSABLE = [
59
122
  'a[href]',
60
123
  'button:not([disabled])',
@@ -176,11 +239,15 @@ else (f[0] || d).focus();
176
239
  (f[0] || d).focus();
177
240
  }
178
241
 
179
- function onKey(e) {
180
- var k = e.key;
242
+ var dm = flDismissable({
243
+ escOn: [d],
244
+ inside: [d],
245
+ when: function () { return !closed; },
246
+ onDismiss: function () { close(); },
247
+ });
181
248
 
182
- if (k === 'Escape' || k === 'Esc') { e.preventDefault(); close(); return; }
183
- if (k !== 'Tab') return;
249
+ function onKey(e) {
250
+ if (e.key !== 'Tab') return;
184
251
 
185
252
  var list = [].slice.call(d.querySelectorAll(FOCUSABLE))
186
253
  .filter(function (x) { return x.offsetParent !== null; });
@@ -192,10 +259,8 @@ e.preventDefault(); last.focus();
192
259
  e.preventDefault(); first.focus();
193
260
  }
194
261
  }
195
- function onBackdropClick(e) { if (e.target === host) close(); }
196
262
 
197
263
  d.addEventListener('keydown', onKey);
198
- host.addEventListener('click', onBackdropClick);
199
264
  closeBtn.addEventListener('click', function () { close(); });
200
265
 
201
266
  var closed = false;
@@ -204,7 +269,8 @@ if (closed) return;
204
269
  closed = true;
205
270
  d.setAttribute('data-state', 'leaving');
206
271
  d.removeEventListener('keydown', onKey);
207
- host.removeEventListener('click', onBackdropClick);
272
+
273
+ dm.destroy();
208
274
  setBackgroundInert(false);
209
275
  unlockScroll();
210
276
 
@@ -137,6 +137,7 @@ var flDismissable = function (opts) {
137
137
  var opt = opts || {};
138
138
  var escOn = opt.escOn || [];
139
139
  var inside = opt.inside || [];
140
+ var outside = opt.outside !== false;
140
141
  var when = opt.when || function () { return true; };
141
142
  var onDismiss = opt.onDismiss || function () {};
142
143
 
@@ -177,7 +178,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
177
178
  for (var j = 0; j < escOn.length; j++) {
178
179
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
179
180
  }
180
- shared.stack.push(rec);
181
+
182
+ if (outside) shared.stack.push(rec);
181
183
  if (!shared.bound) {
182
184
  shared.bound = true;
183
185
  document.addEventListener('click', onDocClick, true);
@@ -194,6 +196,68 @@ if (k >= 0) shared.stack.splice(k, 1);
194
196
  };
195
197
  };
196
198
 
199
+ var flTypeahead = function (opts) {
200
+ var opt = opts || {};
201
+ var items = opt.items || function () { return []; };
202
+ var textOf = opt.textOf || function (x) { return String((x && x.textContent) || ''); };
203
+ var usable = opt.usable || function () { return true; };
204
+ var timeout = opt.timeout || 500;
205
+ var onHit = opt.onHit || function () {};
206
+ var buf = '';
207
+ var timer = null;
208
+ var last = -1;
209
+
210
+ function stop() {
211
+ if (timer) { clearTimeout(timer); timer = null; }
212
+ }
213
+
214
+ function repeated(s) {
215
+ if (s.length < 2) return false;
216
+ for (var i = 1; i < s.length; i++) {
217
+ if (s.charAt(i) !== s.charAt(0)) return false;
218
+ }
219
+ return true;
220
+ }
221
+
222
+ function find(needle) {
223
+ var l = items() || [];
224
+ var n = l.length;
225
+ if (!n) return -1;
226
+ for (var k = 0; k < n; k++) {
227
+ var i = (last + 1 + k) % n;
228
+ if (!usable(l[i])) continue;
229
+ if (textOf(l[i]).toLowerCase().indexOf(needle) === 0) return i;
230
+ }
231
+ return -1;
232
+ }
233
+
234
+ function type(ch) {
235
+ if (!ch) return -1;
236
+ buf += String(ch).toLowerCase();
237
+ stop();
238
+ timer = setTimeout(clear, timeout);
239
+ var needle = repeated(buf) ? buf.charAt(0) : buf;
240
+ var i = find(needle);
241
+ if (i >= 0) {
242
+ last = i;
243
+ onHit(i, (items() || [])[i], buf);
244
+ }
245
+ return i;
246
+ }
247
+
248
+ function clear() {
249
+ buf = '';
250
+ stop();
251
+ }
252
+
253
+ return {
254
+ type: type,
255
+ clear: clear,
256
+ buffer: function () { return buf; },
257
+ destroy: function () { stop(); },
258
+ };
259
+ };
260
+
197
261
  function closest(el, sel) {
198
262
  if (!el) return null;
199
263
  if (el.closest) return el.closest(sel);
@@ -267,6 +331,8 @@ btn.setAttribute('aria-expanded', 'true');
267
331
  active = -1;
268
332
 
269
333
  focusAt(0);
334
+
335
+ typeahead.clear();
270
336
  flEmit(root, 'fl-open', null);
271
337
  if (opt.onOpen) opt.onOpen();
272
338
  }
@@ -276,6 +342,7 @@ function close(restoreFocus) {
276
342
  menu.hidden = true;
277
343
  btn.setAttribute('aria-expanded', 'false');
278
344
  active = -1;
345
+ typeahead.clear();
279
346
  if (restoreFocus !== false) fr.restore();
280
347
  flEmit(root, 'fl-close', null);
281
348
  if (opt.onClose) opt.onClose();
@@ -305,6 +372,16 @@ tabindex: false,
305
372
  onMove: function (i) { focusAt(i); },
306
373
  });
307
374
 
375
+ var typeahead = flTypeahead({
376
+ items: function () { return itemsOf(menu); },
377
+ textOf: function (el) {
378
+ var s = String((el && el.textContent) || '').replace(/\s+/g, ' ').trim();
379
+ return s.replace(/^[^0-9A-Za-z\u4e00-\u9fa5]+/, '');
380
+ },
381
+ usable: function (el) { return el.getAttribute('aria-disabled') !== 'true'; },
382
+ onHit: function (i) { focusAt(i); },
383
+ });
384
+
308
385
  if (!menu.id) menu.id = 'dd-menu-' + (new Date()).getTime();
309
386
  btn.setAttribute('aria-haspopup', 'true');
310
387
  btn.setAttribute('aria-expanded', 'false');
@@ -339,6 +416,13 @@ return;
339
416
 
340
417
  if (closest(e.target, '[data-dd-menu]') !== menu) return;
341
418
 
419
+ if (k.length === 1 && k !== ' ' &&
420
+ !e.ctrlKey && !e.metaKey && !e.altKey && !e.isComposing) {
421
+ e.preventDefault();
422
+ typeahead.type(k);
423
+ return;
424
+ }
425
+
342
426
  if (k === 'Tab') { close(false); return; }
343
427
 
344
428
  if (k === 'Enter' || k === ' ' || k === 'Spacebar') {
@@ -358,7 +442,10 @@ onDismiss: function (e, reason) { close(reason === 'esc'); },
358
442
  return {
359
443
  open: open,
360
444
  close: close,
361
- destroy: function () { roving.destroy(); dismiss.destroy(); menu.hidden = true; },
445
+ destroy: function () {
446
+ roving.destroy(); dismiss.destroy(); typeahead.destroy();
447
+ menu.hidden = true;
448
+ },
362
449
  };
363
450
  }
364
451
 
@@ -59,6 +59,7 @@ var flDismissable = function (opts) {
59
59
  var opt = opts || {};
60
60
  var escOn = opt.escOn || [];
61
61
  var inside = opt.inside || [];
62
+ var outside = opt.outside !== false;
62
63
  var when = opt.when || function () { return true; };
63
64
  var onDismiss = opt.onDismiss || function () {};
64
65
 
@@ -99,7 +100,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
99
100
  for (var j = 0; j < escOn.length; j++) {
100
101
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
101
102
  }
102
- shared.stack.push(rec);
103
+
104
+ if (outside) shared.stack.push(rec);
103
105
  if (!shared.bound) {
104
106
  shared.bound = true;
105
107
  document.addEventListener('click', onDocClick, true);
@@ -251,6 +251,69 @@ clear: function () { saved = null; },
251
251
  };
252
252
  };
253
253
 
254
+ var flDismissable = function (opts) {
255
+ var opt = opts || {};
256
+ var escOn = opt.escOn || [];
257
+ var inside = opt.inside || [];
258
+ var outside = opt.outside !== false;
259
+ var when = opt.when || function () { return true; };
260
+ var onDismiss = opt.onDismiss || function () {};
261
+
262
+ var shared = window.__flDismiss || (window.__flDismiss = { stack: [], bound: false });
263
+
264
+ function isInside(node) {
265
+ if (!node) return false;
266
+ for (var i = 0; i < inside.length; i++) {
267
+ var el = inside[i];
268
+ if (!el || !el.contains) continue;
269
+ if (el === node || el.contains(node)) return true;
270
+ }
271
+ return false;
272
+ }
273
+
274
+ function onKey(e) {
275
+ if (!when()) return;
276
+ var k = e.key;
277
+ if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
278
+
279
+ var closed = onDismiss(e, 'esc');
280
+ if (closed === false) return;
281
+ if (e.preventDefault) e.preventDefault();
282
+ if (e.stopPropagation) e.stopPropagation();
283
+ }
284
+
285
+ function onDocClick(e) {
286
+ var top = null;
287
+ for (var i = shared.stack.length - 1; i >= 0; i--) {
288
+ if (shared.stack[i].when()) { top = shared.stack[i]; break; }
289
+ }
290
+ if (!top || top.isInside(e.target)) return;
291
+ top.onDismiss(e, 'outside');
292
+ }
293
+
294
+ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
295
+
296
+ for (var j = 0; j < escOn.length; j++) {
297
+ if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
298
+ }
299
+
300
+ if (outside) shared.stack.push(rec);
301
+ if (!shared.bound) {
302
+ shared.bound = true;
303
+ document.addEventListener('click', onDocClick, true);
304
+ }
305
+
306
+ return {
307
+ destroy: function () {
308
+ for (var j = 0; j < escOn.length; j++) {
309
+ if (escOn[j]) escOn[j].removeEventListener('keydown', onKey);
310
+ }
311
+ var k = shared.stack.indexOf(rec);
312
+ if (k >= 0) shared.stack.splice(k, 1);
313
+ },
314
+ };
315
+ };
316
+
254
317
  function dialog(opts) {
255
318
  opts = opts || {};
256
319
 
@@ -310,16 +373,16 @@ var uninstallIsolation = null;
310
373
  openStack.push(stackEntry);
311
374
  if (!useInert) uninstallIsolation = installIsolation(stackEntry);
312
375
 
376
+ var dm = flDismissable({
377
+ escOn: [box],
378
+ outside: false,
379
+ when: function () { return !closed; },
380
+ onDismiss: function () { close(); },
381
+ });
382
+
313
383
  function focusables() { return focusableIn(box); }
314
384
 
315
385
  box.addEventListener('keydown', function (e) {
316
- if (e.key === 'Escape') {
317
- e.preventDefault();
318
-
319
- e.stopPropagation();
320
- close();
321
- return;
322
- }
323
386
  if (e.key !== 'Tab') return;
324
387
 
325
388
  var f = focusables();
@@ -370,6 +433,8 @@ function close() {
370
433
  if (closed) return;
371
434
  closed = true;
372
435
 
436
+ dm.destroy();
437
+
373
438
  var ae = document.activeElement;
374
439
  var focusIsMine = !ae || ae === document.body ||
375
440
  box.contains(ae) || backdrop.contains(ae);
@@ -36,6 +36,7 @@ var flDismissable = function (opts) {
36
36
  var opt = opts || {};
37
37
  var escOn = opt.escOn || [];
38
38
  var inside = opt.inside || [];
39
+ var outside = opt.outside !== false;
39
40
  var when = opt.when || function () { return true; };
40
41
  var onDismiss = opt.onDismiss || function () {};
41
42
 
@@ -76,7 +77,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
76
77
  for (var j = 0; j < escOn.length; j++) {
77
78
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
78
79
  }
79
- shared.stack.push(rec);
80
+
81
+ if (outside) shared.stack.push(rec);
80
82
  if (!shared.bound) {
81
83
  shared.bound = true;
82
84
  document.addEventListener('click', onDocClick, true);
package/dist/COSTS.md CHANGED
@@ -22,36 +22,36 @@
22
22
  | primitive | `button` | 2.7 KB | 1.0 KB | — | **3.7 KB** | 26.0 KB | −22.4 KB |
23
23
  | primitive | `card` | 2.7 KB | 0.9 KB | — | **3.6 KB** | 23.1 KB | −19.5 KB |
24
24
  | primitive | `choice` | 2.7 KB | 1.1 KB | — | **3.8 KB** | 25.0 KB | −21.2 KB |
25
- | primitive | `combobox` | 2.7 KB | 1.0 KB | 5.4 KB | **9.0 KB** | 29.3 KB | −20.3 KB |
25
+ | primitive | `combobox` | 2.7 KB | 1.0 KB | 5.4 KB | **9.0 KB** | 29.2 KB | −20.1 KB |
26
26
  | primitive | `date-range` | 2.7 KB | 0.8 KB | 2.5 KB | **6.1 KB** | 26.1 KB | −20.1 KB |
27
27
  | primitive | `delta` | 2.7 KB | 0.3 KB | — | **3.0 KB** | 22.4 KB | −19.4 KB |
28
28
  | primitive | `input` | 2.7 KB | 1.0 KB | — | **3.7 KB** | 23.2 KB | −19.5 KB |
29
29
  | primitive | `metric` | 2.7 KB | 0.5 KB | — | **3.2 KB** | 22.8 KB | −19.7 KB |
30
- | primitive | `popover` | 2.7 KB | 0.8 KB | 2.2 KB | **5.7 KB** | 27.2 KB | −21.5 KB |
30
+ | primitive | `popover` | 2.7 KB | 0.8 KB | 2.2 KB | **5.7 KB** | 26.9 KB | −21.2 KB |
31
31
  | primitive | `progress` | 2.7 KB | 0.7 KB | — | **3.4 KB** | 22.5 KB | −19.1 KB |
32
- | primitive | `select` | 2.7 KB | 0.8 KB | 3.5 KB | **7.0 KB** | 29.4 KB | −22.4 KB |
32
+ | primitive | `select` | 2.7 KB | 0.8 KB | 3.5 KB | **7.0 KB** | 29.2 KB | −22.2 KB |
33
33
  | primitive | `separator` | 2.7 KB | 0.2 KB | — | **2.9 KB** | 20.9 KB | −18.0 KB |
34
34
  | primitive | `skeleton` | 2.7 KB | 0.5 KB | — | **3.2 KB** | 21.1 KB | −17.9 KB |
35
35
  | primitive | `switch` | 2.7 KB | 0.8 KB | — | **3.4 KB** | 26.8 KB | −23.3 KB |
36
36
  | pattern | `accordion` | 2.7 KB | 1.3 KB | 1.8 KB | **5.8 KB** | 34.1 KB | −28.3 KB |
37
37
  | pattern | `content` | 2.7 KB | 1.0 KB | — | **3.7 KB** | 24.2 KB | −20.5 KB |
38
- | pattern | `drawer` | 2.7 KB | 0.8 KB | 2.0 KB | **5.4 KB** | 25.0 KB | −19.6 KB |
39
- | pattern | `dropdown` | 2.7 KB | 0.8 KB | 2.9 KB | **6.4 KB** | 28.1 KB | −21.7 KB |
38
+ | pattern | `drawer` | 2.7 KB | 0.8 KB | 2.4 KB | **5.9 KB** | 26.1 KB | −20.2 KB |
39
+ | pattern | `dropdown` | 2.7 KB | 0.8 KB | 3.4 KB | **6.9 KB** | 29.1 KB | −22.2 KB |
40
40
  | pattern | `form-validation` | 2.7 KB | 0.8 KB | — | **3.5 KB** | 24.1 KB | −20.7 KB |
41
41
  | pattern | `list` | 2.7 KB | 0.6 KB | — | **3.3 KB** | 22.0 KB | −18.7 KB |
42
- | pattern | `nav` | 2.7 KB | 1.2 KB | 2.6 KB | **6.5 KB** | 30.9 KB | −24.4 KB |
43
- | pattern | `overlay` | 2.7 KB | 1.1 KB | 2.8 KB | **6.7 KB** | 33.9 KB | −27.2 KB |
42
+ | pattern | `nav` | 2.7 KB | 1.2 KB | 2.6 KB | **6.6 KB** | 30.7 KB | −24.1 KB |
43
+ | pattern | `overlay` | 2.7 KB | 1.1 KB | 3.3 KB | **7.1 KB** | 34.5 KB | −27.4 KB |
44
44
  | pattern | `pagination` | 2.7 KB | 0.8 KB | 2.6 KB | **6.0 KB** | 30.1 KB | −24.1 KB |
45
45
  | pattern | `reveal` | 2.7 KB | 0.3 KB | 1.2 KB | **4.2 KB** | 24.2 KB | −20.0 KB |
46
46
  | pattern | `states` | 2.7 KB | 0.8 KB | — | **3.5 KB** | 23.6 KB | −20.1 KB |
47
47
  | pattern | `tabs` | 2.7 KB | 0.7 KB | 2.0 KB | **5.4 KB** | 26.8 KB | −21.4 KB |
48
- | pattern | `tooltip` | 2.7 KB | 0.5 KB | 1.9 KB | **5.1 KB** | 26.3 KB | −21.2 KB |
48
+ | pattern | `tooltip` | 2.7 KB | 0.5 KB | 1.9 KB | **5.1 KB** | 26.0 KB | −20.9 KB |
49
49
  | pattern | `tree` | 2.7 KB | 0.6 KB | 2.9 KB | **6.2 KB** | 27.8 KB | −21.6 KB |
50
50
 
51
51
  ## 三、全库
52
52
 
53
- - 全部组件都用上(min,含 tokens 一份):**61.2 KB**
54
- - 源码版(带注释)全库 gzip:303.0 KB
53
+ - 全部组件都用上(min,含 tokens 一份):**62.6 KB**
54
+ - 源码版(带注释)全库 gzip:305.5 KB
55
55
 
56
56
  > ⚠️ 源码版那个数字**不是**要付的钱:它包含我们的注释(全库 CSS 有 67% 是注释,字节口径)。
57
57
  > 它的用途只有一个 —— 让人看清"dist 到底省了多少"。
@@ -4,9 +4,10 @@
4
4
  但证明不了"念出来是否**合理**"。这一页把这件事变成**可执行、可复现、可填表**的动作,
5
5
  时长约 20 分钟。
6
6
 
7
- **当前状态:已执行一轮(2026-10-30)。** 九个页面都跑过 Tab 遍历,浮层焦点在
8
- `overlay` / `drawer` 上验到,**标题层级(`H` 键逐级跳)本轮没做**,结果表里如实写着
9
- "未执行"。
7
+ **当前状态:已执行两轮(2026-10-30)。** 九个页面都跑过 Tab 遍历,浮层焦点在
8
+ `overlay` / `drawer` 上验到;**标题层级(`H` 键逐级跳)在第二轮补齐**——九个页面
9
+ 共 107 个标题,逐条与页面结构对得上。第一轮修掉的缺陷、第二轮的破坏实验,
10
+ 以及三趟才拿到干净数据的那三个操作坑,都写在第三节。
10
11
 
11
12
  证据形态要说清楚:**不是人耳旁听**,是让 NVDA 把朗读日志写到文件,逐条取它交给语音
12
13
  合成器的原始文本(`IO - speech.speech.speak`)。这比人耳更可复现、可存档,但它证明的
@@ -43,9 +44,17 @@ python3 -m http.server 8000 --bind 127.0.0.1
43
44
 
44
45
  ## 二 · 每页只做这 5 件事(NVDA 键位在括号里)
45
46
 
46
- 1. **从页面顶部按 Tab 走一遍**,听到的每个控件都要有**说得通的名字**。
47
- 听到 "button"、"未标记"、"undefined"、只有图标名 ⇒ ❌ 记下来。
47
+ 1. **按 Tab 走一遍**(走的是焦点顺序,与浏览光标停在哪无关),听到的每个控件都要有
48
+ **说得通的名字**。听到 "button"、"未标记"、"undefined"、只有图标名 ⇒ ❌ 记下来。
48
49
  2. **用读屏的"下一个标题"跳(NVDA `H`)**,标题层级应当是 1 → 2 → 3,不跳级。
50
+
51
+ > **三个实操坑**(2026-10-30 实测,三个都踩过,踩完才拿到干净结果):
52
+ > - **换一个查询串再打开**。NVDA 按文档 URL 记忆浏览光标:同一个 URL 再开一次会恢复
53
+ > 上次的位置,若上次停在文末,按 `H` 只会念「往下没有标题」——看起来像"本页没有标题"。
54
+ > - **`h1` 用 `H` 再 `SHIFT+H` 取**。加载后的光标落点不固定:有时正好在 `h1` 上
55
+ > (按 `↑` 直接念出「标题 1 级 + 页面名」),有时落在紧随其后的导语段落上。
56
+ > 稳妥的做法是先按一次 `H` 走到第一个 `h2`,再按 `SHIFT+H` 退回来 —— 那一条必然是 `h1`。
57
+ > - **末端那句「往下没有标题」是证据,不是失败**。它证明 `H` 已经走过本页最后一个标题。
49
58
  3. **把焦点放到一个禁用控件上**,它应当被念成"不可用"(而不是干脆跳过或念成正常按钮)。
50
59
  4. **触发表单错误**(页面里带校验的那块),错误文字应当被**紧跟控件**念出,
51
60
  不是只在屏幕右上角飘一条。
@@ -61,28 +70,48 @@ python3 -m http.server 8000 --bind 127.0.0.1
61
70
 
62
71
  | 页面 | ① 名字 | ② 标题层级 | ③ 禁用态 | ④ 错误说明 | ⑤ 浮层焦点 | 备注(听到了什么) |
63
72
  |---|---|---|---|---|---|---|
64
- | button | 通过 | 未执行 | 未执行 | — | — | 12 个按钮全部念出名字;加载态念「忙碌中」 |
65
- | input | 通过 | 未执行 | 未执行 | 通过 | — | 只读念「只读」;错误念「错误的输入 · 请输入有效金额」,紧跟控件 |
66
- | choice | 通过 | 未执行 | — | — | — | 「全选(下面 3 项)」念「部分选中」;单选念「时间范围 · 分组 · 已选中 · 3 之 1 项」 |
67
- | tabs | 通过 | 未执行 | — | — | — | 念「选项卡组 · 选项卡 · 已选中 · 2 之 1 项」;错例组三个 `tabindex=0` 各停一次,正例组只停一次 —— 对照成立 |
68
- | overlay | 通过 | 通过(对话框标题念成 2 级) | — | — | 通过 | 打开后焦点进对话框(取消 / 确认标记),Esc 后回到「普通确认」,再 Tab 落到「删除这份报告」 |
69
- | table | 通过 | 未执行 | — | — | — | 表格念「共 7 行 7 列 · 1 行 1 列」;🔴 **列头按钮把 `↕` 念了出来**(见下) |
70
- | **accordion** | 通过 | 通过(标题念成 3 级) | 通过 | — | — | 「公司基本信息 · 按钮 · 已展开 · 标题 3 级」→ 回车念「已折叠」→ 再回车念「已展开」;`aria-disabled` 那栏念「不可用」 |
71
- | **drawer** | 通过 | 未执行 | — | — | 通过 | 打开念「从left进来的抽屉 · 对话框」;Tab 在「取消 → 确定 → 关闭」之间循环;Esc 后回到「左侧 · 按钮」 |
72
- | **dropdown** | 通过 | 未执行 | — | — | 通过(修复后) | 按钮念「菜单按钮 · 已折叠 · 子菜单」,展开念「已展开 · 菜单」。**修复前**:按 Enter 打开后焦点留在按钮上(读屏在浏览模式下把 Enter 变成点击,走 click 分支),修复后真机复测焦点落在「✓ 按日期」。方向键仍无法在项间移动 —— 极简 APG 对照页表现相同,属读屏浏览模式限制 |
73
-
74
- > **为什么后三行是补的**:`accordion` / `drawer` / `dropdown` 这三个都是 beta 组件
75
- > (`ai/components.json` 当前记 4 个 beta),而它们升 stable 的判据之一就是「行为零欠账」
76
- > ——人工读屏是这件事**唯一的用户侧证据**。
73
+ | button | 通过 | 通过(19/19) | 未执行 | — | — | 12 个按钮全部念出名字;加载态念「忙碌中」。标题从「1 级 button · 七态矩阵」→「2 级 七态」→「3 级 1. default …」,末条念「往下没有标题」 |
74
+ | input | 通过 | 通过(13/13) | 未执行 | 通过 | — | 只读念「只读」;错误念「错误的输入 · 请输入有效金额」,紧跟控件。标题 1 → 2 → 3 → 2 → 3 → 2 → 2,与结构一致 |
75
+ | choice | 通过 | 通过(10/10) | — | — | — | 「全选(下面 3 项)」念「部分选中」;单选念「时间范围 · 分组 · 已选中 · 3 之 1 项」。标题 1 → 2×6 → 3 → 3 → 2 |
76
+ | tabs | 通过 | 通过(10/10) | — | — | — | 念「选项卡组 · 选项卡 · 已选中 · 2 之 1 项」;错例组三个 `tabindex=0` 各停一次,正例组只停一次 —— 对照成立。标题 1 → 2×5 → 3×4 |
77
+ | overlay | 通过 | 通过(11/11,对话框标题念成 2 级) | — | — | 通过 | 打开后焦点进对话框(取消 / 确认标记),Esc 后回到「普通确认」,再 Tab 落到「删除这份报告」。标题 1 → 2×3 → 3×2 → 2×2 → 3×2 → 2 |
78
+ | table | 通过 | 通过(10/10) | — | — | — | 表格念「共 7 行 7 列 · 1 行 1 列」;🔴 **列头按钮把 `↕` 念了出来**(见下,已修)。标题 1 → 2×7 → 3 → 3 |
79
+ | **accordion** | 通过 | 通过(21/21) | 通过 | — | — | 「公司基本信息 · 按钮 · 已展开 · 标题 3 级」→ 回车念「已折叠」→ 再回车念「已展开」;`aria-disabled` 那栏念「不可用」。标题 1 → 2 → 3×3 → 2 → 3×3 → 2 → 3×3 → 2×4 → 3×5 |
80
+ | **drawer** | 通过 | 通过(6/6) | — | — | 通过 | 打开念「从left进来的抽屉 · 对话框」;Tab 在「取消 → 确定 → 关闭」之间循环;Esc 后回到「左侧 · 按钮」。标题 1 → 2×3 → 3×2 |
81
+ | **dropdown** | 通过 | 通过(7/7) | — | — | 通过(修复后) | 按钮念「菜单按钮 · 已折叠 · 子菜单」,展开念「已展开 · 菜单」。**修复前**:按 Enter 打开后焦点留在按钮上(读屏在浏览模式下把 Enter 变成点击,走 click 分支),修复后真机复测焦点落在「✓ 按日期」。方向键仍无法在项间移动 —— 极简 APG 对照页表现相同,属读屏浏览模式限制。标题 1 → 2×4 → 3×2 |
82
+
83
+ > **为什么后三行是补的**:`accordion` / `drawer` / `dropdown` 当时都是 beta 组件,
84
+ > 而它们升 stable 的判据之一就是「行为零欠账」——人工读屏是这件事**唯一的用户侧证据**。
85
+ > (0.14.0 把 drawer / overlay 的 dismissable 收编进核;accordion 的 `roving:gap`
86
+ > 经核对 APG 原文后判定为**判据写错**(APG Accordion 没有方向键),改为 `n/a`;
87
+ > 0.15.0 补上 dropdown 的 typeahead。`ai/components.json` 当前 **37 个全 stable**,
88
+ > 阶梯靠正向控制证明仍能区分有债的组件。)
77
89
  > 只覆盖 `overlay` 一个浮层,等于对其他浮层的真实表现没有任何记录。
78
90
 
79
91
  **本轮跑出来并已修掉的一条真缺陷**:`table` 的列头按钮被念成「公司 · `↕` · 按钮」——
80
92
  那个 `↕` 来自 `table.css` 里 `.table__sort::after` 的 `content`,Chromium 把伪元素文本算进了
81
- 可访问名。**自动检查抓不到它**(可访问名"非空"就算过),212 道门禁里也没有一道看这个。
93
+ 可访问名。**自动检查抓不到它**(可访问名"非空"就算过),当时那 212 道门禁里也没有一道看这个。
82
94
 
83
95
  改法:把字形换成 `mask` 图像(`content` 留空),三态形状仍是 `↕ / ↑ / ↓`,
84
96
  颜色靠 `currentColor` 跟着主题走,标记结构一个字符都不用动。
85
- ⚠️ 不走 `content: "↕" / ""` 那条路 —— 替代文本语法 Firefox 不支持,箭头会直接消失。
97
+
98
+ **为什么不用 `content: "↕" / ""` 那个一行改法**(2026-10-30 实测更正):
99
+ 早先这里写着"Firefox 不支持替代文本语法"——**那是错的**。三个引擎实测下来都被接受,
100
+ 而且都能把 `↕` 从可访问名里去掉:
101
+
102
+ | 写法 | Chromium | Firefox | WebKit |
103
+ |---|---|---|---|
104
+ | `content: "↕" / ""` | 显示 · 名=`公司` | 显示 · 名=`公司` | 显示 · 名=`公司` |
105
+ | `content: "↕" / "排序"` | 显示 · 名=`公司 排序` | 显示 · 名=`公司排序` | 显示 · 名=`公司排序` |
106
+ | `mask`(本库) | 显示 · 名=`公司` | 显示 · 名=`公司` | 显示 · 名=`公司` |
107
+ | `content: "↕"`(原写法) | 显示 · 名=`公司↕` | 显示 · 名=`公司↕` | 显示 · 名=`公司↕` |
108
+
109
+ 真正拦住它的是**老引擎**:这套语法要 Chromium 108 / Firefox 128 / Safari 17.4 起才认,
110
+ 而不认的引擎会**把整条声明丢掉** ⇒ 箭头彻底消失(比念出符号更糟)。
111
+ 支持矩阵里有 Android 百度浏览器(Chromium 97,见 [LAYER-BASELINE](LAYER-BASELINE.md)),
112
+ 落在门槛之外。🔴 **这一条是按版本门槛推断的,没有在 Chromium 97 上实测过** ——
113
+ 说的就是"没测的不算已验证"。
114
+
86
115
  改完用 NVDA 复听:表头只剩「公司 · 按钮」;`table-check` 的排序判据也跟着改成
87
116
  读 `mask` 的自定义属性(破坏实验验证过:把降序箭头改成升序形状,判据当场红)。
88
117
 
@@ -93,11 +122,42 @@ python3 -m http.server 8000 --bind 127.0.0.1
93
122
  自动门禁当时是绿的,因为它只测了 `↓` 这条键盘路径。
94
123
  **修法与判据**:把聚焦首项收进 `open()`(两条路径共用),并补一条"点击打开也聚焦首项"。
95
124
 
125
+ ### ② 那一格是怎么填的:两条通道,各测一半
126
+
127
+ 标题层级容易被当成"看一眼 DOM 就知道"的事,但**"结构对不对"和"读屏跳得对不对"
128
+ 是两件事**。所以这一格用两条互不依赖的通道各测一遍:
129
+
130
+ | 通道 | 覆盖 | 这一轮的结果 |
131
+ |---|---|---|
132
+ | 机器 · 运行时 DOM + 可访问性树 | **96 个页面、887 个标题**(`node_modules` / `dist` 之外的全部 HTML,空夹具除外) | 每页恰好一个 `h1`;跳级 0 处、空名 0 个;层级分布 `h1×96 · h2×581 · h3×210`(没有 h4–h6);DOM 里的标题数与无障碍树里的 heading 节点数**逐页相等** |
133
+ | 读屏 · NVDA `H` 键实录 | 协议表里的 **9 页、107 个标题** | **107/107 条**念出的级别与页面结构逐条一致;九页走到底时都念「往下没有标题」 |
134
+
135
+ 机器侧不是"跑绿了就算"。这三条 axe 规则(`heading-order` /
136
+ `page-has-heading-one` / `empty-heading`)都做过破坏实验:
137
+
138
+ | 注入的破坏 | `heading-order` | `page-has-heading-one` | `empty-heading` |
139
+ |---|---|---|---|
140
+ | 基线(不动) | 绿 | 绿 | 绿 |
141
+ | `h2` 后插 `h4`(跳级) | **红** | 绿 | 绿 |
142
+ | 删掉唯一的 `h1` | 绿 | **红** | 绿 |
143
+ | 清空一个 `h3` 的文本 | 绿 | 绿 | **红** |
144
+ | 反向对照:`h3` 后插 `h4`(合法) | 绿 | 绿 | 绿 |
145
+
146
+ 最后一行是关键:合法层级仍然全绿,说明它不是"见到 `h4` 就报"。
147
+
148
+ > **机器侧可复跑**:`node 05-audit/heading-outline.js`(先起本地静态服务),
149
+ > 末行会打印 `96 页 · 887 个标题(h1×96 · h2×581 · h3×210)· 0 页异常`。
150
+ > 它不是门禁,是取证工具:结构本身已由 axe 那三条规则守着,它负责的是
151
+ > "把整站每个标题都数一遍"这件事,以及第四条判据(DOM 标题数 == 无障碍树 heading 数)。
152
+ >
153
+ > **读屏侧不进仓库**:实录脚本依赖一台装了 NVDA 的 Windows 机。复核方式是按第二节
154
+ > 第 2 条自己走一遍——九页的层级序列在上表每行的备注里都抄了,对不上就是有问题。
155
+
96
156
  状态只填三种:**通过** / **失败(附听到的内容)** / **未执行**。
97
157
  "大概没问题"不属于这三种,不要写。
98
158
 
99
159
  填写人 / 日期 / 系统与读屏版本:
100
- `脚本驱动 NVDA(朗读取自 speech 日志原文,非人耳旁听)/ 2026-10-30 / Windows 10 22H2 19045 · NVDA 2026.3 · Edge`
160
+ `脚本驱动 NVDA(朗读取自 speech 日志原文,非人耳旁听)/ 2026-10-30,两轮 / Windows 10 22H2 19045 · NVDA 2026.3 · Edge`
101
161
 
102
162
  ---
103
163
 
@@ -113,5 +173,6 @@ python3 -m http.server 8000 --bind 127.0.0.1
113
173
  ## 五 · 为什么这份协议要写进仓库
114
174
 
115
175
  因为"我们打算做读屏测试"和"我们做过读屏测试"在仓库里长得一模一样 ——
116
- 除非有一张**带状态列的表**。这张表现在全部写着"未执行",
117
- 它记录的不是进度,而是**边界**:本库目前能承诺的东西到哪为止。
176
+ 除非有一张**带状态列的表**。第一轮填这张表时,九页里有六页的"标题层级"写着"未执行";
177
+ 那一列记录的不是进度,而是**边界**。第二轮把那一列填满,边界就移动了一格 ——
178
+ 现在还剩 macOS VoiceOver 与 Android TalkBack 两格空白(见第四节第 2 条)。