agentcss 0.24.2 → 0.25.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/README.md +1 -1
- package/01-tokens/tokens.css +67 -1
- package/01-tokens/typography.css +1 -1
- package/02-primitives/button/demo.html +8 -0
- package/02-primitives/card/README.md +15 -0
- package/02-primitives/choice/README.md +30 -0
- package/02-primitives/choice/choice.css +1 -1
- package/02-primitives/input/README.md +1 -0
- package/02-primitives/select/README.md +42 -2
- package/02-primitives/select/demo.html +11 -6
- package/02-primitives/select/select.js +184 -94
- package/02-primitives/timeline/demo.html +2 -2
- package/02-primitives/timeline/timeline.css +1 -1
- package/03-patterns/accordion/README.md +15 -0
- package/03-patterns/content/README.md +11 -0
- package/03-patterns/drawer/README.md +1 -1
- package/03-patterns/dropdown/demo.html +1 -1
- package/03-patterns/form-validation/README.md +18 -0
- package/03-patterns/form-validation/form-validation.css +12 -1
- package/03-patterns/list/README.md +11 -0
- package/03-patterns/overlay/README.md +26 -6
- package/03-patterns/overlay/overlay.css +22 -3
- package/03-patterns/overlay/overlay.js +63 -17
- package/03-patterns/states/README.md +15 -0
- package/03-patterns/states/states.css +18 -1
- package/03-patterns/tooltip/README.md +12 -0
- package/04-recipes/longform/longform.html +3 -3
- package/04-recipes/report/README.md +9 -0
- package/04-recipes/report/report.css +5 -0
- package/04-recipes/table/README.md +16 -0
- package/04-recipes/tierA-tokens.css +3 -3
- package/09-assets/README.md +18 -0
- package/09-assets/bar/bar.css +25 -10
- package/09-assets/echarts-adapter/demo.html +3 -1
- package/09-assets/echarts-adapter/echarts-adapter.js +107 -34
- package/09-assets/scientific-plot/README.md +2 -1
- package/09-assets/scientific-plot/scientific-plot.js +0 -7
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +317 -18
- package/CONTRIBUTING.md +6 -5
- package/README.md +6 -2
- package/README.zh-CN.md +19 -7
- package/SECURITY.md +5 -2
- package/START-HERE.md +7 -7
- package/SUPPORT.md +86 -24
- package/ai/START-HERE.md +3 -2
- package/ai/cli.js +126 -6
- package/ai/components.json +258 -2
- package/ai/components.meta.json +257 -40
- package/ai/components.schema.json +96 -0
- package/ai/contract.schema.json +11 -0
- package/ai/pdf-pages.js +79 -0
- package/ai/pdf.js +354 -0
- package/ai/tokens.json +2 -2
- package/ai/validate.js +48 -1
- package/dist/01-tokens/tokens.css +1 -1
- package/dist/02-primitives/select/select.js +158 -71
- package/dist/03-patterns/form-validation/form-validation.css +1 -1
- package/dist/03-patterns/overlay/overlay.css +1 -1
- package/dist/03-patterns/overlay/overlay.js +40 -11
- package/dist/03-patterns/states/states.css +1 -1
- package/dist/04-recipes/report/report.css +1 -1
- package/dist/04-recipes/tierA-tokens.css +1 -1
- package/dist/09-assets/bar/bar.css +1 -1
- package/dist/09-assets/echarts-adapter/echarts-adapter.js +75 -33
- package/dist/09-assets/scientific-plot/scientific-plot.js +0 -7
- package/dist/COSTS.md +37 -37
- package/docs/ACCESSIBILITY.md +3 -3
- package/docs/FACTS.md +1 -1
- package/docs/LAYER-BASELINE.md +28 -3
- package/docs/PRINT.md +450 -0
- package/docs/RELEASE-PROCESS.md +78 -3
- package/index.html +1 -1
- package/llms-full.txt +7 -7
- package/llms.txt +5 -0
- package/package.json +6 -1
|
@@ -276,8 +276,26 @@
|
|
|
276
276
|
|
|
277
277
|
var options = [].slice.call(list.querySelectorAll('[role="option"]'));
|
|
278
278
|
var isMulti = !!opt.multiple;
|
|
279
|
-
|
|
279
|
+
/* 🔴 真多值:以前多选也只有一个 `selected` 字符串,而 aria-multiselectable
|
|
280
|
+
照样写 "true" —— ARIA 在**谎报**能力。现在统一用数组存值,
|
|
281
|
+
单选只是"长度 0 或 1"的特例,两条路径共用同一份状态。 */
|
|
282
|
+
var values = [];
|
|
280
283
|
var activeIdx = -1;
|
|
284
|
+
var destroyed = false;
|
|
285
|
+
|
|
286
|
+
function valueOfOpt(o) { return o ? o.getAttribute('data-value') : null; }
|
|
287
|
+
function hasValue(v) { return v != null && values.indexOf(String(v)) >= 0; }
|
|
288
|
+
function seed(v) {
|
|
289
|
+
if (v == null) return;
|
|
290
|
+
var arr = Object.prototype.toString.call(v) === '[object Array]' ? v : [v];
|
|
291
|
+
var next = [];
|
|
292
|
+
for (var i = 0; i < arr.length; i++) {
|
|
293
|
+
var s = arr[i] == null ? '' : String(arr[i]);
|
|
294
|
+
if (s && next.indexOf(s) < 0) next.push(s);
|
|
295
|
+
}
|
|
296
|
+
values = isMulti ? next : next.slice(0, 1);
|
|
297
|
+
}
|
|
298
|
+
seed(opt.value);
|
|
281
299
|
|
|
282
300
|
list.setAttribute('role', 'listbox');
|
|
283
301
|
if (isMulti) list.setAttribute('aria-multiselectable', 'true');
|
|
@@ -317,38 +335,102 @@
|
|
|
317
335
|
|
|
318
336
|
function selectedIdx() {
|
|
319
337
|
for (var i = 0; i < options.length; i++) {
|
|
320
|
-
if (options[i]
|
|
338
|
+
if (hasValue(valueOfOpt(options[i]))) return i;
|
|
321
339
|
}
|
|
322
340
|
return -1;
|
|
323
341
|
}
|
|
324
342
|
function valueText() {
|
|
325
|
-
var
|
|
326
|
-
|
|
343
|
+
var texts = [];
|
|
344
|
+
for (var i = 0; i < options.length; i++) {
|
|
345
|
+
if (hasValue(valueOfOpt(options[i]))) texts.push(optText(options[i]));
|
|
346
|
+
}
|
|
347
|
+
return texts.length ? texts.join('、') : (opt.placeholder || '请选择');
|
|
327
348
|
}
|
|
349
|
+
/* ⚠️ 空列表也会被调用(初始化 / 清空后重绘):必须能接住 undefined,
|
|
350
|
+
老实现直接 o.querySelector 会抛 "Cannot read properties of undefined"。 */
|
|
328
351
|
function optText(o) {
|
|
352
|
+
if (!o) return '';
|
|
329
353
|
var t = o.querySelector('.select__opt-text');
|
|
330
354
|
return ((t || o).textContent || '').replace(/[✓\s]/g, '').trim();
|
|
331
355
|
}
|
|
332
356
|
|
|
333
|
-
|
|
334
|
-
|
|
357
|
+
/** 对外值:单选给字符串(无值 null),多选给数组副本 */
|
|
358
|
+
function value() {
|
|
359
|
+
return isMulti ? values.slice() : (values.length ? values[0] : null);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* 表单承接:作者写了几个 hidden 就复用几个,不够的自动补同 name 的兄弟节点。
|
|
363
|
+
多选必须"一个值一个 input",否则提交出去只有最后一个 —— 那是假多选。 */
|
|
364
|
+
function syncHidden() {
|
|
365
|
+
var all = [].slice.call(root.querySelectorAll('input[type="hidden"]'));
|
|
366
|
+
var mine = [];
|
|
367
|
+
var author = [];
|
|
368
|
+
all.forEach(function (i) {
|
|
369
|
+
if (i.getAttribute('data-select-value') === '1') mine.push(i);
|
|
370
|
+
else author.push(i);
|
|
371
|
+
});
|
|
372
|
+
var name = opt.name || root.getAttribute('data-name') ||
|
|
373
|
+
(author[0] && author[0].name) || (mine[0] && mine[0].name) ||
|
|
374
|
+
root.id || ''; /* 兜底用根元素 id:没有 name 的话多选压根提交不出去 */
|
|
375
|
+
var slots = author.concat(mine);
|
|
376
|
+
var want = values.slice();
|
|
377
|
+
for (var i = 0; i < want.length; i++) {
|
|
378
|
+
var inp = slots[i];
|
|
379
|
+
if (!inp) {
|
|
380
|
+
inp = document.createElement('input');
|
|
381
|
+
inp.type = 'hidden';
|
|
382
|
+
inp.setAttribute('data-select-value', '1');
|
|
383
|
+
root.appendChild(inp);
|
|
384
|
+
slots.push(inp);
|
|
385
|
+
}
|
|
386
|
+
if (name) inp.name = name;
|
|
387
|
+
else inp.removeAttribute('name');
|
|
388
|
+
inp.value = want[i];
|
|
389
|
+
}
|
|
390
|
+
for (var j = want.length; j < slots.length; j++) {
|
|
391
|
+
var extra = slots[j];
|
|
392
|
+
if (extra.getAttribute('data-select-value') === '1') {
|
|
393
|
+
if (extra.parentNode) extra.parentNode.removeChild(extra);
|
|
394
|
+
} else {
|
|
395
|
+
extra.value = ''; /* 作者自己的 input 留着,只是清空 */
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function paint(silent) {
|
|
401
|
+
var label = valueText();
|
|
402
|
+
var empty = selectedIdx() < 0;
|
|
335
403
|
var vEl = btn.querySelector('.select__value');
|
|
336
404
|
if (vEl) {
|
|
337
|
-
vEl.textContent =
|
|
338
|
-
vEl.classList.toggle('select__value--placeholder',
|
|
405
|
+
vEl.textContent = label;
|
|
406
|
+
vEl.classList.toggle('select__value--placeholder', empty);
|
|
339
407
|
}
|
|
408
|
+
/* 多选标签会被 CSS 截断:title 里留全量,鼠标悬停能看全 */
|
|
409
|
+
if (empty) btn.removeAttribute('title');
|
|
410
|
+
else btn.setAttribute('title', label);
|
|
340
411
|
options.forEach(function (o) {
|
|
341
|
-
var on = o
|
|
412
|
+
var on = hasValue(valueOfOpt(o));
|
|
342
413
|
o.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
343
414
|
var ck = o.querySelector('.select__check');
|
|
344
415
|
if (ck) ck.textContent = on ? '✓' : '';
|
|
345
416
|
});
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
417
|
+
syncHidden();
|
|
418
|
+
if (silent) return;
|
|
419
|
+
flEmit(root, 'fl-change', { value: value(), values: values.slice(), text: label });
|
|
420
|
+
if (opt.onChange) opt.onChange(value(), label);
|
|
421
|
+
/* 🔴 原生 change:宿主表单 / 框架(React 受控、form.reset 监听)认的是这个,
|
|
422
|
+
只发 fl-change 等于"只有本库自己知道值变了"。 */
|
|
423
|
+
var ev = null;
|
|
424
|
+
if (typeof window.Event === 'function') {
|
|
425
|
+
try { ev = new window.Event('change', { bubbles: true }); } catch (e) { ev = null; }
|
|
426
|
+
}
|
|
427
|
+
if (!ev) {
|
|
428
|
+
try {
|
|
429
|
+
ev = document.createEvent('Event');
|
|
430
|
+
ev.initEvent('change', true, false);
|
|
431
|
+
} catch (e2) { ev = null; }
|
|
432
|
+
}
|
|
433
|
+
if (ev) root.dispatchEvent(ev);
|
|
352
434
|
}
|
|
353
435
|
|
|
354
436
|
/**
|
|
@@ -428,112 +510,120 @@
|
|
|
428
510
|
function commit(i) {
|
|
429
511
|
var o = options[i];
|
|
430
512
|
if (!o || o.getAttribute('aria-disabled') === 'true') return;
|
|
513
|
+
var v = valueOfOpt(o);
|
|
431
514
|
if (isMulti) {
|
|
432
|
-
/*
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
515
|
+
/* 多选:再点一次取消;不关闭列表(APG 多选 listbox 语义) */
|
|
516
|
+
var k = values.indexOf(v);
|
|
517
|
+
if (k >= 0) values.splice(k, 1);
|
|
518
|
+
else values.push(v);
|
|
519
|
+
paint();
|
|
520
|
+
return;
|
|
437
521
|
}
|
|
522
|
+
values = [v];
|
|
438
523
|
paint();
|
|
524
|
+
close(true); /* 选中并关闭 + 归位 */
|
|
439
525
|
}
|
|
440
526
|
|
|
441
|
-
/* ---------- 键盘(APG Select)----------
|
|
442
|
-
|
|
527
|
+
/* ---------- 键盘(APG Select)----------
|
|
528
|
+
🔴 只允许**一个** keydown 处理器。
|
|
529
|
+
以前这里挂了两个:一个是 ↑↓/Enter,一个是 onListKey(↑↓/Home/End/Enter/字符)。
|
|
530
|
+
同一事件两个监听器都跑 ⇒ 按一次 ↓ 走两格、多选 Enter 选上又取消、
|
|
531
|
+
关闭态 Enter 打开后立刻被第二个处理器提交关闭。
|
|
532
|
+
⚠️ 而旧契约只断言"序列变了",所以这个 bug 从门禁底下过去了。 */
|
|
533
|
+
function onBtnKey(e) {
|
|
534
|
+
if (destroyed) return;
|
|
443
535
|
var k = e.key;
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
536
|
+
var opened = !list.hidden;
|
|
537
|
+
|
|
538
|
+
if (k === 'ArrowDown' || k === 'ArrowUp' || k === 'Enter' || k === ' ' || k === 'Spacebar') {
|
|
539
|
+
e.preventDefault();
|
|
540
|
+
if (!opened) {
|
|
541
|
+
open();
|
|
542
|
+
/* APG:↑ 打开时游标落**末项**,↓ 打开落选中项 */
|
|
543
|
+
if (k === 'ArrowUp' && options.length) setActive(options.length - 1, -1);
|
|
451
544
|
return;
|
|
452
545
|
}
|
|
453
|
-
|
|
454
|
-
if (
|
|
455
|
-
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
/* Alt+↓ 直接打开(不改值)*/
|
|
459
|
-
if (k === 'ArrowDown' && e.altKey) { e.preventDefault(); open(); return; }
|
|
460
|
-
/* Home/End */
|
|
461
|
-
if (list.hidden && (k === 'Home' || k === 'End')) {
|
|
462
|
-
e.preventDefault(); open();
|
|
463
|
-
if (k === 'Home') setActive(0);
|
|
464
|
-
else setActive(options.length - 1);
|
|
546
|
+
if (k === 'ArrowDown') { setActive(activeIndex() + 1, 1); return; }
|
|
547
|
+
if (k === 'ArrowUp') { setActive(activeIndex() - 1, -1); return; }
|
|
548
|
+
if (activeIdx >= 0) commit(activeIdx); /* 多选:切一次、不关闭 */
|
|
465
549
|
return;
|
|
466
550
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
/* 🔴 焦点常驻按钮 ⇒ 键盘事件主要落在 btn 上。
|
|
470
|
-
这里把 btn 的 handler 也挂一份完整逻辑(Esc/类型搜索/Home/End),
|
|
471
|
-
否则它们永远收不到事件。 */
|
|
472
|
-
function onListKey(e) {
|
|
473
|
-
var k = e.key;
|
|
474
|
-
if (k === 'ArrowDown') { e.preventDefault(); setActive(activeIndex() + 1, 1); return; }
|
|
475
|
-
if (k === 'ArrowUp') { e.preventDefault(); setActive(activeIndex() - 1, -1); return; }
|
|
476
|
-
if (k === 'Home') { e.preventDefault(); setActive(0, 1); return; }
|
|
477
|
-
if (k === 'End') { e.preventDefault(); setActive(options.length - 1, -1); return; }
|
|
478
|
-
/* ③ Esc 关闭 + 焦点还按钮
|
|
479
|
-
🔴 加 stopPropagation(**组合契约**抓到的真问题):
|
|
480
|
-
本组件常被放进 drawer / dialog 里。
|
|
481
|
-
原来只 preventDefault ⇒ 事件**继续冒泡**到外层弹层
|
|
482
|
-
⇒ **按一次 Esc 把内外两层一起关掉**。
|
|
483
|
-
⚠️ 单组件契约**测不到这个**(它没有外层)——
|
|
484
|
-
所以必须有"组合契约"这种真嵌套的测试。
|
|
485
|
-
⇒ 正解:内层弹层"吃掉"这个键时必须 stopPropagation。
|
|
486
|
-
层级弹层的 Esc 语义本来就是"只关最上面那层"。 */
|
|
487
|
-
/* Esc 已由 dismissable 核接管(见上面的 flDismissable):
|
|
488
|
-
核把它挂在 btn 上冒泡、并 stopPropagation ⇒ 只关最内层,
|
|
489
|
-
组件里不再各写一份(原先手写 10 处、分布 7 个文件)。 */
|
|
490
|
-
if (k === 'Tab') { close(false); return; }
|
|
491
|
-
if (k === 'Enter' || k === ' ' || k === 'Spacebar') {
|
|
551
|
+
if (k === 'Home' || k === 'End') {
|
|
492
552
|
e.preventDefault();
|
|
493
|
-
if (
|
|
553
|
+
if (!opened) open();
|
|
554
|
+
if (options.length) setActive(k === 'Home' ? 0 : options.length - 1, k === 'Home' ? 1 : -1);
|
|
494
555
|
return;
|
|
495
556
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
557
|
+
if (k === 'Tab') { if (opened) close(false); return; }
|
|
558
|
+
|
|
559
|
+
/* 单个可打印字符 = 类型搜索(Enter/Space/Tab 各有语义,不能算打字) */
|
|
560
|
+
if (k && k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
561
|
+
if (!opened) open(); /* 关闭态打字:先开再定位 */
|
|
500
562
|
e.preventDefault();
|
|
501
563
|
typeahead.type(k);
|
|
502
564
|
}
|
|
565
|
+
/* Esc 由 dismissable 核接管(挂在 btn 上、且 stopPropagation):
|
|
566
|
+
只关最内层,组件里不再写第二份。 */
|
|
503
567
|
}
|
|
504
|
-
|
|
505
|
-
btn.addEventListener('keydown', function (e) {
|
|
506
|
-
/* 列表没开时,btn 自己的 handler 已经处理了开合;开了之后交给 onListKey */
|
|
507
|
-
if (list.hidden) return;
|
|
508
|
-
var k = e.key;
|
|
509
|
-
/* 这些键在"已打开"状态下由 onListKey 统一处理 */
|
|
510
|
-
if (k === 'Escape' || k === 'Esc' || k === 'ArrowDown' || k === 'ArrowUp' ||
|
|
511
|
-
k === 'Home' || k === 'End' || k === 'Enter' || k === ' ' ||
|
|
512
|
-
k === 'Spacebar' || k === 'Tab') {
|
|
513
|
-
/* Enter/Space 在关闭态要"打开",交给 btn 原 handler */
|
|
514
|
-
if (list.hidden) return;
|
|
515
|
-
onListKey(e);
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) onListKey(e);
|
|
519
|
-
});
|
|
568
|
+
btn.addEventListener('keydown', onBtnKey);
|
|
520
569
|
|
|
521
570
|
/* ---------- 点击 ---------- */
|
|
522
|
-
|
|
571
|
+
function onBtnClick() {
|
|
572
|
+
if (destroyed) return;
|
|
523
573
|
if (list.hidden) open(); else close();
|
|
524
|
-
}
|
|
525
|
-
|
|
574
|
+
}
|
|
575
|
+
function onListClick(e) {
|
|
576
|
+
if (destroyed) return;
|
|
526
577
|
var o = e.target.closest ? closest(e.target, '[role="option"]') : null;
|
|
527
578
|
if (!o) return;
|
|
528
579
|
commit(options.indexOf(o));
|
|
529
|
-
}
|
|
580
|
+
}
|
|
581
|
+
btn.addEventListener('click', onBtnClick);
|
|
582
|
+
list.addEventListener('click', onListClick);
|
|
583
|
+
|
|
584
|
+
/* 🔴 表单 reset:hidden input 会被浏览器清空,而组件内部状态不会跟着变
|
|
585
|
+
⇒ 重置后 UI 还显示旧值,提交却是空的。监听 form 的 reset,
|
|
586
|
+
等浏览器把 DOM 重置完(下一个宏任务)再把状态改回初始值。 */
|
|
587
|
+
var initialValues = values.slice();
|
|
588
|
+
var form = closest(root, 'form'); /* 用模块内兜底,别直接调 Element.closest(老 WebView 没有) */
|
|
589
|
+
function onFormReset() {
|
|
590
|
+
setTimeout(function () {
|
|
591
|
+
if (destroyed) return;
|
|
592
|
+
values = initialValues.slice();
|
|
593
|
+
paint();
|
|
594
|
+
}, 0);
|
|
595
|
+
}
|
|
596
|
+
if (form) form.addEventListener('reset', onFormReset);
|
|
530
597
|
/* 点外部关闭已由 dismissable 核接管(document 捕获阶段判定)。 */
|
|
531
598
|
|
|
532
|
-
|
|
599
|
+
/* 初始化重绘不派发 change:原生 select 也不会在挂载时发一次 */
|
|
600
|
+
paint(true);
|
|
601
|
+
|
|
533
602
|
return {
|
|
534
603
|
open: open, close: close,
|
|
535
|
-
get value() { return
|
|
536
|
-
set value(v) {
|
|
604
|
+
get value() { return value(); },
|
|
605
|
+
set value(v) { seed(v); paint(); },
|
|
606
|
+
get values() { return values.slice(); },
|
|
607
|
+
set values(v) { seed(v); paint(); },
|
|
608
|
+
/* 🔴 对外必须能销毁:动态移除组件时,document 上的 dismissable 监听、
|
|
609
|
+
typeahead 缓冲、btn 上的 keydown 都要摘掉,否则越挂越多。 */
|
|
610
|
+
destroy: function () {
|
|
611
|
+
if (destroyed) return;
|
|
612
|
+
destroyed = true;
|
|
613
|
+
btn.removeEventListener('keydown', onBtnKey);
|
|
614
|
+
btn.removeEventListener('click', onBtnClick);
|
|
615
|
+
list.removeEventListener('click', onListClick);
|
|
616
|
+
if (form) form.removeEventListener('reset', onFormReset);
|
|
617
|
+
dismiss.destroy();
|
|
618
|
+
typeahead.destroy();
|
|
619
|
+
fr.clear();
|
|
620
|
+
list.hidden = true;
|
|
621
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
622
|
+
btn.removeAttribute('aria-activedescendant');
|
|
623
|
+
/* 只删我们自己补出来的 hidden(作者写的那几个不动) */
|
|
624
|
+
[].slice.call(root.querySelectorAll('input[type="hidden"][data-select-value="1"]'))
|
|
625
|
+
.forEach(function (i) { if (i.parentNode) i.parentNode.removeChild(i); });
|
|
626
|
+
},
|
|
537
627
|
};
|
|
538
628
|
}
|
|
539
629
|
|
|
@@ -118,8 +118,8 @@
|
|
|
118
118
|
</div>
|
|
119
119
|
<p class="cmp__why">
|
|
120
120
|
两栏的字形数差 1 —— 那 1 个就是圆点。这不是洁癖:
|
|
121
|
-
<strong>伪元素里的字符会参与可访问名计算</strong
|
|
122
|
-
|
|
121
|
+
<strong>伪元素里的字符会参与可访问名计算</strong>(实测:侧栏被读屏念成
|
|
122
|
+
“圆点 总览”)。要画形状就用盒子,不要用字。
|
|
123
123
|
</p>
|
|
124
124
|
|
|
125
125
|
<h2 class="rule-top">什么时候不该用它</h2>
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
----------------------------------------------------------------------------
|
|
38
38
|
同一个道理:颜色在强制色里会消失。这里用**圆点更大 + 标题更重**,
|
|
39
39
|
两条都是非颜色线索,且都不依赖 `::before` 里的字符
|
|
40
|
-
(`content: "●"` 会被算进可访问名,读屏念「圆点
|
|
40
|
+
(`content: "●"` 会被算进可访问名,读屏念「圆点 …」—— 实测过)。
|
|
41
41
|
========================================================================= */
|
|
42
42
|
|
|
43
43
|
.timeline {
|
|
@@ -38,6 +38,21 @@
|
|
|
38
38
|
| `.accordion__heading` | heading 包装(真实 `<h2>`–`<h6>`)|
|
|
39
39
|
| `.accordion__trigger` | 原生 `<button>` |
|
|
40
40
|
| `.accordion__panel` | 内容区 |
|
|
41
|
+
| `.accordion__hint` | 标题下方的补充说明(`--fs-xs` + `--text-secondary`,`display:block`)。放"展开后有什么"这类预期,能显著减少无谓展开 |
|
|
42
|
+
| `.accordion--flush` | 去掉外框与背景,融进已有卡片 / 侧栏里(`border:0; background:transparent`) |
|
|
43
|
+
| `.accordion__panel-inner.cols-auto` | 面板内容按内容宽度自动分列(`auto-fit` + `minmax(200px,1fr)`),窄了自动塌成一列 |
|
|
44
|
+
| `.accordion__panel-inner.cols-2` / `.cols-3` | 固定两列 / 三列;**≥720px 才分列**,窄屏塌成一列 |
|
|
45
|
+
| `.acc-table` | 面板里的表格:`width:100%` + `border-collapse`,**窄屏横向滚动而不挤压换行**(表格一旦换行,列头和数据就对不上) |
|
|
46
|
+
|
|
47
|
+
`cols-*` 加在 `.accordion__panel-inner` 上,不是加在 `.accordion__panel` 上:
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<div class="accordion__panel">
|
|
51
|
+
<div class="accordion__panel-inner cols-2">
|
|
52
|
+
<p>左栏</p><p>右栏</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
```
|
|
41
56
|
|
|
42
57
|
## 两种模式
|
|
43
58
|
|
|
@@ -25,6 +25,17 @@
|
|
|
25
25
|
.prose > table { ... }
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
**唯一的例外是 `.task-list-item`。** 它不是我们发明的:GFM 的任务列表会把
|
|
29
|
+
`<li>` 渲染成 `<li class="task-list-item"><input type="checkbox" disabled>`,
|
|
30
|
+
类名由渲染器给出,我们只能去接。所以 `content.css` 里只有它是类选择器:
|
|
31
|
+
|
|
32
|
+
| 类 | 谁来加 | 我们做什么 |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `.task-list-item` | markdown 渲染器(GFM 任务列表) | 去掉原生项目符号(`list-style:none`)、把复选框从缩进里拉回来(`margin-inline-start:-1.5em`);`disabled` 的复选框用 `accent-color` 表达状态,**不用 opacity**(灰到看不清等于没表达) |
|
|
35
|
+
|
|
36
|
+
⚠️ 只有 `.prose .task-list-item` 生效——任务列表必须出现在文章正文里,
|
|
37
|
+
裸的 `<ul>` 不接管。
|
|
38
|
+
|
|
28
39
|
结果:**从任何 CommonMark 兼容渲染器拿到的 HTML,不需要任何后处理就能正确显示。**
|
|
29
40
|
|
|
30
41
|
唯一的自定义类名是 `.prose`——它不改变语义,只是"这块是正文"的标记。
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
| 部件 | 说明 |
|
|
42
42
|
|---|---|
|
|
43
|
-
|
|
|
43
|
+
| `.drawer--right`(默认)/ `.drawer--left` / `.drawer--top` / `.drawer--bottom` | 四个进入方向。左右是侧栏(宽度由 `--drawer-w` 定),上下是**抽屉式面板**(`min(60vh, 420px)` 高、整宽),适合移动端底部操作区 |
|
|
44
44
|
| `.drawer__head` / `__title` / `__close` | 头部与关闭按钮 |
|
|
45
45
|
| `.drawer__body` | 内容 |
|
|
46
46
|
| `.drawer__foot` | 底部操作区 |
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
border: 1px solid var(--border-decor);
|
|
27
27
|
border-radius: var(--r-sm);
|
|
28
28
|
}
|
|
29
|
-
/* 反例专用:24px 一行(本库把它量出来过,见 dropdown.css
|
|
29
|
+
/* 反例专用:24px 一行(本库把它量出来过,见 dropdown.css 的实测记录) */
|
|
30
30
|
.dd-bug {
|
|
31
31
|
display: flex; align-items: center;
|
|
32
32
|
height: 24px; padding: 0 var(--sp-3);
|
|
@@ -117,6 +117,24 @@ e.textContent = v === '' ? '金额不能为空' : '最多两位小数,例如 1
|
|
|
117
117
|
4. 字段加 `aria-invalid="true"`
|
|
118
118
|
5. **异步校验时不要禁用输入框** —— 用户会以为不能打字了。保持可输入,旁边给小提示
|
|
119
119
|
|
|
120
|
+
### 异步校验的 pending 态两个类
|
|
121
|
+
|
|
122
|
+
| 类 | 加在哪 | 作用 |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `.field--checking` | `.field` 上 | 标记"这一格正在校验"。目前只作状态标记(并占住 `::after`,防止有人改用 padding 挤位置) |
|
|
125
|
+
| `.field__checking-hint` | `.field` 里、输入框后 | 显示一个小转圈 + 文字("正在检查用户名…")。转圈用 `::before` 画,配色走 `--accent` / `--border-control` |
|
|
126
|
+
|
|
127
|
+
```html
|
|
128
|
+
<div class="field field--checking">
|
|
129
|
+
<label class="field__label" for="u">用户名</label>
|
|
130
|
+
<input class="field__input" id="u" aria-describedby="u-check">
|
|
131
|
+
<span class="field__checking-hint" id="u-check">正在检查是否可用…</span>
|
|
132
|
+
</div>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
⚠️ 校验结束要把 `.field--checking` 摘掉、把 hint 换成错误或成功提示 ——
|
|
136
|
+
**留着不摘,用户会以为一直在转圈**,这比不显示更糟。
|
|
137
|
+
|
|
120
138
|
---
|
|
121
139
|
|
|
122
140
|
## 反例
|
|
@@ -142,7 +142,18 @@
|
|
|
142
142
|
"脏"是**正交标记**(一个字段可以同时是 dirty 且 state=invalid)。
|
|
143
143
|
塞进同一个属性 ⇒ 两个维度互斥,表达不了组合。
|
|
144
144
|
------------------------------------------------------------------------ */
|
|
145
|
-
|
|
145
|
+
/* 🔴 这里必须是 `.field .field__error`(两段),不能只写 `.field__error`。
|
|
146
|
+
原因:`02-primitives/input/input.css` 给 `.field__error` 定了**外观**
|
|
147
|
+
(`display:flex` + 图标 + 危险色),本文件给它定**可见性**(默认藏起来)。
|
|
148
|
+
两条同特异性(0,1,0)⇒ 谁后加载谁赢。实测:
|
|
149
|
+
|
|
150
|
+
input.css 先、本文件后 → display:none ✅ 校验前不报红
|
|
151
|
+
本文件先、input.css 后 → display:flex 🔴 首屏把全部错误都挂出来
|
|
152
|
+
|
|
153
|
+
也就是说"错误提示该不该显示"取决于打包器的 CSS 顺序 —— 用户永远不会想到
|
|
154
|
+
去查这个。加一层 `.field` 把特异性抬到 (0,2,0),顺序就不再是变量。
|
|
155
|
+
代价:错误元素必须在 `.field` 内(本库的结构契约就是这么定的)。 */
|
|
156
|
+
.field .field__error { display: none; }
|
|
146
157
|
.field[data-dirty] .field__error { display: flex; }
|
|
147
158
|
|
|
148
159
|
/* 错误汇总展开时,全部错误强制显示,不管字段脏不脏 —— 那是用户主动要看清单。
|
|
@@ -120,6 +120,17 @@ CSS 里另外关掉 hover 等微交互过渡——那是**不同层次**,别
|
|
|
120
120
|
|
|
121
121
|
触屏**没有 hover**,操作会彻底消失。
|
|
122
122
|
|
|
123
|
+
### `.list__note`:列表末尾的一行小字
|
|
124
|
+
|
|
125
|
+
`--fs-xs` + `--text-tertiary`,左右内边距与行对齐(`--sp-2 --sp-4`)。
|
|
126
|
+
放"共 128 条,按更新时间排序"这类**关于这个列表本身**的说明 ——
|
|
127
|
+
不要拿它当空状态用(空状态用 `states` 的 `.state--empty`)。
|
|
128
|
+
|
|
129
|
+
```html
|
|
130
|
+
<ul class="list">…</ul>
|
|
131
|
+
<p class="list__note">共 128 条,按更新时间排序</p>
|
|
132
|
+
```
|
|
133
|
+
|
|
123
134
|
---
|
|
124
135
|
|
|
125
136
|
## 反模式
|
|
@@ -15,13 +15,18 @@
|
|
|
15
15
|
|
|
16
16
|
| 类 | 作用 |
|
|
17
17
|
|---|---|
|
|
18
|
-
| `.
|
|
18
|
+
| `.dialog-backdrop` | 遮罩层(`position: fixed` 铺满视口,`role="dialog"` 的宿主) |
|
|
19
19
|
| `.dialog` | 弹窗面板 |
|
|
20
|
-
| `.dialog__title` / `
|
|
21
|
-
| `.toast-region` | 通知容器(`role="status" aria-live="polite"`)|
|
|
20
|
+
| `.dialog__title` / `__desc` / `__actions` | 弹窗内部结构 |
|
|
21
|
+
| `.toast-region` | 通知容器(`role="status"` `aria-live="polite"`)|
|
|
22
22
|
| `.toast` | 单条通知 |
|
|
23
|
-
| `.
|
|
24
|
-
| `.toast--success` /
|
|
23
|
+
| `.toast__title` / `__desc` / `__body` | 通知内部结构 |
|
|
24
|
+
| `.toast--success` / `.toast--error` / `.toast--warning` | 三种变体(**靠底色 + 图标双编码**,不只靠颜色)|
|
|
25
|
+
|
|
26
|
+
⚠️ **这张表曾经有三行是假的**(2026-11-02 修):`.overlay` 这个类全库
|
|
27
|
+
没有任何 CSS 定义(真实的遮罩叫 `.dialog-backdrop`)、`.toast__icon` 与
|
|
28
|
+
`.dialog__body` 同样不存在。它们是从早期版本抄下来的名字 ——
|
|
29
|
+
照着写的人会得到"样式没生效,但也不报错"。
|
|
25
30
|
|
|
26
31
|
⚠️ **三种 toast 变体在本 demo 里没有演示** ——
|
|
27
32
|
它们是给复用者的钩子,样式已备好,需要你自己触发。
|
|
@@ -57,9 +62,24 @@ Tab 在弹层内循环,**绝不跑到背后的页面去**。
|
|
|
57
62
|
**并加一层保险**:支持 `inert` 的浏览器上,把背景节点整体 `inert = true`——
|
|
58
63
|
比 focus trap 更彻底(鼠标点击也失效)。不支持就靠 focus trap 兜。
|
|
59
64
|
|
|
65
|
+
#### 背景隔离的三条硬要求(外部审计后补的)
|
|
66
|
+
|
|
67
|
+
| 要求 | 为什么 |
|
|
68
|
+
|---|---|
|
|
69
|
+
| **记原值再还原** | 页面可能本来就把某块设为 `inert`(另一个弹层、宿主锁屏)。关闭时固定写 `false` 会把它**误解锁** |
|
|
70
|
+
| **嵌套时隔离下层弹层** | 以前把所有 `.dialog-backdrop` 一律跳过,导致开 A 再开 B 时,**两个 dialog 同时暴露给读屏**。只该跳过自己这一层 |
|
|
71
|
+
| **打开期间新增的节点也要隔离** | 快照只拍一次是不够的:toast、埋点 iframe、框架 portal 在弹层开着时插进 `body`,不在快照里就漏网 ⇒ 用 `MutationObserver` 盯 `body` 的直接子节点 |
|
|
72
|
+
|
|
73
|
+
⚠️ 观察器必须**能自愈**:如果这一层的 backdrop 被外部直接摘掉(框架卸载、
|
|
74
|
+
测试清理),`close()` 永远不会跑 ⇒ 幽灵观察器会把之后插入的每个节点都设成
|
|
75
|
+
`inert` 且无人释放。症状是"之后任何 dialog 都抢不回焦点"。所以回调里先检查
|
|
76
|
+
自己是否还在文档里,不在就地回收。
|
|
77
|
+
|
|
60
78
|
### 2. 焦点归还
|
|
61
79
|
|
|
62
|
-
|
|
80
|
+
**关闭后焦点必须回到触发它的那个元素 —— 但只在"焦点还是我的"时候。**
|
|
81
|
+
先算 `focusIsMine`(焦点在弹层内 / 在 backdrop 上 / 已经掉到 body),
|
|
82
|
+
不是我的就**不要抢**:先关外层时,内层正拿着焦点。
|
|
63
83
|
不还的话焦点掉到 `<body>`,键盘用户要重新 Tab 一遍才能回到原处。
|
|
64
84
|
|
|
65
85
|
触发元素可能已被移除(用户点了别处),所以要有 fallback。
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
box-shadow: var(--shadow-2);
|
|
55
55
|
|
|
56
56
|
/* 🔴 去掉 `border-left: 3px solid`。
|
|
57
|
-
|
|
57
|
+
左侧竖条是纯装饰:3px 竖条在浅底上形成一条硬边,
|
|
58
58
|
视觉噪声大于信息量。已从全库去掉,**这里漏了**(典型的"只改一半")。
|
|
59
59
|
|
|
60
60
|
WCAG 1.4.1(不能只靠颜色)**不依赖竖条**:图标 + 文字本身就够了
|
|
@@ -194,8 +194,7 @@
|
|
|
194
194
|
|
|
195
195
|
/* 动作区:按钮**右对齐且相邻**。
|
|
196
196
|
🔴 去掉 space-between + margin-right:auto。
|
|
197
|
-
原来让破坏性按钮靠左、取消靠右,中间空一大片 ——
|
|
198
|
-
评审意见「删除和取消放到中间来」,看着别扭。
|
|
197
|
+
原来让破坏性按钮靠左、取消靠右,中间空一大片 —— 看着别扭。
|
|
199
198
|
常规做法(Linear / Vercel / GitHub 都这样):两个按钮挨着,一起靠右。
|
|
200
199
|
次要的在左(取消),主要的在右(删除)。 */
|
|
201
200
|
.dialog__actions {
|
|
@@ -283,6 +282,26 @@ body[data-scroll-locked] { overflow: hidden; }
|
|
|
283
282
|
.toast__timer { animation: none; width: 100%; } /* 进度条停住,不假装还在倒计时 */
|
|
284
283
|
}
|
|
285
284
|
|
|
285
|
+
/* ============================================================================
|
|
286
|
+
@media print —— 模态在纸上
|
|
287
|
+
=========================================================================
|
|
288
|
+
🔴 遮罩**不隐藏**,拉回正常流:`.dialog` 是 `.dialog-backdrop` 的子节点,
|
|
289
|
+
隐藏遮罩会把弹窗里的字一起吞掉(静默截断)。实测:屏幕开着 dialog 时
|
|
290
|
+
打印,遮罩是 `position: fixed; inset: 0; rgba(21,24,28,.4)`,整页 40% 黑。
|
|
291
|
+
⚠️ 必须写在这个文件里:写进 tokens.css 会被这里同特异性的 `position: fixed`
|
|
292
|
+
盖掉 —— 规则写了、不生效、不报错(E254;由 print-policy-gate L3 抓到)。
|
|
293
|
+
========================================================================= */
|
|
294
|
+
@media print {
|
|
295
|
+
.dialog-backdrop {
|
|
296
|
+
position: static;
|
|
297
|
+
inset: auto;
|
|
298
|
+
display: block;
|
|
299
|
+
padding: 0;
|
|
300
|
+
background: none;
|
|
301
|
+
}
|
|
302
|
+
.dialog { max-height: none; overflow: visible; }
|
|
303
|
+
}
|
|
304
|
+
|
|
286
305
|
/* ============================================================================
|
|
287
306
|
反模式
|
|
288
307
|
=========================================================================
|