@cssints/apg-specs 0.0.0-stage → 0.5.1

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 (260) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -2
  3. package/dist/apg/accordion.bridge.d.mts +35 -0
  4. package/dist/apg/accordion.bridge.mjs +326 -0
  5. package/dist/apg/accordion.model.json +8397 -0
  6. package/dist/apg/alert.bridge.d.mts +22 -0
  7. package/dist/apg/alert.bridge.mjs +86 -0
  8. package/dist/apg/alert.model.json +871 -0
  9. package/dist/apg/alertdialog.bridge.d.mts +21 -0
  10. package/dist/apg/alertdialog.bridge.mjs +221 -0
  11. package/dist/apg/alertdialog.model.json +5232 -0
  12. package/dist/apg/breadcrumb.bridge.d.mts +26 -0
  13. package/dist/apg/breadcrumb.bridge.mjs +120 -0
  14. package/dist/apg/breadcrumb.model.json +574 -0
  15. package/dist/apg/button.bridge.d.mts +35 -0
  16. package/dist/apg/button.bridge.mjs +208 -0
  17. package/dist/apg/button.model.json +5814 -0
  18. package/dist/apg/carousel.bridge.d.mts +44 -0
  19. package/dist/apg/carousel.bridge.mjs +262 -0
  20. package/dist/apg/carousel.model.json +8963 -0
  21. package/dist/apg/checkbox-group.bridge.d.mts +25 -0
  22. package/dist/apg/checkbox-group.bridge.mjs +276 -0
  23. package/dist/apg/checkbox-group.model.json +3863 -0
  24. package/dist/apg/checkbox.bridge.d.mts +34 -0
  25. package/dist/apg/checkbox.bridge.mjs +253 -0
  26. package/dist/apg/checkbox.model.json +3158 -0
  27. package/dist/apg/combobox-autocomplete.bridge.d.mts +14 -0
  28. package/dist/apg/combobox-autocomplete.bridge.mjs +326 -0
  29. package/dist/apg/combobox-autocomplete.model.json +11586 -0
  30. package/dist/apg/combobox-dialog.bridge.d.mts +17 -0
  31. package/dist/apg/combobox-dialog.bridge.mjs +250 -0
  32. package/dist/apg/combobox-dialog.model.json +8981 -0
  33. package/dist/apg/combobox-grid.bridge.d.mts +17 -0
  34. package/dist/apg/combobox-grid.bridge.mjs +227 -0
  35. package/dist/apg/combobox-grid.model.json +8554 -0
  36. package/dist/apg/combobox-select-only.bridge.d.mts +24 -0
  37. package/dist/apg/combobox-select-only.bridge.mjs +304 -0
  38. package/dist/apg/combobox-select-only.model.json +7846 -0
  39. package/dist/apg/combobox-tree.bridge.d.mts +17 -0
  40. package/dist/apg/combobox-tree.bridge.mjs +211 -0
  41. package/dist/apg/combobox-tree.model.json +9345 -0
  42. package/dist/apg/dialog-modal.bridge.d.mts +35 -0
  43. package/dist/apg/dialog-modal.bridge.mjs +186 -0
  44. package/dist/apg/dialog-modal.model.json +7580 -0
  45. package/dist/apg/disclosure.bridge.d.mts +25 -0
  46. package/dist/apg/disclosure.bridge.mjs +202 -0
  47. package/dist/apg/disclosure.model.json +4703 -0
  48. package/dist/apg/feed.bridge.d.mts +31 -0
  49. package/dist/apg/feed.bridge.mjs +246 -0
  50. package/dist/apg/feed.model.json +4523 -0
  51. package/dist/apg/grid-selection.bridge.d.mts +13 -0
  52. package/dist/apg/grid-selection.bridge.mjs +160 -0
  53. package/dist/apg/grid-selection.model.json +6974 -0
  54. package/dist/apg/grid-sort.bridge.d.mts +12 -0
  55. package/dist/apg/grid-sort.bridge.mjs +138 -0
  56. package/dist/apg/grid-sort.model.json +3799 -0
  57. package/dist/apg/grid.bridge.d.mts +54 -0
  58. package/dist/apg/grid.bridge.mjs +441 -0
  59. package/dist/apg/grid.model.json +3802 -0
  60. package/dist/apg/link.bridge.d.mts +24 -0
  61. package/dist/apg/link.bridge.mjs +126 -0
  62. package/dist/apg/link.model.json +694 -0
  63. package/dist/apg/listbox-multi.bridge.d.mts +10 -0
  64. package/dist/apg/listbox-multi.bridge.mjs +261 -0
  65. package/dist/apg/listbox-multi.model.json +10893 -0
  66. package/dist/apg/listbox-rearrangeable.bridge.d.mts +20 -0
  67. package/dist/apg/listbox-rearrangeable.bridge.mjs +237 -0
  68. package/dist/apg/listbox-rearrangeable.model.json +17592 -0
  69. package/dist/apg/listbox-shared.d.mts +58 -0
  70. package/dist/apg/listbox-shared.mjs +171 -0
  71. package/dist/apg/listbox-single.bridge.d.mts +10 -0
  72. package/dist/apg/listbox-single.bridge.mjs +196 -0
  73. package/dist/apg/listbox-single.model.json +8024 -0
  74. package/dist/apg/listbox-typeahead.bridge.d.mts +10 -0
  75. package/dist/apg/listbox-typeahead.bridge.mjs +153 -0
  76. package/dist/apg/listbox-typeahead.model.json +5615 -0
  77. package/dist/apg/menu-button.bridge.d.mts +29 -0
  78. package/dist/apg/menu-button.bridge.mjs +259 -0
  79. package/dist/apg/menu-button.model.json +5706 -0
  80. package/dist/apg/menu-checkable.bridge.d.mts +25 -0
  81. package/dist/apg/menu-checkable.bridge.mjs +226 -0
  82. package/dist/apg/menu-checkable.model.json +4277 -0
  83. package/dist/apg/menu-menubar.bridge.d.mts +27 -0
  84. package/dist/apg/menu-menubar.bridge.mjs +273 -0
  85. package/dist/apg/menu-menubar.model.json +9540 -0
  86. package/dist/apg/meter.bridge.d.mts +28 -0
  87. package/dist/apg/meter.bridge.mjs +159 -0
  88. package/dist/apg/meter.model.json +1185 -0
  89. package/dist/apg/player.bridge.d.mts +24 -0
  90. package/dist/apg/player.bridge.mjs +342 -0
  91. package/dist/apg/player.model.json +11013 -0
  92. package/dist/apg/radio.bridge.d.mts +35 -0
  93. package/dist/apg/radio.bridge.mjs +371 -0
  94. package/dist/apg/radio.model.json +5011 -0
  95. package/dist/apg/slider-multithumb.bridge.d.mts +34 -0
  96. package/dist/apg/slider-multithumb.bridge.mjs +365 -0
  97. package/dist/apg/slider-multithumb.model.json +5774 -0
  98. package/dist/apg/slider.bridge.d.mts +54 -0
  99. package/dist/apg/slider.bridge.mjs +359 -0
  100. package/dist/apg/slider.model.json +4881 -0
  101. package/dist/apg/spinbutton.bridge.d.mts +41 -0
  102. package/dist/apg/spinbutton.bridge.mjs +327 -0
  103. package/dist/apg/spinbutton.model.json +7571 -0
  104. package/dist/apg/switch.bridge.d.mts +30 -0
  105. package/dist/apg/switch.bridge.mjs +243 -0
  106. package/dist/apg/switch.model.json +3159 -0
  107. package/dist/apg/tabs.bridge.d.mts +41 -0
  108. package/dist/apg/tabs.bridge.mjs +294 -0
  109. package/dist/apg/tabs.model.json +7659 -0
  110. package/dist/apg/timeline.bridge.d.mts +36 -0
  111. package/dist/apg/timeline.bridge.mjs +265 -0
  112. package/dist/apg/timeline.model.json +6189 -0
  113. package/dist/apg/toolbar.bridge.d.mts +25 -0
  114. package/dist/apg/toolbar.bridge.mjs +307 -0
  115. package/dist/apg/toolbar.model.json +6616 -0
  116. package/dist/apg/tooltip.bridge.d.mts +36 -0
  117. package/dist/apg/tooltip.bridge.mjs +208 -0
  118. package/dist/apg/tooltip.model.json +8295 -0
  119. package/dist/apg/treegrid.bridge.d.mts +28 -0
  120. package/dist/apg/treegrid.bridge.mjs +311 -0
  121. package/dist/apg/treegrid.model.json +17358 -0
  122. package/dist/apg/treeview.bridge.d.mts +26 -0
  123. package/dist/apg/treeview.bridge.mjs +396 -0
  124. package/dist/apg/treeview.model.json +13745 -0
  125. package/dist/apg/windowsplitter.bridge.d.mts +36 -0
  126. package/dist/apg/windowsplitter.bridge.mjs +242 -0
  127. package/dist/apg/windowsplitter.model.json +6253 -0
  128. package/dist/canvas/camera.model.json +6568 -0
  129. package/dist/canvas/canvas.bridge.d.mts +190 -0
  130. package/dist/canvas/canvas.bridge.mjs +729 -0
  131. package/dist/canvas/pointer.model.json +17095 -0
  132. package/dist/scenario.d.mts +233 -0
  133. package/dist/scenario.mjs +313 -0
  134. package/package.json +57 -4
  135. package/scenarios/apg/README.md +221 -0
  136. package/scenarios/apg/accordion.base-ui.json +1005 -0
  137. package/scenarios/apg/accordion.react-aria.json +929 -0
  138. package/scenarios/apg/alert.base-ui.json +81 -0
  139. package/scenarios/apg/alert.react-aria.json +98 -0
  140. package/scenarios/apg/alertdialog.base-ui.json +331 -0
  141. package/scenarios/apg/alertdialog.react-aria.json +225 -0
  142. package/scenarios/apg/breadcrumb.react-aria.json +312 -0
  143. package/scenarios/apg/button.ariakit.json +136 -0
  144. package/scenarios/apg/button.base-ui.json +747 -0
  145. package/scenarios/apg/button.react-aria.json +443 -0
  146. package/scenarios/apg/carousel.apg.json +909 -0
  147. package/scenarios/apg/checkbox.ariakit.json +299 -0
  148. package/scenarios/apg/checkbox.base-ui.json +1126 -0
  149. package/scenarios/apg/checkbox.react-aria.json +518 -0
  150. package/scenarios/apg/combobox.ariakit.json +945 -0
  151. package/scenarios/apg/combobox.base-ui.json +2444 -0
  152. package/scenarios/apg/combobox.react-aria.json +845 -0
  153. package/scenarios/apg/dialog-modal.ariakit.json +1102 -0
  154. package/scenarios/apg/dialog-modal.base-ui.json +775 -0
  155. package/scenarios/apg/dialog-modal.react-aria.json +638 -0
  156. package/scenarios/apg/disclosure.apg.json +349 -0
  157. package/scenarios/apg/disclosure.ariakit.json +163 -0
  158. package/scenarios/apg/disclosure.base-ui.json +451 -0
  159. package/scenarios/apg/disclosure.react-aria.json +643 -0
  160. package/scenarios/apg/feed.apg.json +442 -0
  161. package/scenarios/apg/grid.ariakit.json +197 -0
  162. package/scenarios/apg/grid.base-ui.json +720 -0
  163. package/scenarios/apg/grid.react-aria.json +969 -0
  164. package/scenarios/apg/landmarks.ariakit.json +93 -0
  165. package/scenarios/apg/landmarks.react-aria.json +330 -0
  166. package/scenarios/apg/link.ariakit.json +83 -0
  167. package/scenarios/apg/link.base-ui.json +88 -0
  168. package/scenarios/apg/link.react-aria.json +186 -0
  169. package/scenarios/apg/listbox.ariakit.json +78 -0
  170. package/scenarios/apg/listbox.base-ui.json +163 -0
  171. package/scenarios/apg/listbox.react-aria.json +1118 -0
  172. package/scenarios/apg/menu-button.ariakit.json +924 -0
  173. package/scenarios/apg/menu-button.base-ui.json +1155 -0
  174. package/scenarios/apg/menu-button.react-aria.json +1080 -0
  175. package/scenarios/apg/menubar.ariakit.json +758 -0
  176. package/scenarios/apg/menubar.base-ui.json +673 -0
  177. package/scenarios/apg/meter.base-ui.json +243 -0
  178. package/scenarios/apg/meter.react-aria.json +201 -0
  179. package/scenarios/apg/radio.ariakit.json +459 -0
  180. package/scenarios/apg/radio.base-ui.json +581 -0
  181. package/scenarios/apg/radio.react-aria.json +576 -0
  182. package/scenarios/apg/slider-multithumb.base-ui.json +316 -0
  183. package/scenarios/apg/slider-multithumb.react-aria.json +363 -0
  184. package/scenarios/apg/slider.base-ui.json +544 -0
  185. package/scenarios/apg/slider.react-aria.json +456 -0
  186. package/scenarios/apg/spinbutton.base-ui.json +1688 -0
  187. package/scenarios/apg/spinbutton.react-aria.json +426 -0
  188. package/scenarios/apg/switch.base-ui.json +456 -0
  189. package/scenarios/apg/switch.react-aria.json +303 -0
  190. package/scenarios/apg/table.ariakit.json +65 -0
  191. package/scenarios/apg/table.react-aria.json +221 -0
  192. package/scenarios/apg/tabs.ariakit.json +329 -0
  193. package/scenarios/apg/tabs.base-ui.json +875 -0
  194. package/scenarios/apg/tabs.react-aria.json +644 -0
  195. package/scenarios/apg/toolbar.ariakit.json +595 -0
  196. package/scenarios/apg/toolbar.base-ui.json +1189 -0
  197. package/scenarios/apg/toolbar.react-aria.json +383 -0
  198. package/scenarios/apg/tooltip.ariakit.json +441 -0
  199. package/scenarios/apg/tooltip.base-ui.json +411 -0
  200. package/scenarios/apg/tooltip.react-aria.json +454 -0
  201. package/scenarios/apg/treegrid.apg.json +2558 -0
  202. package/scenarios/apg/treegrid.react-aria.json +5094 -0
  203. package/scenarios/apg/treeview.apg.json +928 -0
  204. package/scenarios/apg/windowsplitter.apg.json +557 -0
  205. package/scenarios/canvas/README.md +69 -0
  206. package/scenarios/canvas/camera.contract.json +237 -0
  207. package/scenarios/canvas/camera.excalidraw.json +57 -0
  208. package/scenarios/canvas/marquee.excalidraw.json +122 -0
  209. package/scenarios/canvas/pan.excalidraw.json +46 -0
  210. package/scenarios/canvas/pointer.contract.json +528 -0
  211. package/scenarios/parts.json +291 -0
  212. package/shared/aria.qnt +186 -0
  213. package/shared/details-group.qnt +50 -0
  214. package/shared/focusgroup.qnt +152 -0
  215. package/shared/invokers.qnt +81 -0
  216. package/shared/popover.qnt +71 -0
  217. package/specs/apg/STATIC.md +103 -0
  218. package/specs/apg/accordion.qnt +578 -0
  219. package/specs/apg/alert.qnt +131 -0
  220. package/specs/apg/alertdialog.qnt +455 -0
  221. package/specs/apg/breadcrumb.qnt +88 -0
  222. package/specs/apg/button.qnt +445 -0
  223. package/specs/apg/carousel.qnt +732 -0
  224. package/specs/apg/checkbox-group.qnt +388 -0
  225. package/specs/apg/checkbox.qnt +394 -0
  226. package/specs/apg/combobox-autocomplete.qnt +764 -0
  227. package/specs/apg/combobox-dialog.qnt +627 -0
  228. package/specs/apg/combobox-grid.qnt +632 -0
  229. package/specs/apg/combobox-select-only.qnt +626 -0
  230. package/specs/apg/combobox-tree.qnt +633 -0
  231. package/specs/apg/dialog-modal.qnt +499 -0
  232. package/specs/apg/disclosure.qnt +309 -0
  233. package/specs/apg/feed.qnt +485 -0
  234. package/specs/apg/grid-selection.qnt +512 -0
  235. package/specs/apg/grid-sort.qnt +330 -0
  236. package/specs/apg/grid.qnt +360 -0
  237. package/specs/apg/link.qnt +119 -0
  238. package/specs/apg/listbox-multi.qnt +799 -0
  239. package/specs/apg/listbox-rearrangeable.qnt +801 -0
  240. package/specs/apg/listbox-single.qnt +511 -0
  241. package/specs/apg/listbox-typeahead.qnt +396 -0
  242. package/specs/apg/menu-button.qnt +363 -0
  243. package/specs/apg/menu-checkable.qnt +304 -0
  244. package/specs/apg/menu-menubar.qnt +531 -0
  245. package/specs/apg/meter.qnt +129 -0
  246. package/specs/apg/player.qnt +1081 -0
  247. package/specs/apg/radio.qnt +394 -0
  248. package/specs/apg/slider-multithumb.qnt +441 -0
  249. package/specs/apg/slider.qnt +396 -0
  250. package/specs/apg/spinbutton.qnt +494 -0
  251. package/specs/apg/switch.qnt +389 -0
  252. package/specs/apg/tabs.qnt +412 -0
  253. package/specs/apg/timeline.qnt +612 -0
  254. package/specs/apg/toolbar.qnt +364 -0
  255. package/specs/apg/tooltip.qnt +553 -0
  256. package/specs/apg/treegrid.qnt +983 -0
  257. package/specs/apg/treeview.qnt +1004 -0
  258. package/specs/apg/windowsplitter.qnt +487 -0
  259. package/specs/canvas/camera.qnt +407 -0
  260. package/specs/canvas/pointer.qnt +841 -0
