agentcss 0.0.0-stage → 0.11.3

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.
Files changed (274) hide show
  1. package/01-tokens/README.md +236 -0
  2. package/01-tokens/behavior/dismissable.js +132 -0
  3. package/01-tokens/behavior/emit.js +76 -0
  4. package/01-tokens/behavior/focus-return.js +81 -0
  5. package/01-tokens/behavior/roving.js +152 -0
  6. package/01-tokens/behavior/typeahead.js +124 -0
  7. package/01-tokens/focus-ring-demo.html +133 -0
  8. package/01-tokens/focus-ring.css +102 -0
  9. package/01-tokens/page.css +244 -0
  10. package/01-tokens/polyfill.js +116 -0
  11. package/01-tokens/responsive-demo.html +171 -0
  12. package/01-tokens/scrollbar-demo.html +194 -0
  13. package/01-tokens/scrollbar.css +179 -0
  14. package/01-tokens/theme-toggle.js +103 -0
  15. package/01-tokens/tokens.css +972 -0
  16. package/01-tokens/typography.css +156 -0
  17. package/02-primitives/badge/README.md +71 -0
  18. package/02-primitives/badge/badge.css +141 -0
  19. package/02-primitives/badge/demo.html +101 -0
  20. package/02-primitives/button/README.md +99 -0
  21. package/02-primitives/button/button.css +353 -0
  22. package/02-primitives/button/demo.html +200 -0
  23. package/02-primitives/card/README.md +145 -0
  24. package/02-primitives/card/card.css +246 -0
  25. package/02-primitives/card/demo.html +170 -0
  26. package/02-primitives/choice/README.md +161 -0
  27. package/02-primitives/choice/choice.css +395 -0
  28. package/02-primitives/choice/demo.html +277 -0
  29. package/02-primitives/combobox/README.md +79 -0
  30. package/02-primitives/combobox/combobox.css +223 -0
  31. package/02-primitives/combobox/combobox.js +485 -0
  32. package/02-primitives/combobox/demo.html +109 -0
  33. package/02-primitives/date-range/README.md +100 -0
  34. package/02-primitives/date-range/date-range.css +206 -0
  35. package/02-primitives/date-range/date-range.js +301 -0
  36. package/02-primitives/date-range/demo.html +104 -0
  37. package/02-primitives/input/README.md +170 -0
  38. package/02-primitives/input/demo.html +246 -0
  39. package/02-primitives/input/input.css +239 -0
  40. package/02-primitives/popover/README.md +80 -0
  41. package/02-primitives/popover/demo.html +150 -0
  42. package/02-primitives/popover/popover.css +187 -0
  43. package/02-primitives/popover/popover.js +331 -0
  44. package/02-primitives/progress/README.md +82 -0
  45. package/02-primitives/progress/demo.html +154 -0
  46. package/02-primitives/progress/progress.css +172 -0
  47. package/02-primitives/select/README.md +95 -0
  48. package/02-primitives/select/demo.html +121 -0
  49. package/02-primitives/select/select.css +151 -0
  50. package/02-primitives/select/select.js +549 -0
  51. package/02-primitives/separator/README.md +63 -0
  52. package/02-primitives/separator/demo.html +84 -0
  53. package/02-primitives/separator/separator.css +73 -0
  54. package/02-primitives/skeleton/README.md +69 -0
  55. package/02-primitives/skeleton/demo.html +110 -0
  56. package/02-primitives/skeleton/skeleton.css +96 -0
  57. package/02-primitives/switch/README.md +65 -0
  58. package/02-primitives/switch/demo.html +115 -0
  59. package/02-primitives/switch/switch.css +370 -0
  60. package/03-patterns/accordion/README.md +121 -0
  61. package/03-patterns/accordion/accordion.css +453 -0
  62. package/03-patterns/accordion/accordion.js +369 -0
  63. package/03-patterns/accordion/demo.html +223 -0
  64. package/03-patterns/content/README.md +103 -0
  65. package/03-patterns/content/content.css +286 -0
  66. package/03-patterns/content/demo.html +88 -0
  67. package/03-patterns/drawer/README.md +79 -0
  68. package/03-patterns/drawer/demo.html +101 -0
  69. package/03-patterns/drawer/drawer.css +129 -0
  70. package/03-patterns/drawer/drawer.js +258 -0
  71. package/03-patterns/dropdown/README.md +78 -0
  72. package/03-patterns/dropdown/demo.html +101 -0
  73. package/03-patterns/dropdown/dropdown.css +135 -0
  74. package/03-patterns/dropdown/dropdown.js +451 -0
  75. package/03-patterns/form-validation/README.md +147 -0
  76. package/03-patterns/form-validation/demo.html +280 -0
  77. package/03-patterns/form-validation/form-validation.css +263 -0
  78. package/03-patterns/list/README.md +150 -0
  79. package/03-patterns/list/demo.html +225 -0
  80. package/03-patterns/list/flip.js +201 -0
  81. package/03-patterns/list/list.css +162 -0
  82. package/03-patterns/nav/README.md +225 -0
  83. package/03-patterns/nav/demo.html +131 -0
  84. package/03-patterns/nav/nav.css +314 -0
  85. package/03-patterns/nav/nav.js +430 -0
  86. package/03-patterns/nav/toc.js +111 -0
  87. package/03-patterns/overlay/README.md +196 -0
  88. package/03-patterns/overlay/demo.html +241 -0
  89. package/03-patterns/overlay/overlay.css +319 -0
  90. package/03-patterns/overlay/overlay.js +563 -0
  91. package/03-patterns/pagination/README.md +114 -0
  92. package/03-patterns/pagination/demo.html +165 -0
  93. package/03-patterns/pagination/pagination.css +195 -0
  94. package/03-patterns/pagination/pagination.js +395 -0
  95. package/03-patterns/reveal/README.md +91 -0
  96. package/03-patterns/reveal/demo.html +106 -0
  97. package/03-patterns/reveal/reveal.css +93 -0
  98. package/03-patterns/reveal/reveal.js +172 -0
  99. package/03-patterns/states/README.md +141 -0
  100. package/03-patterns/states/demo.html +240 -0
  101. package/03-patterns/states/states.css +252 -0
  102. package/03-patterns/tabs/README.md +84 -0
  103. package/03-patterns/tabs/demo.html +139 -0
  104. package/03-patterns/tabs/tabs.css +172 -0
  105. package/03-patterns/tabs/tabs.js +307 -0
  106. package/03-patterns/tooltip/README.md +96 -0
  107. package/03-patterns/tooltip/demo.html +78 -0
  108. package/03-patterns/tooltip/tooltip.css +89 -0
  109. package/03-patterns/tooltip/tooltip.js +288 -0
  110. package/03-patterns/tree/README.md +94 -0
  111. package/03-patterns/tree/demo.html +138 -0
  112. package/03-patterns/tree/tree.css +113 -0
  113. package/03-patterns/tree/tree.js +456 -0
  114. package/04-recipes/analysis-report/README.md +80 -0
  115. package/04-recipes/analysis-report/analysis-report.css +187 -0
  116. package/04-recipes/analysis-report/demo.html +274 -0
  117. package/04-recipes/data-showcase/README.md +45 -0
  118. package/04-recipes/data-showcase/demo.html +247 -0
  119. package/04-recipes/detail-page/README.md +93 -0
  120. package/04-recipes/detail-page/demo.html +55 -0
  121. package/04-recipes/detail-page/detail-page.css +190 -0
  122. package/04-recipes/detail-page/full.html +60 -0
  123. package/04-recipes/detail-page/null.html +60 -0
  124. package/04-recipes/detail-page/partial.html +60 -0
  125. package/04-recipes/detail-page/verify-skeleton.py +19 -0
  126. package/04-recipes/longform/README.md +93 -0
  127. package/04-recipes/longform/agc-memory.svg +4 -0
  128. package/04-recipes/longform/content.html +610 -0
  129. package/04-recipes/longform/content.md +51 -0
  130. package/04-recipes/longform/longform.html +627 -0
  131. package/04-recipes/report/README.md +176 -0
  132. package/04-recipes/report/demo.html +355 -0
  133. package/04-recipes/report/report.css +364 -0
  134. package/04-recipes/table/README.md +103 -0
  135. package/04-recipes/table/demo.html +455 -0
  136. package/04-recipes/table/table.css +372 -0
  137. package/04-recipes/tierA-tokens.css +189 -0
  138. package/09-assets/README.md +74 -0
  139. package/09-assets/bar/README.md +94 -0
  140. package/09-assets/bar/bar.css +166 -0
  141. package/09-assets/bar/bar.js +320 -0
  142. package/09-assets/bar/demo.html +176 -0
  143. package/09-assets/echarts-adapter/demo.html +192 -0
  144. package/09-assets/echarts-adapter/echarts-adapter.js +277 -0
  145. package/09-assets/model-viewer/README.md +155 -0
  146. package/09-assets/model-viewer/demo.html +105 -0
  147. package/09-assets/model-viewer/model-viewer.css +143 -0
  148. package/09-assets/model-viewer/model-viewer.js +423 -0
  149. package/09-assets/scientific-plot/README.md +94 -0
  150. package/09-assets/scientific-plot/demo.html +252 -0
  151. package/09-assets/scientific-plot/scientific-plot.css +141 -0
  152. package/09-assets/scientific-plot/scientific-plot.js +610 -0
  153. package/09-assets/sparkline/README.md +153 -0
  154. package/09-assets/sparkline/demo.html +163 -0
  155. package/09-assets/sparkline/sparkline.css +158 -0
  156. package/09-assets/sparkline/sparkline.js +508 -0
  157. package/API.md +332 -0
  158. package/CHANGELOG.md +3138 -0
  159. package/CODE_OF_CONDUCT.md +64 -0
  160. package/CONTRIBUTING.md +89 -0
  161. package/ERRORS.md +1658 -0
  162. package/LICENSE +21 -0
  163. package/README.md +497 -2
  164. package/SECURITY.md +72 -0
  165. package/START-HERE.md +164 -0
  166. package/SUPPORT.md +109 -0
  167. package/adapters/presentation/README.md +113 -0
  168. package/adapters/presentation/check.js +101 -0
  169. package/adapters/presentation/deck.schema.json +74 -0
  170. package/adapters/presentation/to-doc.js +138 -0
  171. package/ai/START-HERE.md +104 -0
  172. package/ai/behaviors.json +380 -0
  173. package/ai/behaviors.schema.json +61 -0
  174. package/ai/capabilities.json +56 -0
  175. package/ai/cli.js +387 -0
  176. package/ai/components.json +1944 -0
  177. package/ai/components.meta.json +168 -0
  178. package/ai/components.schema.json +220 -0
  179. package/ai/contract.schema.json +147 -0
  180. package/ai/deprecations.json +55 -0
  181. package/ai/deprecations.schema.json +85 -0
  182. package/ai/motion-policy.json +22 -0
  183. package/ai/patch.js +323 -0
  184. package/ai/patch.schema.json +63 -0
  185. package/ai/profiles.json +92 -0
  186. package/ai/tokens.audience.json +55 -0
  187. package/ai/tokens.audience.schema.json +73 -0
  188. package/ai/tokens.json +762 -0
  189. package/ai/tokens.schema.json +58 -0
  190. package/ai/tokens.tree.json +752 -0
  191. package/ai/tokens.tree.schema.json +56 -0
  192. package/ai/validate.js +383 -0
  193. package/dist/01-tokens/behavior/dismissable.js +60 -0
  194. package/dist/01-tokens/behavior/emit.js +21 -0
  195. package/dist/01-tokens/behavior/focus-return.js +31 -0
  196. package/dist/01-tokens/behavior/roving.js +77 -0
  197. package/dist/01-tokens/behavior/typeahead.js +61 -0
  198. package/dist/01-tokens/focus-ring.css +1 -0
  199. package/dist/01-tokens/page.css +1 -0
  200. package/dist/01-tokens/polyfill.js +78 -0
  201. package/dist/01-tokens/scrollbar.css +1 -0
  202. package/dist/01-tokens/theme-toggle.js +61 -0
  203. package/dist/01-tokens/tokens.css +1 -0
  204. package/dist/01-tokens/typography.css +1 -0
  205. package/dist/02-primitives/badge/badge.css +1 -0
  206. package/dist/02-primitives/button/button.css +1 -0
  207. package/dist/02-primitives/card/card.css +1 -0
  208. package/dist/02-primitives/choice/choice.css +1 -0
  209. package/dist/02-primitives/combobox/combobox.css +1 -0
  210. package/dist/02-primitives/combobox/combobox.js +439 -0
  211. package/dist/02-primitives/date-range/date-range.css +1 -0
  212. package/dist/02-primitives/date-range/date-range.js +267 -0
  213. package/dist/02-primitives/input/input.css +1 -0
  214. package/dist/02-primitives/popover/popover.css +1 -0
  215. package/dist/02-primitives/popover/popover.js +257 -0
  216. package/dist/02-primitives/progress/progress.css +1 -0
  217. package/dist/02-primitives/select/select.css +1 -0
  218. package/dist/02-primitives/select/select.js +449 -0
  219. package/dist/02-primitives/separator/separator.css +1 -0
  220. package/dist/02-primitives/skeleton/skeleton.css +1 -0
  221. package/dist/02-primitives/switch/switch.css +1 -0
  222. package/dist/03-patterns/accordion/accordion.css +1 -0
  223. package/dist/03-patterns/accordion/accordion.js +219 -0
  224. package/dist/03-patterns/content/content.css +1 -0
  225. package/dist/03-patterns/drawer/drawer.css +1 -0
  226. package/dist/03-patterns/drawer/drawer.js +229 -0
  227. package/dist/03-patterns/dropdown/dropdown.css +1 -0
  228. package/dist/03-patterns/dropdown/dropdown.js +365 -0
  229. package/dist/03-patterns/form-validation/form-validation.css +1 -0
  230. package/dist/03-patterns/list/flip.js +117 -0
  231. package/dist/03-patterns/list/list.css +1 -0
  232. package/dist/03-patterns/nav/nav.css +1 -0
  233. package/dist/03-patterns/nav/nav.js +322 -0
  234. package/dist/03-patterns/nav/toc.js +76 -0
  235. package/dist/03-patterns/overlay/overlay.css +1 -0
  236. package/dist/03-patterns/overlay/overlay.js +398 -0
  237. package/dist/03-patterns/pagination/pagination.css +1 -0
  238. package/dist/03-patterns/pagination/pagination.js +273 -0
  239. package/dist/03-patterns/reveal/reveal.css +1 -0
  240. package/dist/03-patterns/reveal/reveal.js +138 -0
  241. package/dist/03-patterns/states/states.css +1 -0
  242. package/dist/03-patterns/tabs/tabs.css +1 -0
  243. package/dist/03-patterns/tabs/tabs.js +242 -0
  244. package/dist/03-patterns/tooltip/tooltip.css +1 -0
  245. package/dist/03-patterns/tooltip/tooltip.js +213 -0
  246. package/dist/03-patterns/tree/tree.css +1 -0
  247. package/dist/03-patterns/tree/tree.js +383 -0
  248. package/dist/04-recipes/analysis-report/analysis-report.css +1 -0
  249. package/dist/04-recipes/detail-page/detail-page.css +1 -0
  250. package/dist/04-recipes/report/report.css +1 -0
  251. package/dist/04-recipes/table/table.css +1 -0
  252. package/dist/04-recipes/tierA-tokens.css +1 -0
  253. package/dist/09-assets/bar/bar.css +1 -0
  254. package/dist/09-assets/bar/bar.js +222 -0
  255. package/dist/09-assets/echarts-adapter/echarts-adapter.js +185 -0
  256. package/dist/09-assets/model-viewer/model-viewer.css +1 -0
  257. package/dist/09-assets/model-viewer/model-viewer.js +320 -0
  258. package/dist/09-assets/scientific-plot/scientific-plot.css +1 -0
  259. package/dist/09-assets/scientific-plot/scientific-plot.js +568 -0
  260. package/dist/09-assets/sparkline/sparkline.css +1 -0
  261. package/dist/09-assets/sparkline/sparkline.js +265 -0
  262. package/dist/COSTS.md +56 -0
  263. package/docs/AI-CONTRACT.md +197 -0
  264. package/docs/BACKLOG.md +127 -0
  265. package/docs/BENCHMARK.md +252 -0
  266. package/docs/GUIDANCE.md +154 -0
  267. package/docs/INVARIANT.md +372 -0
  268. package/docs/LAYER-BASELINE.md +158 -0
  269. package/docs/RELEASING.md +311 -0
  270. package/docs/RFC-THEME-SCOPE.md +141 -0
  271. package/docs/ROADMAP.md +182 -0
  272. package/index.html +82 -0
  273. package/package.json +107 -4
  274. package/types/index.d.ts +310 -0