@@ -0,0 +1,485 @@
1
+ // WAI-ARIA APG — Feed pattern: the feed behaviour machine.
2
+ //
3
+ // Sources (https://www.w3.org/WAI/ARIA/apg/patterns/feed/):
4
+ // - "About This Pattern": the page loads/removes articles based on which
5
+ // article holds DOM focus; the reading cursor is the focused article.
6
+ // - "Keyboard Interaction": Page Down / Page Up move focus to the next /
7
+ // previous article; Control+End / Control+Home move focus to the first
8
+ // focusable element after / before the feed.
9
+ // - "WAI-ARIA Roles, States, and Properties": aria-posinset (position of the
10
+ // article, numbering from 1), aria-setsize (articles loaded, or the total;
11
+ // -1 when the total is undetermined), aria-busy=true on the feed while
12
+ // articles are being added/removed and false again when the operation is
13
+ // complete (also on a failed load).
14
+ // - Example https://www.w3.org/WAI/ARIA/apg/patterns/feed/examples/feed/:
15
+ // articles are tabindex=0 (focus can enter the feed on any loaded article
16
+ // with Tab), aria-setsize = articles currently contained by the feed,
17
+ // loading is triggered by focus reaching the last loaded article.
18
+ //
19
+ // Request vs commit (map ticket rt1p). The number of loaded articles is the
20
+ // value. A batch arriving (`load_finish`) is a *request* carrying the intent
21
+ // (`requested`); it fires onLoadedChange (`requests` counts the calls) when the
22
+ // intent differs from the current `loaded`, and then:
23
+ // uncontrolled the request is accepted: the count commits the intent
24
+ // controlled, holds the host ignores it ("rejected"): the count is untouched
25
+ // controlled, accepts the host answers with the same value (`accepting`): the
26
+ // count commits through the controlled value, not the event
27
+ // `valueProp` is the only thing that changes the count while `controlled`;
28
+ // `setValue` is the host writing it. `control` / `acceptRequests` are
29
+ // configuration events (init is literal-only). `load_start` / `load_fail` are
30
+ // adapter lifecycle events (busy), not value changes. Invariants for the model:
31
+ // ControlledFollowsProp, RejectedRequestUntouched, AcceptedRequestCommits.
32
+ //
33
+ // Machine: one feed with `loaded` articles (posinset 1..loaded) out of a fixed
34
+ // server-side `totalArticles`. Focus is in one of three zones: before the
35
+ // feed, on an article of the feed, or after the feed. Loading is a two-phase
36
+ // operation (load_start -> load_finish | load_fail) bracketed by aria-busy.
37
+ //
38
+ // Configuration variants:
39
+ // - totalArticles (constant def): size of the whole feed.
40
+ // - sizeMode ("loaded" | "total"): which value aria-setsize reports; a prop
41
+ // change at any time (set_size_mode).
42
+ // - totalKnown (bool): whether the page knows the total; with sizeMode
43
+ // "total" and an unknown total aria-setsize is -1 (set_total_known).
44
+ // - batch sizes 1..3 per load_finish (pool).
45
+ //
46
+ // Deliberate omissions:
47
+ // - Removing articles / prepending articles (renumbering of posinset); the
48
+ // feed only grows at its end.
49
+ // - Nested feeds and their navigation keys (Alt+Page Down, Ctrl+End to the
50
+ // next outer article); articles that capture the feed keys themselves.
51
+ // - Roles, labels and descriptions (role=feed/article, aria-labelledby,
52
+ // aria-describedby): static DOM contract with no behaviour to model.
53
+ // - Visual scrolling, scroll-position-triggered loading (load_start stands in
54
+ // for it), loading delay, and which focusable element exactly is "after"
55
+ // or "before" the feed (zones only).
56
+ // - Control+Home from outside the feed and Tab order inside articles.
57
+ module apg_feed {
58
+ var phase: str // "idle" | "loading"
59
+ var busy: bool // the feed's aria-busy attribute
60
+ var loaded: int // articles currently in the DOM (posinset 1..loaded)
61
+ var zone: str // focus zone: "before" | "feed" | "after"
62
+ var focus: int // posinset of the focused article (0 = focus outside the feed)
63
+ var sizeMode: str // "loaded" | "total": what aria-setsize reports
64
+ var totalKnown: bool // the page knows the total number of articles
65
+ var controlled: bool // the host owns the loaded count (`value` prop present)
66
+ var accepting: bool // controlled host that answers every request with its intent
67
+ var valueProp: int // the controlled value prop (meaningful while controlled)
68
+ var requested: int // intent of the last request
69
+ var requests: int // number of requests that fired onLoadedChange
70
+ var outcome: str // "none" | "accepted" | "rejected"
71
+ var base: int // count when the last request was received
72
+
73
+ // the number of articles the whole feed holds on the server
74
+ def totalArticles: int = 6
75
+
76
+ // --- derived ARIA contract ------------------------------------------------
77
+ // aria-posinset of the article at (1-based) DOM index i
78
+ def ariaPosinset(i: int): int = i
79
+ // aria-setsize of every article: loaded count, total, or -1 when undetermined
80
+ def ariaSetsize(mode: str, known: bool, count: int): int =
81
+ if (mode == "loaded") count else if (known) totalArticles else -1
82
+ // aria-busy on the feed element
83
+ def ariaBusy(p: str): bool = p == "loading"
84
+ // tabindex of an article (every article is in the Tab sequence)
85
+ def articleTabindex(i: int): int = 0
86
+
87
+ // --- guards ---------------------------------------------------------------
88
+ def hasMore(count: int): bool = count < totalArticles
89
+ def canStartLoad(p: str, count: int): bool = and(p == "idle", hasMore(count))
90
+ def inFeed(z: str): bool = z == "feed"
91
+ // Page Down: next article, stay on the last loaded one
92
+ def pageDownTarget(z: str, f: int, count: int): int =
93
+ if (and(inFeed(z), f < count)) f + 1 else f
94
+ // Page Up: previous article, stay on the first
95
+ def pageUpTarget(z: str, f: int): int =
96
+ if (and(inFeed(z), f > 1)) f - 1 else f
97
+ // focus on the last loaded article while more exist triggers a load
98
+ def focusTriggersLoad(z: str, f: int, p: str, count: int): bool =
99
+ and(and(inFeed(z), f == count), canStartLoad(p, count))
100
+ def addedArticles(count: int, n: int): int =
101
+ if (count + n <= totalArticles) n else totalArticles - count
102
+
103
+ // --- request / commit (map ticket rt1p) ----------------------------------
104
+ def moved(before: int, after: int): int = if (before == after) 0 else 1
105
+ // the count after a batch when a load is in flight (else unchanged)
106
+ def finishWant(loading: bool, count: int, n: int): int =
107
+ if (loading) count + addedArticles(count, n) else count
108
+ // controlled host that keeps the old value: the request is rejected
109
+ def holds: bool = and(controlled, not(accepting))
110
+ // controlled host that writes the intent back through the value prop
111
+ def follows: bool = and(controlled, accepting)
112
+ def verdict: str = if (holds) "rejected" else "accepted"
113
+ // the count after a request for `want`
114
+ def landed(h: bool, current: int, want: int): int = if (h) current else want
115
+ // the value prop after a request for `want`
116
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
117
+
118
+ action init = all {
119
+ phase' = "idle",
120
+ busy' = false,
121
+ loaded' = 1,
122
+ zone' = "before",
123
+ focus' = 0,
124
+ sizeMode' = "loaded",
125
+ totalKnown' = false,
126
+ controlled' = false,
127
+ accepting' = false,
128
+ valueProp' = 1,
129
+ requested' = 1,
130
+ requests' = 0,
131
+ outcome' = "none",
132
+ base' = 1
133
+ }
134
+
135
+ // Tab (or a click) puts focus on loaded article n; an unloaded n is a no-op
136
+ action focus_article = {
137
+ nondet n = Set(1, 2, 3, 4, 5, 6).oneOf()
138
+ all {
139
+ phase' = if (focusTriggersLoad(if (and(n >= 1, n <= loaded)) "feed" else zone, if (and(n >= 1, n <= loaded)) n else focus, phase, loaded)) "loading" else phase,
140
+ busy' = if (focusTriggersLoad(if (and(n >= 1, n <= loaded)) "feed" else zone, if (and(n >= 1, n <= loaded)) n else focus, phase, loaded)) true else busy,
141
+ loaded' = loaded,
142
+ zone' = if (and(n >= 1, n <= loaded)) "feed" else zone,
143
+ focus' = if (and(n >= 1, n <= loaded)) n else focus,
144
+ sizeMode' = sizeMode,
145
+ totalKnown' = totalKnown,
146
+ controlled' = controlled,
147
+ accepting' = accepting,
148
+ valueProp' = valueProp,
149
+ requested' = requested,
150
+ requests' = requests,
151
+ outcome' = outcome,
152
+ base' = base
153
+ }
154
+ }
155
+
156
+ // Page Down inside the feed
157
+ action page_down = all {
158
+ phase' = if (focusTriggersLoad(zone, pageDownTarget(zone, focus, loaded), phase, loaded)) "loading" else phase,
159
+ busy' = if (focusTriggersLoad(zone, pageDownTarget(zone, focus, loaded), phase, loaded)) true else busy,
160
+ loaded' = loaded,
161
+ zone' = zone,
162
+ focus' = pageDownTarget(zone, focus, loaded),
163
+ sizeMode' = sizeMode,
164
+ totalKnown' = totalKnown,
165
+ controlled' = controlled,
166
+ accepting' = accepting,
167
+ valueProp' = valueProp,
168
+ requested' = requested,
169
+ requests' = requests,
170
+ outcome' = outcome,
171
+ base' = base
172
+ }
173
+
174
+ // Page Up inside the feed
175
+ action page_up = all {
176
+ phase' = phase,
177
+ busy' = busy,
178
+ loaded' = loaded,
179
+ zone' = zone,
180
+ focus' = pageUpTarget(zone, focus),
181
+ sizeMode' = sizeMode,
182
+ totalKnown' = totalKnown,
183
+ controlled' = controlled,
184
+ accepting' = accepting,
185
+ valueProp' = valueProp,
186
+ requested' = requested,
187
+ requests' = requests,
188
+ outcome' = outcome,
189
+ base' = base
190
+ }
191
+
192
+ // Control+End: leave the feed forwards
193
+ action ctrl_end = all {
194
+ phase' = phase,
195
+ busy' = busy,
196
+ loaded' = loaded,
197
+ zone' = if (inFeed(zone)) "after" else zone,
198
+ focus' = if (inFeed(zone)) 0 else focus,
199
+ sizeMode' = sizeMode,
200
+ totalKnown' = totalKnown,
201
+ controlled' = controlled,
202
+ accepting' = accepting,
203
+ valueProp' = valueProp,
204
+ requested' = requested,
205
+ requests' = requests,
206
+ outcome' = outcome,
207
+ base' = base
208
+ }
209
+
210
+ // Control+Home: leave the feed backwards
211
+ action ctrl_home = all {
212
+ phase' = phase,
213
+ busy' = busy,
214
+ loaded' = loaded,
215
+ zone' = if (inFeed(zone)) "before" else zone,
216
+ focus' = if (inFeed(zone)) 0 else focus,
217
+ sizeMode' = sizeMode,
218
+ totalKnown' = totalKnown,
219
+ controlled' = controlled,
220
+ accepting' = accepting,
221
+ valueProp' = valueProp,
222
+ requested' = requested,
223
+ requests' = requests,
224
+ outcome' = outcome,
225
+ base' = base
226
+ }
227
+
228
+ // focus moves somewhere outside the feed (Tab out, click elsewhere)
229
+ action focus_outside = {
230
+ nondet side = Set("before", "after").oneOf()
231
+ all {
232
+ phase' = phase,
233
+ busy' = busy,
234
+ loaded' = loaded,
235
+ zone' = side,
236
+ focus' = 0,
237
+ sizeMode' = sizeMode,
238
+ totalKnown' = totalKnown,
239
+ controlled' = controlled,
240
+ accepting' = accepting,
241
+ valueProp' = valueProp,
242
+ requested' = requested,
243
+ requests' = requests,
244
+ outcome' = outcome,
245
+ base' = base
246
+ }
247
+ }
248
+
249
+ // scroll position asks for more articles: aria-busy goes true
250
+ action load_start = all {
251
+ phase' = if (canStartLoad(phase, loaded)) "loading" else phase,
252
+ busy' = if (canStartLoad(phase, loaded)) true else busy,
253
+ loaded' = loaded,
254
+ zone' = zone,
255
+ focus' = focus,
256
+ sizeMode' = sizeMode,
257
+ totalKnown' = totalKnown,
258
+ controlled' = controlled,
259
+ accepting' = accepting,
260
+ valueProp' = valueProp,
261
+ requested' = requested,
262
+ requests' = requests,
263
+ outcome' = outcome,
264
+ base' = base
265
+ }
266
+
267
+ // request: the batch arrives; articles appended (through the controlled
268
+ // value), aria-busy back to false
269
+ action load_finish = {
270
+ nondet n = Set(1, 2, 3).oneOf()
271
+ all {
272
+ phase' = if (phase == "loading") "idle" else phase,
273
+ busy' = if (phase == "loading") false else busy,
274
+ loaded' = if (phase == "loading") landed(holds, loaded, finishWant(true, loaded, n)) else loaded,
275
+ zone' = zone,
276
+ focus' = focus,
277
+ sizeMode' = sizeMode,
278
+ totalKnown' = totalKnown,
279
+ controlled' = controlled,
280
+ accepting' = accepting,
281
+ valueProp' = if (moved(loaded, finishWant(phase == "loading", loaded, n)) == 1) propAfter(follows, valueProp, finishWant(true, loaded, n)) else valueProp,
282
+ requested' = if (moved(loaded, finishWant(phase == "loading", loaded, n)) == 1) finishWant(true, loaded, n) else requested,
283
+ requests' = requests + moved(loaded, finishWant(phase == "loading", loaded, n)),
284
+ outcome' = if (moved(loaded, finishWant(phase == "loading", loaded, n)) == 1) verdict else outcome,
285
+ base' = if (moved(loaded, finishWant(phase == "loading", loaded, n)) == 1) loaded else base
286
+ }
287
+ }
288
+
289
+ // the load fails: nothing added, aria-busy must still be cleared
290
+ action load_fail = all {
291
+ phase' = if (phase == "loading") "idle" else phase,
292
+ busy' = if (phase == "loading") false else busy,
293
+ loaded' = loaded,
294
+ zone' = zone,
295
+ focus' = focus,
296
+ sizeMode' = sizeMode,
297
+ totalKnown' = totalKnown,
298
+ controlled' = controlled,
299
+ accepting' = accepting,
300
+ valueProp' = valueProp,
301
+ requested' = requested,
302
+ requests' = requests,
303
+ outcome' = outcome,
304
+ base' = base
305
+ }
306
+
307
+ // prop change: which value aria-setsize reports
308
+ action set_size_mode = {
309
+ nondet mode = Set("loaded", "total").oneOf()
310
+ all {
311
+ phase' = phase,
312
+ busy' = busy,
313
+ loaded' = loaded,
314
+ zone' = zone,
315
+ focus' = focus,
316
+ sizeMode' = mode,
317
+ totalKnown' = totalKnown,
318
+ controlled' = controlled,
319
+ accepting' = accepting,
320
+ valueProp' = valueProp,
321
+ requested' = requested,
322
+ requests' = requests,
323
+ outcome' = outcome,
324
+ base' = base
325
+ }
326
+ }
327
+
328
+ // prop change: the page learns (or loses) the total number of articles
329
+ action set_total_known = {
330
+ nondet known = Set(true, false).oneOf()
331
+ all {
332
+ phase' = phase,
333
+ busy' = busy,
334
+ loaded' = loaded,
335
+ zone' = zone,
336
+ focus' = focus,
337
+ sizeMode' = sizeMode,
338
+ totalKnown' = known,
339
+ controlled' = controlled,
340
+ accepting' = accepting,
341
+ valueProp' = valueProp,
342
+ requested' = requested,
343
+ requests' = requests,
344
+ outcome' = outcome,
345
+ base' = base
346
+ }
347
+ }
348
+
349
+ // commit: the host writes the controlled count (a no-op while uncontrolled).
350
+ // An in-flight load settles; focus clamps into the new range, and refocusing
351
+ // the clamped, now-last article asks for more (the load trigger again).
352
+ action setValue = {
353
+ nondet v = Set(1, 2, 3, 4, 5, 6).oneOf()
354
+ all {
355
+ phase' = if (controlled) (if (and(inFeed(zone), focus > v, hasMore(v))) "loading" else "idle") else phase,
356
+ busy' = if (controlled) (and(inFeed(zone), focus > v, hasMore(v))) else busy,
357
+ loaded' = if (controlled) v else loaded,
358
+ zone' = zone,
359
+ focus' = if (and(controlled, inFeed(zone), focus > v)) v else focus,
360
+ sizeMode' = sizeMode,
361
+ totalKnown' = totalKnown,
362
+ controlled' = controlled,
363
+ accepting' = accepting,
364
+ valueProp' = if (controlled) v else valueProp,
365
+ requested' = requested,
366
+ requests' = requests,
367
+ outcome' = if (controlled) "none" else outcome,
368
+ base' = base
369
+ }
370
+ }
371
+
372
+ // configuration: the host starts owning the count, adopting the current one
373
+ action control = all {
374
+ phase' = phase,
375
+ busy' = busy,
376
+ loaded' = loaded,
377
+ zone' = zone,
378
+ focus' = focus,
379
+ sizeMode' = sizeMode,
380
+ totalKnown' = totalKnown,
381
+ controlled' = true,
382
+ accepting' = accepting,
383
+ valueProp' = loaded,
384
+ requested' = requested,
385
+ requests' = requests,
386
+ outcome' = outcome,
387
+ base' = base
388
+ }
389
+
390
+ // configuration: the controlled host answers every request with its intent
391
+ action acceptRequests = all {
392
+ phase' = phase,
393
+ busy' = busy,
394
+ loaded' = loaded,
395
+ zone' = zone,
396
+ focus' = focus,
397
+ sizeMode' = sizeMode,
398
+ totalKnown' = totalKnown,
399
+ controlled' = controlled,
400
+ accepting' = true,
401
+ valueProp' = valueProp,
402
+ requested' = requested,
403
+ requests' = requests,
404
+ outcome' = outcome,
405
+ base' = base
406
+ }
407
+
408
+ action step = any {
409
+ init,
410
+ focus_article,
411
+ page_down,
412
+ page_up,
413
+ ctrl_end,
414
+ ctrl_home,
415
+ focus_outside,
416
+ load_start,
417
+ load_finish,
418
+ load_fail,
419
+ set_size_mode,
420
+ set_total_known,
421
+ setValue,
422
+ control,
423
+ acceptRequests
424
+ }
425
+
426
+ // aria-busy is true exactly while a load is in flight
427
+ val BusyIffLoading: bool = or(
428
+ and(busy, ariaBusy(phase)),
429
+ and(not(busy), not(ariaBusy(phase)))
430
+ )
431
+ val PhaseValid: bool = or(phase == "idle", phase == "loading")
432
+ // a load only runs while articles remain, and the feed never overshoots
433
+ val LoadingImpliesMore: bool = or(not(ariaBusy(phase)), hasMore(loaded))
434
+ val LoadedWithinTotal: bool = and(loaded >= 1, loaded <= totalArticles)
435
+ // focus zone and focused article agree; the focused article exists
436
+ val ZoneValid: bool = or(zone == "before", zone == "feed", zone == "after")
437
+ val FocusMatchesZone: bool = or(
438
+ and(inFeed(zone), focus >= 1),
439
+ and(not(inFeed(zone)), focus == 0)
440
+ )
441
+ val FocusWithinLoaded: bool = focus <= loaded
442
+ val SizeModeValid: bool = or(sizeMode == "loaded", sizeMode == "total")
443
+ // posinset numbers articles 1..loaded without gaps or repeats
444
+ val PosinsetsContiguous: bool = and(
445
+ ariaPosinset(1) == 1,
446
+ ariaPosinset(loaded) == loaded
447
+ )
448
+ // every posinset fits in aria-setsize (unless it is the unknown value -1)
449
+ val PosinsetWithinSetsize: bool = or(
450
+ ariaSetsize(sizeMode, totalKnown, loaded) == -1,
451
+ ariaPosinset(loaded) <= ariaSetsize(sizeMode, totalKnown, loaded)
452
+ )
453
+ val FocusedPosinsetWithinSetsize: bool = or(
454
+ not(inFeed(zone)),
455
+ or(
456
+ ariaSetsize(sizeMode, totalKnown, loaded) == -1,
457
+ ariaPosinset(focus) <= ariaSetsize(sizeMode, totalKnown, loaded)
458
+ )
459
+ )
460
+ // aria-setsize is -1 only when the total was asked for but is unknown
461
+ val SetsizeUnknownOnlyWhenTotalUnknown: bool = or(
462
+ and(
463
+ ariaSetsize(sizeMode, totalKnown, loaded) == -1,
464
+ and(sizeMode == "total", not(totalKnown))
465
+ ),
466
+ and(
467
+ not(ariaSetsize(sizeMode, totalKnown, loaded) == -1),
468
+ not(and(sizeMode == "total", not(totalKnown)))
469
+ )
470
+ )
471
+ val SetsizeValid: bool = or(
472
+ ariaSetsize(sizeMode, totalKnown, loaded) == -1,
473
+ ariaSetsize(sizeMode, totalKnown, loaded) >= 1
474
+ )
475
+ // every article is reachable with Tab
476
+ val ArticlesTabbable: bool = articleTabindex(loaded) == 0
477
+ // callbacks only ever count up from zero
478
+ val RequestsNonNegative: bool = requests >= 0
479
+ // controlled: only the value prop sets the count, so no event commits
480
+ val ControlledFollowsProp: bool = or(not(controlled), loaded == valueProp)
481
+ // a rejected request leaves the count as it found it
482
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), loaded == base)
483
+ // an accepted request committed exactly its intent
484
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), loaded == requested)
485
+ }