@@ -0,0 +1,563 @@
1
+ /* ============================================================================
2
+ frontend-lib / 03-patterns / overlay / overlay.js
3
+ Toast 与模态框的行为层
4
+
5
+ 依赖:overlay.css
6
+ 为什么必须有 JS:focus trap、滚动锁定、焦点归还 —— 三件都是纯 CSS 做不到的。
7
+
8
+ 🔴 语法刻意用 ES5(无箭头函数 / const / 模板字符串):
9
+ 精简档 是 某项目 上的 WebView,版本不确定。
10
+ demo 可以用现代语法(跑在用户浏览器上),库代码不行。
11
+
12
+ API:
13
+ Overlay.toast({ title, desc, variant, duration })
14
+ Overlay.dialog({ title, desc, actions, danger, initialFocus })
15
+ → 返回 { el, close }
16
+ ========================================================================= */
17
+ (function (global) {
18
+ 'use strict';
19
+
20
+ var FOCUSABLE = [
21
+ 'a[href]',
22
+ 'button:not([disabled])',
23
+ 'input:not([disabled]):not([type="hidden"])',
24
+ 'select:not([disabled])',
25
+ 'textarea:not([disabled])',
26
+ '[tabindex]:not([tabindex="-1"])'
27
+ ].join(',');
28
+
29
+ var lockCount = 0; /* 防止多个弹层重复加 padding */
30
+
31
+ /* 🔴 滚动锁定的**记账**
32
+ 以前只有一个 `savedPaddingRight`,问题是:
33
+ · 支持 scrollbar-gutter 时它**从头到尾是 ''**,
34
+ 关闭时却无条件 `body.style.paddingRight = savedPaddingRight`
35
+ ⇒ 把宿主页面原有的内联 padding-right **清空了**;
36
+ · 降级路径里它是**替换**(`= sbw + 'px'`),把宿主原有的值覆盖掉。
37
+ ⇒ 改成两件事分开记:**原值** + **这次到底改没改**。
38
+ 只恢复本组件**真的改过**的属性 —— 没碰过的东西不许写回。 */
39
+ var scrollLock = { changed: false, saved: '', varSet: false };
40
+
41
+ /* 🔴 背景 inert 的**引用计数**
42
+ 以前用「所有权记账」:`changedByMe = !n.inert` ——
43
+ 只有自己真正设过 true 的节点,关闭时才恢复 false。
44
+ ⚠️ 它**假设弹层按打开的逆序关闭**(LIFO)。一旦不是:
45
+ A 开 → 背景 locked(A 记 changedByMe=true)
46
+ B 开 → 背景已 inert(B 记 changedByMe=false,不碰它)
47
+ **先关 A** → A 解锁背景 ⇒ B 还开着,背景却能点了
48
+ 用户看到的是「弹窗明明还在,页面却能点」。
49
+ ⇒ 改成引用计数:**只要还有弹层需要锁,背景就不许解锁**,
50
+ 与关闭顺序无关。这与"所有权"的区别就是引用计数解决不了的问题。 */
51
+ var inertRefs = []; /* [{node, count}] —— ES5 没有 Map,用数组 */
52
+ /* 不支持 inert 的旧 WebView 走这套(见下方 ariaAcquire 的说明) */
53
+ var ariaRefs = []; /* [{node, count, prev}] —— prev 是原来的 aria-hidden 值 */
54
+
55
+ /* ---------------------------------------------------------------- 工具 */
56
+ function focusableIn(root) {
57
+ var all = Array.prototype.slice.call(root.querySelectorAll(FOCUSABLE));
58
+ /* offsetParent 为 null 表示元素不可见(display:none / hidden)——
59
+ 不可见的元素不能被聚焦,否则 trap 会把焦点送给空气 */
60
+ return all.filter(function (el) {
61
+ return el.offsetParent !== null || el === document.activeElement;
62
+ });
63
+ }
64
+
65
+ /* ---------------------------------------------------------------- 滚动锁定
66
+ 🔴 实测反馈"弹窗打开时整页明显向右移动"。
67
+
68
+ 为什么不用 `scrollbar-gutter: stable`(两种写法都量过,都放弃):
69
+ · 无条件 `html{…}` ⇒ **导入即生效**:一个弹层没开过,宿主的
70
+ `100vw` 也已被压窄 ⇒ 改宿主全局布局,与"组件只影响自己"冲突。
71
+ · 状态级 `html[data-scroll-locked]{…}` ⇒ 锁定后才占位,
72
+ `100vw` 元素反而从 393 跳到 378 ⇒ 抖动只是换了个地方发生。
73
+ ⇒ 改回 JS **叠加**补偿:基准取 `getComputedStyle` 而不是内联样式
74
+ (宿主的 padding 也可能来自样式表),替换会把宿主排版吃掉。
75
+
76
+ ⭐ 补偿只能覆盖流式内容。`position: fixed` 相对**视口**定位,
77
+ body 的 padding 管不到 ⇒ 把滚动条宽度公开成
78
+ `--overlay-scrollbar-width`(仅锁定期间存在)让宿主自己补:
79
+ .my-fixed { right: var(--overlay-scrollbar-width, 0px) }
80
+ */
81
+ function lockScroll() {
82
+ if (lockCount++ > 0) return;
83
+
84
+ /* ⭐ 先量,再锁 —— 锁上之后滚动条就没了,量出来是 0 */
85
+ var sbw = window.innerWidth - document.documentElement.clientWidth;
86
+ if (sbw > 0) {
87
+ var cur = parseFloat(getComputedStyle(document.body).paddingRight) || 0;
88
+ scrollLock.saved = document.body.style.paddingRight;
89
+ document.body.style.paddingRight = (cur + sbw) + 'px';
90
+ scrollLock.changed = true;
91
+ document.documentElement.style.setProperty(
92
+ '--overlay-scrollbar-width', sbw + 'px');
93
+ scrollLock.varSet = true;
94
+ }
95
+ document.body.setAttribute('data-scroll-locked', 'true');
96
+ }
97
+
98
+ function unlockScroll() {
99
+ if (--lockCount > 0) return;
100
+ document.body.removeAttribute('data-scroll-locked');
101
+ if (scrollLock.varSet) {
102
+ document.documentElement.style.removeProperty('--overlay-scrollbar-width');
103
+ scrollLock.varSet = false;
104
+ }
105
+ /* 🔴 只恢复**自己真的改过**的。没碰过就一个字节都不许写回 ——
106
+ 否则会把宿主原有的内联 padding-right 清成空字符串。 */
107
+ if (scrollLock.changed) {
108
+ document.body.style.paddingRight = scrollLock.saved;
109
+ scrollLock.changed = false;
110
+ scrollLock.saved = '';
111
+ }
112
+ }
113
+
114
+ /** 取一个背景节点的引用计数条目(没有就建一个并置 inert)。 */
115
+ function inertAcquire(node) {
116
+ for (var i = 0; i < inertRefs.length; i++) {
117
+ if (inertRefs[i].node === node) { inertRefs[i].count++; return; }
118
+ }
119
+ inertRefs.push({ node: node, count: 1 });
120
+ node.inert = true;
121
+ }
122
+
123
+ /** 还一个引用;降到 0 才真正解锁。 */
124
+ function inertRelease(node) {
125
+ for (var i = 0; i < inertRefs.length; i++) {
126
+ if (inertRefs[i].node !== node) continue;
127
+ inertRefs[i].count--;
128
+ if (inertRefs[i].count <= 0) {
129
+ node.inert = false;
130
+ inertRefs.splice(i, 1);
131
+ }
132
+ return;
133
+ }
134
+ }
135
+
136
+ /* ============================================================ aria 兜底
137
+ 🔴 为什么需要这一套
138
+ 本库的目标是「旧 WebView 也要能用」,而 `inert` 要
139
+ Safari 15.5+ / Chrome 102+(≈2022 年),旧 WebView 浏览器没有。
140
+ 没有 inert 时,此前只剩**焦点陷阱**:键盘 Tab 是逃不出去,
141
+ 但**屏幕阅读器的假想光标仍能读到背景内容**(焦点陷阱管的是 focus,
142
+ 管不了浏览模式下的虚拟光标)。
143
+ ⇒ 不支持 inert 时用 aria-hidden 把背景从无障碍树里摘掉,
144
+ 与焦点陷阱叠加,才是完整的"背景不可达"。
145
+ ⚠️ aria-hidden 不能替代 inert 的**交互屏蔽**(它不阻止点击),
146
+ 所以这里只作为补充,不去掉焦点陷阱。 */
147
+
148
+ /** 取一个背景节点的 aria 引用计数条目(没有就建一个并置 aria-hidden)。 */
149
+ function ariaAcquire(node) {
150
+ for (var i = 0; i < ariaRefs.length; i++) {
151
+ if (ariaRefs[i].node === node) { ariaRefs[i].count++; return; }
152
+ }
153
+ /* 记住原值:可能是 null(本来没有这个属性),也可能本来就是 true/false。
154
+ 恢复时必须回到原样 —— 否则我们自己就成了新的无障碍缺陷。 */
155
+ ariaRefs.push({ node: node, count: 1, prev: node.getAttribute('aria-hidden') });
156
+ node.setAttribute('aria-hidden', 'true');
157
+ }
158
+
159
+ /** 还一个 aria 引用;降到 0 才恢复原值。 */
160
+ function ariaRelease(node) {
161
+ for (var i = 0; i < ariaRefs.length; i++) {
162
+ if (ariaRefs[i].node !== node) continue;
163
+ ariaRefs[i].count--;
164
+ if (ariaRefs[i].count <= 0) {
165
+ var prev = ariaRefs[i].prev;
166
+ if (prev === null) node.removeAttribute('aria-hidden');
167
+ else node.setAttribute('aria-hidden', prev);
168
+ ariaRefs.splice(i, 1);
169
+ }
170
+ return;
171
+ }
172
+ }
173
+
174
+ /* ============================================ 交互隔离(仅旧 WebView)
175
+ 🔴 第三轮外部审计要求把这两件事分开:原文:
176
+ 「aria-hidden 不阻止键盘或鼠标交互,测试也没有断言 Tab 焦点不会逃出;
177
+ 应把『辅助技术隐藏背景』和『键盘/指针交互隔离』作为**不同契约**,
178
+ 避免把前者通过误读成后者已解决。」
179
+
180
+ ⇒ 本文件从此把两件事分开写、分开关:
181
+
182
+ · 辅助技术隐藏:背景从无障碍树摘掉
183
+ 现代 → `inert`(浏览器顺手把交互也挡了)
184
+ 旧 → `aria-hidden="true"`(**只管读屏,不管键盘鼠标**)
185
+ · 交互隔离:背景收不到焦点、收不到指针事件
186
+ 现代 → `inert` 自带,不用我们做
187
+ 旧 → **浏览器没有任何机制可用**,只能自己装 document 级守卫(本函数)
188
+
189
+ ⚠️ 为什么只在**没有 inert** 时装守卫:
190
+ 有 inert 时背景本来就不可聚焦、不可点击,再拦一层只会给宿主页面
191
+ 添意外行为(比如它自己写在 document 上的委托监听被吞掉)。
192
+ 也就是说:现代路径的行为**一个字节都没变**。 */
193
+ var openStack = []; /* 打开着的弹层,最后一个是最上层(嵌套用) */
194
+
195
+ function installIsolation(entry) {
196
+ var box = entry.box;
197
+ var backdrop = entry.backdrop;
198
+ var lastInside = null;
199
+
200
+ function topmost() { return openStack[openStack.length - 1]; }
201
+
202
+ /** 把焦点拉回弹层。优先回到**刚才在弹层里的那个元素**,别每次从头开始。 */
203
+ function pullBack() {
204
+ if (lastInside && document.contains(lastInside) && box.contains(lastInside)) {
205
+ lastInside.focus();
206
+ return;
207
+ }
208
+ var f = focusableIn(box);
209
+ if (f.length) { f[0].focus(); return; }
210
+ box.focus();
211
+ }
212
+
213
+ /* 键盘一侧:focusin 是全捕获的 —— 不管是 Tab、还是脚本 focus()、
214
+ 还是读屏软件移焦点,只要焦点落到背景就拉回来。 */
215
+ function onFocusIn(e) {
216
+ if (topmost() !== entry) return; /* 不是最上层:别抢内层的焦点 */
217
+ var t = e.target;
218
+ if (box.contains(t) || backdrop.contains(t)) { lastInside = t; return; }
219
+ pullBack();
220
+ }
221
+
222
+ /* 指针一侧:与 inert 对齐 —— 事件**根本不送到宿主页面**。
223
+ 原来的问题是:旧 WebView 上背景按钮照样能点,而 inert 下不能 ⇒ 两种
224
+ 环境的行为不一致,且"背景不可达"这条承诺只在现代浏览器成立。 */
225
+ function onPointer(e) {
226
+ if (topmost() !== entry) return;
227
+ if (backdrop.contains(e.target)) return;
228
+ if (e.cancelable) e.preventDefault();
229
+ e.stopPropagation();
230
+ }
231
+
232
+ document.addEventListener('focusin', onFocusIn, true);
233
+ document.addEventListener('pointerdown', onPointer, true);
234
+ document.addEventListener('mousedown', onPointer, true);
235
+ document.addEventListener('touchstart', onPointer, true);
236
+ /* `click` 也要拦:合成事件与老 WebView 的"tap→click"路径都可能绕过
237
+ pointerdown / mousedown。 */
238
+ document.addEventListener('click', onPointer, true);
239
+
240
+ return function uninstall() {
241
+ document.removeEventListener('focusin', onFocusIn, true);
242
+ document.removeEventListener('pointerdown', onPointer, true);
243
+ document.removeEventListener('mousedown', onPointer, true);
244
+ document.removeEventListener('touchstart', onPointer, true);
245
+ document.removeEventListener('click', onPointer, true);
246
+ };
247
+ }
248
+
249
+ /* ================================================================ Toast
250
+ 🔴 绝不抢焦点。role="status" 会被屏幕阅读器自动播报,
251
+ focus() 会打断用户当前正在做的事。
252
+ */
253
+ function toast(opts) {
254
+ opts = opts || {};
255
+
256
+ var region = document.querySelector('.toast-region');
257
+ if (!region) {
258
+ region = document.createElement('div');
259
+ region.className = 'toast-region';
260
+ /* role=status 让整个区域成为 live region,不用每个 toast 都标 */
261
+ region.setAttribute('role', 'status');
262
+ region.setAttribute('aria-live', 'polite');
263
+ document.body.appendChild(region);
264
+ }
265
+
266
+ var el = document.createElement('div');
267
+ el.className = 'toast' + (opts.variant ? ' toast--' + opts.variant : '');
268
+
269
+ var body = document.createElement('div');
270
+ body.className = 'toast__body';
271
+ if (opts.title) {
272
+ var t = document.createElement('p');
273
+ t.className = 'toast__title';
274
+ t.textContent = opts.title;
275
+ body.appendChild(t);
276
+ }
277
+ if (opts.desc) {
278
+ var d = document.createElement('p');
279
+ d.className = 'toast__desc';
280
+ d.textContent = opts.desc;
281
+ body.appendChild(d);
282
+ }
283
+ el.appendChild(body);
284
+
285
+ /* 错误必须能手动关,而且默认不自动消失 ——
286
+ 3 秒后悄悄消失,用户还没读完就等于没说。 */
287
+ var auto = opts.duration;
288
+ if (auto === undefined) {
289
+ auto = (opts.variant === 'error') ? 0 : 4000;
290
+ }
291
+
292
+ if (auto > 0) {
293
+ var timer = document.createElement('div');
294
+ timer.className = 'toast__timer';
295
+ timer.style.animationDuration = auto + 'ms';
296
+ el.appendChild(timer);
297
+ }
298
+
299
+ var closeBtn = document.createElement('button');
300
+ closeBtn.className = 'toast__close';
301
+ closeBtn.type = 'button';
302
+ closeBtn.setAttribute('aria-label', '关闭提示');
303
+ closeBtn.textContent = '×';
304
+ el.appendChild(closeBtn);
305
+
306
+ var closed = false;
307
+ function close() {
308
+ if (closed) return;
309
+ closed = true;
310
+ if (auto) clearTimeout(timerId);
311
+ el.setAttribute('data-state', 'leaving');
312
+ /* 动画结束再移除;reduced-motion 下 animation:none,220ms 后直接删 */
313
+ setTimeout(function () {
314
+ if (el.parentNode) el.parentNode.removeChild(el);
315
+ }, 220);
316
+ }
317
+
318
+ closeBtn.addEventListener('click', close);
319
+ region.appendChild(el);
320
+
321
+ var timerId = auto > 0 ? setTimeout(close, auto) : null;
322
+ return { el: el, close: close };
323
+ }
324
+
325
+ /* ================================================================ Dialog */
326
+ /* ==== BEHAVIOR INJECT BEGIN: focus-return ==== */
327
+ var flFocusReturn = function (opts) {
328
+ var opt = opts || {};
329
+ var saved = null;
330
+
331
+ return {
332
+ save: function () {
333
+ var a = document.activeElement;
334
+ saved = (a && a !== document.body && a.focus) ? a : null;
335
+ return saved;
336
+ },
337
+
338
+ restore: function () {
339
+ var target = saved;
340
+ /* ⚠️ 见文档 ②:不判 contains 的话,元素被移除后 focus() 不报错
341
+ 但焦点不动 ⇒ 用户以为还回去了,其实掉在 body 上。 */
342
+ if (!target || !document.contains(target)) target = opt.fallback || null;
343
+ if (!target) { saved = null; return false; }
344
+ try {
345
+ target.focus();
346
+ } catch (e) {
347
+ saved = null;
348
+ return false;
349
+ }
350
+ var ok = document.activeElement === target;
351
+ saved = null; /* 见文档 ③ */
352
+ return ok;
353
+ },
354
+
355
+ target: function () { return saved; },
356
+ clear: function () { saved = null; },
357
+ };
358
+ };
359
+ /* ==== BEHAVIOR INJECT END: focus-return ==== */
360
+ function dialog(opts) {
361
+ opts = opts || {};
362
+
363
+ /* ⭐ 焦点归还**绑到这个实例**,不进全局栈。
364
+ 全局栈 + pop() 在非 LIFO 关闭时会还错人
365
+ (先关外层会把焦点从内层手里抢走)。 */
366
+ /* 焦点归还:走 focus-return 核(0.8.6 收编)。
367
+ 🔴 收编修掉一个**真 bug**:原来这里是
368
+ if (focusIsMine && myTrigger && document.contains(myTrigger))
369
+ ——`focusIsMine` 这个变量**在本文件里根本不存在** ⇒ 条件恒假
370
+ ⇒ **焦点从来没还过**。而 dialog 是模态、不点外部关闭,
371
+ 所以这条唯一的出路就是 Esc/按钮关闭时归位 ⇒ 键盘用户必迷路。
372
+ 核的 save() 记的就是 document.activeElement,语义正好对上。 */
373
+ var fr = flFocusReturn();
374
+ fr.save();
375
+
376
+ var backdrop = document.createElement('div');
377
+ backdrop.className = 'dialog-backdrop';
378
+
379
+ var box = document.createElement('div');
380
+ box.className = 'dialog' + (opts.danger ? ' dialog--danger' : '');
381
+ box.setAttribute('role', 'dialog');
382
+ box.setAttribute('aria-modal', 'true');
383
+ /* tabindex=-1 让容器本身可聚焦 —— 打开时焦点落在这里,
384
+ 屏幕阅读器会读出标题和描述,比直接扔到"确定"按钮上更有用 */
385
+ box.setAttribute('tabindex', '-1');
386
+
387
+ if (opts.title) {
388
+ var h = document.createElement('h2');
389
+ h.className = 'dialog__title';
390
+ h.id = 'dlg-title-' + Date.now();
391
+ h.textContent = opts.title;
392
+ box.appendChild(h);
393
+ box.setAttribute('aria-labelledby', h.id);
394
+ }
395
+ if (opts.desc) {
396
+ var p = document.createElement('p');
397
+ p.className = 'dialog__desc';
398
+ p.id = 'dlg-desc-' + Date.now();
399
+ p.textContent = opts.desc;
400
+ box.appendChild(p);
401
+ box.setAttribute('aria-describedby', p.id);
402
+ }
403
+
404
+ var act = document.createElement('div');
405
+ act.className = 'dialog__actions';
406
+ (opts.actions || []).forEach(function (a) {
407
+ var b = document.createElement('button');
408
+ b.type = 'button';
409
+ b.className = 'btn ' + (a.variant === 'danger' ? 'btn--danger'
410
+ : a.variant === 'primary' ? 'btn--primary' : 'btn--secondary');
411
+ b.textContent = a.label;
412
+ b.addEventListener('click', function () {
413
+ if (a.onClick) a.onClick(close);
414
+ if (a.keepOpen !== true) close();
415
+ });
416
+ act.appendChild(b);
417
+ });
418
+ box.appendChild(act);
419
+
420
+ backdrop.appendChild(box);
421
+ document.body.appendChild(backdrop);
422
+ lockScroll();
423
+
424
+ /* 🔴 入栈与装守卫必须**早于设置初始焦点**(下面「初始焦点」那一段)。
425
+ 实测踩过:放在后面时,外层弹层的守卫先看到"焦点落到我自己范围外"
426
+ (其实是内层弹层刚拿到焦点),立刻把它拉回外层 ⇒ 内层的初始焦点失效,
427
+ 连带的后果是内层关掉后外层再也收不到 keydown,Esc 失效、aria-hidden
428
+ 永远不还原。⇒ 顺序:appendChild → 入栈 → 装守卫 → 设初始焦点。 */
429
+ var useInert = 'inert' in HTMLElement.prototype;
430
+ var stackEntry = { backdrop: backdrop, box: box };
431
+ var uninstallIsolation = null;
432
+ openStack.push(stackEntry);
433
+ if (!useInert) uninstallIsolation = installIsolation(stackEntry);
434
+
435
+ /* ------------------------------------------------------------ 焦点 */
436
+ function focusables() { return focusableIn(box); }
437
+
438
+ box.addEventListener('keydown', function (e) {
439
+ if (e.key === 'Escape') {
440
+ e.preventDefault();
441
+ /* 🔴 必须 stopPropagation:弹层可嵌套。
442
+ * 不拦住的话,内层的 Esc 会**冒泡到外层**,
443
+ * 一次按键把两层全关掉 —— 键盘用户会以为应用崩了。
444
+ * (本库在 popover 上踩过同一个坑,这里一并修好。) */
445
+ e.stopPropagation();
446
+ close();
447
+ return;
448
+ }
449
+ if (e.key !== 'Tab') return;
450
+
451
+ /* focus trap:Tab 在弹层内循环,绝不跑到背后的页面去 */
452
+ var f = focusables();
453
+ if (!f.length) { e.preventDefault(); box.focus(); return; }
454
+ var first = f[0], last = f[f.length - 1];
455
+
456
+ if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) {
457
+ e.preventDefault(); last.focus();
458
+ } else if (!e.shiftKey && document.activeElement === last) {
459
+ e.preventDefault(); first.focus();
460
+ }
461
+ });
462
+
463
+ /* 初始焦点:
464
+ 默认给容器(读标题+描述);
465
+ initialFocus:'cancel' 给取消按钮 —— 破坏性操作用这个,
466
+ 避免用户条件反射按 Enter 把文件删了。 */
467
+ if (opts.initialFocus === 'cancel') {
468
+ var cancel = box.querySelector('.btn:not(.btn--danger)');
469
+ if (cancel) cancel.focus();
470
+ else box.focus();
471
+ } else {
472
+ box.focus();
473
+ }
474
+
475
+ /* 点击遮罩关闭。破坏性操作不要给这个 —— 误点就麻烦了 */
476
+ if (opts.closeOnBackdrop !== false && !opts.danger) {
477
+ backdrop.addEventListener('mousedown', function (e) {
478
+ if (e.target === backdrop) close();
479
+ });
480
+ }
481
+
482
+ var closed = false;
483
+ var inerted = [];
484
+ var ariaed = []; /* 不支持 inert 时改走这条(见 ariaAcquire 的说明) */
485
+
486
+ /* 背景 inert:把背后页面整个变不可交互 —— 比 focus trap 更彻底。
487
+ 支持就用(现代浏览器都有),不支持就靠上面的 focus trap 兜底。
488
+
489
+ 🔴🔴 计数用**引用计数**,不用「所有权」
490
+ ---------------------------------------------------------------------------
491
+ 以前的写法是 `changedByMe = !n.inert`:只有自己真正设过 true 的,
492
+ 关闭时才恢复 false。**它假设弹层按打开的逆序关闭**。
493
+ 一旦调用方先关外层(公开的 close() 允许这么做):
494
+ A 开 → 背景 locked(A 记 changedByMe=true)
495
+ B 开 → 背景已 inert(B 记 changedByMe=false,不碰它)
496
+ **A.close()** → A 解锁背景 ⇒ B 还开着,背景却能点了
497
+ ⇒ 改成引用计数:**只要还有弹层需要锁,背景就不许解锁**,
498
+ 与关闭顺序无关(见文件头 inertRefs 的注释)。
499
+ */
500
+ /* 🔴 背景隔离必须**两条路都走**:
501
+ 支持 inert 就用 inert;不支持(旧 WebView)就用 aria-hidden 兜底。
502
+ 以前只有 inert 分支,旧 WebView 上等于**什么都没做**,
503
+ 辅助技术的虚拟光标仍能穿透到背景内容。
504
+ (`useInert` / `stackEntry` / `uninstallIsolation` 在 appendChild 之后
505
+ 就已经算好了 —— 那份顺序说明见那里,别搬回来。) */
506
+ var nodes = Array.prototype.slice.call(
507
+ document.body.querySelectorAll('body > *')
508
+ );
509
+ nodes.forEach(function (n) {
510
+ /* 任何已有的 backdrop(外层)都跳过 —— 它们自己管自己的 */
511
+ if (n.classList.contains('dialog-backdrop')) return;
512
+ if (useInert) {
513
+ inerted.push(n);
514
+ inertAcquire(n);
515
+ } else {
516
+ ariaed.push(n);
517
+ ariaAcquire(n);
518
+ }
519
+ });
520
+
521
+ function close() {
522
+ if (closed) return;
523
+ closed = true;
524
+
525
+ /* 🔴 焦点归还前先看清**焦点现在在谁手里**(必须在移除 backdrop 之前读,
526
+ 移除之后 activeElement 会变成 body)。
527
+ ⚠️ 只有焦点还在这个弹层里(或已经掉到 body)时才归还 ——
528
+ 否则就是**从别人手里抢焦点**:
529
+ 先关外层时,内层正拿着焦点,外层不许把它抢走。 */
530
+ var ae = document.activeElement;
531
+ var focusIsMine = !ae || ae === document.body ||
532
+ box.contains(ae) || backdrop.contains(ae);
533
+
534
+ if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
535
+
536
+ /* 🔴 守卫必须在**焦点归还之前**拆掉。
537
+ 不拆的话 fr.restore() 把焦点送回触发器(在背景里)⇒
538
+ focusin 捕获到"焦点跑到背景"⇒ 守卫立刻又把它拉回弹层,
539
+ 用户看到的是"关闭了但焦点没回来"。 */
540
+ for (var si = openStack.length - 1; si >= 0; si--) {
541
+ if (openStack[si] === stackEntry) { openStack.splice(si, 1); break; }
542
+ }
543
+ if (uninstallIsolation) { uninstallIsolation(); uninstallIsolation = null; }
544
+
545
+ /* ⭐ 引用计数:还一个引用,降到 0 才真的解锁 ⇒ 与关闭顺序无关 */
546
+ inerted.forEach(inertRelease);
547
+ /* 旧 WebView 走 aria-hidden 的那条路,同样要按引用计数还 */
548
+ ariaed.forEach(ariaRelease);
549
+ unlockScroll();
550
+
551
+ /* 🔴 焦点必须回到**触发它的那个元素**。
552
+ 不还的话,键盘用户要重新 Tab 一遍才能回到原处。 */
553
+ fr.restore();
554
+ /* ⚠️ 不写 `document.body.focus()`:body 默认不可聚焦,
555
+ 那行从来没起过作用,只会让人以为"有兜底"。 */
556
+ }
557
+
558
+ return { el: box, close: close };
559
+ }
560
+
561
+ global.Overlay = { toast: toast, dialog: dialog };
562
+
563
+ })(window);
@@ -0,0 +1,114 @@
1
+ # pagination · 分页
2
+
3
+ 判据:**WCAG 2.5.5Target Size** + 键盘 `←` `→` `Home` `End` 可达。
4
+
5
+ 依据 5 个来源口径一致:W3C APG / MDN / USWDS / Component Gallery / Mironsoft。
6
+
7
+ ## 🔴 六条结构硬要求(不是"最佳实践")
8
+
9
+ ### 1. 必须包在 `<nav>` 里,且 `aria-label` **唯一**
10
+
11
+ 一个页面常常有多个 `<nav>` —— 主导航、面包屑、分页。
12
+ 没有 `aria-label`,读屏用户分不清哪个是哪个。
13
+
14
+ ⛔ **不要**把 `role="navigation"` 直接放在 `<ul>` 上 ——
15
+ 那会**覆盖 `<ul>` 的列表语义**(Component Gallery 明确点出这是常见错误)。
16
+
17
+ ### 2. 用 `<ul>` + `<li>`,不要用松散的 `<a>`
18
+
19
+ 列表能传达"共几项"和"当前在第几项"。
20
+
21
+ ### 3. 当前页用 `aria-current="page"`
22
+
23
+ ⚠️ **不要**用 `aria-selected` —— 那是给 tab / option / row 用的。
24
+
25
+ ### 4. 每个页码的 `aria-label` 要说人话
26
+
27
+ "第 4 页",不是 "4"。脱离上下文时,孤零零的数字没有意义。
28
+
29
+ ### 5. 上一页/下一页要有明确的 `aria-label`
30
+
31
+ "上一页",不是只有一个箭头图标。
32
+ 首/末页时用 **`disabled`** 标记 —— 不是"留在 DOM 里但不工作"。
33
+
34
+ ### 6. ⭐ 客户端翻页必须有 `aria-live="polite"`,且**元素常驻 DOM**
35
+
36
+ 只改 `textContent`。**插入新元素的话很多读屏器播报不出来** ——
37
+ 这是实测里最容易踩的一条。
38
+
39
+ ## 结构
40
+
41
+ ```html
42
+ <nav class="pagination" aria-label="分页导航">
43
+ <ul class="pagination__list" data-pg-list>
44
+ <li class="pagination__item">
45
+ <a class="pagination__link" href="?page=1"
46
+ aria-label="上一页" data-goto="prev">上一页</a>
47
+ </li>
48
+ <li class="pagination__item">
49
+ <a class="pagination__link" href="?page=4" aria-label="第 4 页"
50
+ aria-current="page" data-page="4">4</a>
51
+ </li>
52
+ <li class="pagination__ellipsis" aria-hidden="true">…</li>
53
+ …
54
+ </ul>
55
+ <p class="pagination__status pagination__sr" data-pg-live
56
+ aria-live="polite"></p>
57
+ </nav>
58
+ ```
59
+
60
+ | 部件 | 说明 |
61
+ |---|---|
62
+ | `.pagination` | 根(在 `<nav>` 里)|
63
+ | `.pagination__list` / `[data-pg-list]` | `<ul>` |
64
+ | `.pagination__item` / `.pagination__link` | `<li>` / `<a>` |
65
+ | `.pagination__ellipsis` | 省略号,`aria-hidden` |
66
+ | `.pagination__live` / `[data-pg-live]` | **常驻**的播报区,`aria-live="polite"` |
67
+ | `.pagination__sr` | 只给读屏的视觉隐藏文本 |
68
+ | `.pagination__status` | 当前状态文字 |
69
+
70
+ ## 启用:配置全部从 DOM 属性读
71
+
72
+ ```html
73
+ <nav class="pagination" aria-label="分页导航"
74
+ data-pagination data-total="302" data-page-size="20" data-page="1">
75
+ ```
76
+
77
+ ```js
78
+ const pg = Pagination.create(元素); // 单参数
79
+ ```
80
+
81
+ | 属性 | 含义 | 默认 |
82
+ |---|---|---|
83
+ | `data-total` | 总条数 | 1 |
84
+ | `data-page-size` | 每页条数 | 20 |
85
+ | `data-page` | 当前页 | 1 |
86
+ | `data-pg-param` | URL 里的页码参数名 | `page` |
87
+
88
+ 另外两个:
89
+
90
+ ```js
91
+ Pagination.update(); // 扫描 [data-pagination] 并逐个 create(可传 root 限定范围)
92
+ Pagination.pagesOf(total, pageSize); // ⭐ 纯函数算页数 —— 表格用它显示「共 N 页」
93
+ ```
94
+
95
+ `create()` 是**幂等**的:同一个元素第二次调用直接返回已有实例,不会重复绑。
96
+
97
+ ### ⭐ 从 URL 恢复页码
98
+
99
+ ```js
100
+ var u = node.getAttribute('data-pg-param') || 'page';
101
+ var q = parseInt(new URL(location.href).searchParams.get(u) || '0', 10);
102
+ if (q > 0) this.page = Math.min(q, this.pages);
103
+ ```
104
+
105
+ 刷新页面、别人分享链接,都能回到同一页 —— 而且会被**夹在合法页数范围内**。
106
+ 这是"翻页"从一次性动作变成可分享状态的关键。
107
+
108
+ ## 成熟度:stable(无行为债)
109
+
110
+ `ai/components.json` 里 `pagination` 的 `behaviorDebt` 为空。
111
+
112
+ ## 门禁
113
+
114
+ `05-audit/pagination-check.js`