@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,394 @@
1
+ // WAI-ARIA APG — Radio Group pattern (radiogroup + 4 radio items).
2
+ //
3
+ // Modelled (https://www.w3.org/WAI/ARIA/apg/patterns/radio/):
4
+ // - "Keyboard Interaction / Radio Groups Not Contained in a Toolbar":
5
+ // Tab / Shift+Tab focus enters the group on the checked radio, or on the
6
+ // first radio when none is checked (entering never checks)
7
+ // Space checks the focused radio if not already checked
8
+ // Right / Down focus AND check the next radio, last wraps to first
9
+ // Left / Up focus AND check the previous radio, first wraps to last
10
+ // - "WAI-ARIA Roles, States, and Properties": role radiogroup / radio,
11
+ // aria-checked true|false on every radio.
12
+ // Examples (variants of the SAME machine, chosen once at init via `flavor`):
13
+ // - roving https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio/
14
+ // DOM focus sits on the radio; tabindex 0 on the tab stop radio,
15
+ // -1 on the others; the radiogroup has no tabindex.
16
+ // - activedescendant
17
+ // https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-activedescendant/
18
+ // DOM focus sits on the radiogroup (tabindex 0); radios carry no
19
+ // tabindex; aria-activedescendant on the group names the
20
+ // visually-focused radio while focus is inside the group.
21
+ // Disabled radios (task requirement, not in the APG examples): they are never
22
+ // focused, checked or the tab stop; arrows skip them (wrapping across them).
23
+ //
24
+ // Request vs commit (map ticket rt1p). The checked radio is the value. Every
25
+ // event a user sees as a change — `arrow_next`, `arrow_prev`, `space`, `click`
26
+ // — is a *request* carrying an intent (`requested`); it fires onValueChange
27
+ // (`requests` counts the calls) when the intent differs from the current
28
+ // `checked`, and then:
29
+ // uncontrolled the request is accepted: the check commits the intent
30
+ // controlled, holds the host ignores it ("rejected"): the check is untouched
31
+ // controlled, accepts the host answers with the same value (`accepting`): the
32
+ // check commits through the controlled value, not the event
33
+ // `valueProp` is the only thing that changes the check while `controlled`;
34
+ // `setValue` is the host writing it. `control` / `acceptRequests` are
35
+ // configuration events (init is literal-only). DOM focus still moves with the
36
+ // request, so `FocusFollowsChecked` is an uncontrolled guarantee. Invariants
37
+ // for the model: ControlledFollowsProp, RejectedRequestUntouched,
38
+ // AcceptedRequestCommits.
39
+ //
40
+ // Pools / constants: 4 radios (indexes 0..3); `mask` is a bitmask of disabled
41
+ // radios (bit i = radio i), drawn from a pool that always leaves one enabled;
42
+ // `pick` is the preselected radio (-1 = none). `init` is the literal default
43
+ // (roving, none disabled, none checked); set_flavor / set_disabled / preselect
44
+ // re-mount the widget with other props (focus leaves the group) and follow the
45
+ // controlled value.
46
+ //
47
+ // Sentinels: focus/checked -1 = none. domFocus: 0..3 = a radio, 4 = the
48
+ // radiogroup element, -1 = outside the group. tabindex: -2 = attribute absent.
49
+ //
50
+ // Deliberate omissions: the toolbar flavor, the radio-rating example, Enter,
51
+ // aria-labelledby / aria-describedby / aria-label wiring, mouse hover, disabling
52
+ // without a re-mount, Shift+Tab "last radio" native-browser difference,
53
+ // dynamic add/remove of radios. Click is modelled as focus+check of an enabled radio.
54
+ //
55
+ // ponytail: radio count is fixed at 4; the generator has no sets/lists, so the
56
+ // group is unrolled into index arithmetic. Widen the modulus and weight table
57
+ // to grow it.
58
+ module apg_radio {
59
+ var flavor: str // "roving" | "activedescendant"
60
+ var mask: int // disabled bitmask (bit i set = radio i disabled)
61
+ var checked: int // checked radio, -1 = none
62
+ var focus: int // radio holding (visual) focus, -1 = focus outside the group
63
+ var controlled: bool // the host owns the check (`value` prop present)
64
+ var accepting: bool // controlled host that answers every request with its intent
65
+ var valueProp: int // the controlled value prop (meaningful while controlled)
66
+ var requested: int // intent of the last request
67
+ var requests: int // number of requests that fired onValueChange
68
+ var outcome: str // "none" | "accepted" | "rejected"
69
+ var base: int // check when the last request was received
70
+
71
+ def weight(i: int): int =
72
+ if (i == 0) 1 else if (i == 1) 2 else if (i == 2) 4 else 8
73
+ def disabled(m: int, i: int): bool = imod(idiv(m, weight(i)), 2) == 1
74
+ def isEnabled(m: int, i: int): bool = not(disabled(m, i))
75
+ def b2i(b: bool): int = if (b) 1 else 0
76
+
77
+ // first enabled radio (the pool guarantees at least one)
78
+ def firstEnabled(m: int): int =
79
+ if (isEnabled(m, 0)) 0 else if (isEnabled(m, 1)) 1 else if (isEnabled(m, 2)) 2 else 3
80
+
81
+ // arrow targets: skip disabled radios, wrap around; stay put if alone
82
+ def nextEnabled(m: int, i: int): int =
83
+ if (isEnabled(m, imod(i + 1, 4))) imod(i + 1, 4)
84
+ else if (isEnabled(m, imod(i + 2, 4))) imod(i + 2, 4)
85
+ else if (isEnabled(m, imod(i + 3, 4))) imod(i + 3, 4)
86
+ else i
87
+ def prevEnabled(m: int, i: int): int =
88
+ if (isEnabled(m, imod(i + 3, 4))) imod(i + 3, 4)
89
+ else if (isEnabled(m, imod(i + 2, 4))) imod(i + 2, 4)
90
+ else if (isEnabled(m, imod(i + 1, 4))) imod(i + 1, 4)
91
+ else i
92
+
93
+ // ---- request / commit (map ticket rt1p) ----------------------------------
94
+ def moved(before: int, after: int): int = if (before == after) 0 else 1
95
+ // the intent of each request (an enabled target, or the current check)
96
+ def nextWant(m: int, c: int, f: int): int = if (f >= 0) nextEnabled(m, f) else c
97
+ def prevWant(m: int, c: int, f: int): int = if (f >= 0) prevEnabled(m, f) else c
98
+ def spaceWant(c: int, f: int): int = if (f >= 0) f else c
99
+ def clickWant(t: int, m: int, c: int): int = if (isEnabled(m, t)) t else c
100
+ // a value the host may write: -1 (none) or an enabled radio
101
+ def validPick(v: int, m: int): bool = or(v < 0, isEnabled(m, v))
102
+ // controlled host that keeps the old value: the request is rejected
103
+ def holds: bool = and(controlled, not(accepting))
104
+ // controlled host that writes the intent back through the value prop
105
+ def follows: bool = and(controlled, accepting)
106
+ def verdict: str = if (holds) "rejected" else "accepted"
107
+ // the check after a request for `want`
108
+ def landed(h: bool, current: int, want: int): int = if (h) current else want
109
+ // the value prop after a request for `want`
110
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
111
+
112
+ // ---- ARIA contract, derived from state ----
113
+
114
+ // the radio Tab lands on: the checked one, else the first enabled one
115
+ def tabStop(m: int, c: int): int = if (c >= 0) c else firstEnabled(m)
116
+
117
+ def ariaChecked(c: int, i: int): bool = c == i
118
+ def ariaDisabled(m: int, i: int): bool = disabled(m, i)
119
+
120
+ // roving: tabindex 0 on the tab stop, -1 elsewhere; activedescendant: absent
121
+ def radioTabindex(v: str, m: int, c: int, i: int): int =
122
+ if (v == "roving") (if (i == tabStop(m, c)) 0 else -1) else -2
123
+ // activedescendant: the radiogroup itself is the tab stop; roving: absent
124
+ def groupTabindex(v: str): int = if (v == "activedescendant") 0 else -2
125
+ // aria-activedescendant on the radiogroup: the focused radio, -1 = absent
126
+ def activeDescendant(v: str, f: int): int =
127
+ if (v == "activedescendant") f else -1
128
+ // which element owns DOM focus
129
+ def domFocus(v: str, f: int): int =
130
+ if (f < 0) -1 else if (v == "roving") f else 4
131
+
132
+ def checkedCount(c: int): int =
133
+ b2i(ariaChecked(c, 0)) + b2i(ariaChecked(c, 1)) + b2i(ariaChecked(c, 2)) + b2i(ariaChecked(c, 3))
134
+ // tab stops contributed by one radio / the group, summed over the widget
135
+ def tabStopCount(v: str, m: int, c: int): int =
136
+ b2i(radioTabindex(v, m, c, 0) == 0) + b2i(radioTabindex(v, m, c, 1) == 0)
137
+ + b2i(radioTabindex(v, m, c, 2) == 0) + b2i(radioTabindex(v, m, c, 3) == 0)
138
+ + b2i(groupTabindex(v) == 0)
139
+
140
+ action init = all {
141
+ flavor' = "roving",
142
+ mask' = 0,
143
+ checked' = -1,
144
+ focus' = -1,
145
+ controlled' = false,
146
+ accepting' = false,
147
+ valueProp' = -1,
148
+ requested' = -1,
149
+ requests' = 0,
150
+ outcome' = "none",
151
+ base' = -1
152
+ }
153
+
154
+ // The three configuration actions model (re)mounting the widget with other
155
+ // props: they drop focus out of the group, so every later state is reached
156
+ // through the keyboard/pointer actions below. While controlled they keep the
157
+ // value prop in step with the check.
158
+ action set_flavor = {
159
+ nondet v = Set("roving", "activedescendant").oneOf()
160
+ all {
161
+ flavor' = v,
162
+ mask' = mask,
163
+ checked' = checked,
164
+ focus' = -1,
165
+ controlled' = controlled,
166
+ accepting' = accepting,
167
+ valueProp' = valueProp,
168
+ requested' = requested,
169
+ requests' = requests,
170
+ outcome' = outcome,
171
+ base' = base
172
+ }
173
+ }
174
+
175
+ // disabled radios; the pool always leaves one enabled, and a checked radio
176
+ // that becomes disabled is unchecked
177
+ action set_disabled = {
178
+ nondet m = Set(0, 1, 2, 6, 9, 14).oneOf()
179
+ all {
180
+ flavor' = flavor,
181
+ mask' = m,
182
+ checked' = if (and(checked >= 0, isEnabled(m, checked))) checked else -1,
183
+ focus' = -1,
184
+ controlled' = controlled,
185
+ accepting' = accepting,
186
+ valueProp' = if (controlled) (if (and(checked >= 0, isEnabled(m, checked))) checked else -1) else valueProp,
187
+ requested' = requested,
188
+ requests' = requests,
189
+ outcome' = "none",
190
+ base' = base
191
+ }
192
+ }
193
+
194
+ // initially checked radio (-1 = none); a disabled pick leaves none checked
195
+ action preselect = {
196
+ nondet pick = Set(-1, 0, 1, 2, 3).oneOf()
197
+ all {
198
+ flavor' = flavor,
199
+ mask' = mask,
200
+ checked' = if (and(pick >= 0, isEnabled(mask, pick))) pick else -1,
201
+ focus' = -1,
202
+ controlled' = controlled,
203
+ accepting' = accepting,
204
+ valueProp' = if (controlled) (if (and(pick >= 0, isEnabled(mask, pick))) pick else -1) else valueProp,
205
+ requested' = requested,
206
+ requests' = requests,
207
+ outcome' = "none",
208
+ base' = base
209
+ }
210
+ }
211
+
212
+ // Tab / Shift+Tab into the group: focus the tab stop, never check it.
213
+ // Already inside: Tab leaves the group, which is `leave`.
214
+ action enter = all {
215
+ flavor' = flavor,
216
+ mask' = mask,
217
+ checked' = checked,
218
+ focus' = if (focus < 0) tabStop(mask, checked) else focus,
219
+ controlled' = controlled,
220
+ accepting' = accepting,
221
+ valueProp' = valueProp,
222
+ requested' = requested,
223
+ requests' = requests,
224
+ outcome' = outcome,
225
+ base' = base
226
+ }
227
+
228
+ action leave = all {
229
+ flavor' = flavor,
230
+ mask' = mask,
231
+ checked' = checked,
232
+ focus' = -1,
233
+ controlled' = controlled,
234
+ accepting' = accepting,
235
+ valueProp' = valueProp,
236
+ requested' = requested,
237
+ requests' = requests,
238
+ outcome' = outcome,
239
+ base' = base
240
+ }
241
+
242
+ // request: Right / Down arrow moves focus and checks, wrapping, skipping disabled
243
+ action arrow_next = all {
244
+ flavor' = flavor,
245
+ mask' = mask,
246
+ checked' = landed(holds, checked, nextWant(mask, checked, focus)),
247
+ focus' = if (focus >= 0) nextEnabled(mask, focus) else focus,
248
+ controlled' = controlled,
249
+ accepting' = accepting,
250
+ valueProp' = propAfter(follows, valueProp, nextWant(mask, checked, focus)),
251
+ requested' = if (moved(checked, nextWant(mask, checked, focus)) == 1) nextWant(mask, checked, focus) else requested,
252
+ requests' = requests + moved(checked, nextWant(mask, checked, focus)),
253
+ outcome' = if (moved(checked, nextWant(mask, checked, focus)) == 1) verdict else outcome,
254
+ base' = if (moved(checked, nextWant(mask, checked, focus)) == 1) checked else base
255
+ }
256
+
257
+ // request: Left / Up arrow
258
+ action arrow_prev = all {
259
+ flavor' = flavor,
260
+ mask' = mask,
261
+ checked' = landed(holds, checked, prevWant(mask, checked, focus)),
262
+ focus' = if (focus >= 0) prevEnabled(mask, focus) else focus,
263
+ controlled' = controlled,
264
+ accepting' = accepting,
265
+ valueProp' = propAfter(follows, valueProp, prevWant(mask, checked, focus)),
266
+ requested' = if (moved(checked, prevWant(mask, checked, focus)) == 1) prevWant(mask, checked, focus) else requested,
267
+ requests' = requests + moved(checked, prevWant(mask, checked, focus)),
268
+ outcome' = if (moved(checked, prevWant(mask, checked, focus)) == 1) verdict else outcome,
269
+ base' = if (moved(checked, prevWant(mask, checked, focus)) == 1) checked else base
270
+ }
271
+
272
+ // request: Space checks the focused radio (no-op when already checked)
273
+ action space = all {
274
+ flavor' = flavor,
275
+ mask' = mask,
276
+ checked' = landed(holds, checked, spaceWant(checked, focus)),
277
+ focus' = focus,
278
+ controlled' = controlled,
279
+ accepting' = accepting,
280
+ valueProp' = propAfter(follows, valueProp, spaceWant(checked, focus)),
281
+ requested' = if (moved(checked, spaceWant(checked, focus)) == 1) spaceWant(checked, focus) else requested,
282
+ requests' = requests + moved(checked, spaceWant(checked, focus)),
283
+ outcome' = if (moved(checked, spaceWant(checked, focus)) == 1) verdict else outcome,
284
+ base' = if (moved(checked, spaceWant(checked, focus)) == 1) checked else base
285
+ }
286
+
287
+ // request: pointer on an enabled radio takes focus and becomes checked
288
+ action click = {
289
+ nondet target = Set(0, 1, 2, 3).oneOf()
290
+ all {
291
+ flavor' = flavor,
292
+ mask' = mask,
293
+ checked' = landed(holds, checked, clickWant(target, mask, checked)),
294
+ focus' = if (isEnabled(mask, target)) target else focus,
295
+ controlled' = controlled,
296
+ accepting' = accepting,
297
+ valueProp' = propAfter(follows, valueProp, clickWant(target, mask, checked)),
298
+ requested' = if (moved(checked, clickWant(target, mask, checked)) == 1) clickWant(target, mask, checked) else requested,
299
+ requests' = requests + moved(checked, clickWant(target, mask, checked)),
300
+ outcome' = if (moved(checked, clickWant(target, mask, checked)) == 1) verdict else outcome,
301
+ base' = if (moved(checked, clickWant(target, mask, checked)) == 1) checked else base
302
+ }
303
+ }
304
+
305
+ // commit: the host writes the controlled check (a no-op while uncontrolled)
306
+ action setValue = {
307
+ nondet v = Set(-1, 0, 1, 2, 3).oneOf()
308
+ all {
309
+ flavor' = flavor,
310
+ mask' = mask,
311
+ checked' = if (and(controlled, validPick(v, mask))) v else checked,
312
+ focus' = focus,
313
+ controlled' = controlled,
314
+ accepting' = accepting,
315
+ valueProp' = if (and(controlled, validPick(v, mask))) v else valueProp,
316
+ requested' = requested,
317
+ requests' = requests,
318
+ outcome' = if (controlled) "none" else outcome,
319
+ base' = base
320
+ }
321
+ }
322
+
323
+ // configuration: the host starts owning the check, adopting the current one
324
+ action control = all {
325
+ flavor' = flavor,
326
+ mask' = mask,
327
+ checked' = checked,
328
+ focus' = focus,
329
+ controlled' = true,
330
+ accepting' = accepting,
331
+ valueProp' = checked,
332
+ requested' = requested,
333
+ requests' = requests,
334
+ outcome' = outcome,
335
+ base' = base
336
+ }
337
+
338
+ // configuration: the controlled host answers every request with its intent
339
+ action acceptRequests = all {
340
+ flavor' = flavor,
341
+ mask' = mask,
342
+ checked' = checked,
343
+ focus' = focus,
344
+ controlled' = controlled,
345
+ accepting' = true,
346
+ valueProp' = valueProp,
347
+ requested' = requested,
348
+ requests' = requests,
349
+ outcome' = outcome,
350
+ base' = base
351
+ }
352
+
353
+ action step = any { init, set_flavor, set_disabled, preselect, enter, leave, arrow_next, arrow_prev, space, click, setValue, control, acceptRequests }
354
+
355
+ // never more than one radio is aria-checked="true"
356
+ val AtMostOneChecked: bool = checkedCount(checked) <= 1
357
+ // exactly one tab stop for the whole group, in both variants
358
+ val ExactlyOneTabStop: bool = tabStopCount(flavor, mask, checked) == 1
359
+ // the tab stop is the checked radio, else the first (enabled) one
360
+ val TabStopIsCheckedOrFirst: bool =
361
+ if (checked >= 0) tabStop(mask, checked) == checked else tabStop(mask, checked) == firstEnabled(mask)
362
+ // a disabled radio is never checked, focused or the tab stop
363
+ val DisabledNeverActive: bool = and(
364
+ or(checked < 0, isEnabled(mask, checked)),
365
+ or(focus < 0, isEnabled(mask, focus)),
366
+ isEnabled(mask, tabStop(mask, checked))
367
+ )
368
+ // focus and checked move together: once a radio is checked, focus inside
369
+ // the group is always on it; before that it can only be on the first radio.
370
+ // A controlled host that rejects a request leaves focus on the requested
371
+ // radio, so this is an uncontrolled guarantee.
372
+ val FocusFollowsChecked: bool = or(
373
+ controlled,
374
+ focus < 0,
375
+ if (checked >= 0) focus == checked else focus == firstEnabled(mask)
376
+ )
377
+ // at least one radio can always be reached
378
+ val SomeRadioEnabled: bool = or(isEnabled(mask, 0), isEnabled(mask, 1), isEnabled(mask, 2), isEnabled(mask, 3))
379
+ // aria-activedescendant exists only while focus is inside an activedescendant group
380
+ val ActiveDescendantContract: bool =
381
+ if (flavor == "activedescendant")
382
+ and(activeDescendant(flavor, focus) == focus, or(focus < 0, domFocus(flavor, focus) == 4))
383
+ else
384
+ and(activeDescendant(flavor, focus) == -1, domFocus(flavor, focus) == focus)
385
+ val VariantValid: bool = or(flavor == "roving", flavor == "activedescendant")
386
+ // callbacks only ever count up from zero
387
+ val RequestsNonNegative: bool = requests >= 0
388
+ // controlled: only the value prop sets the check, so no event commits
389
+ val ControlledFollowsProp: bool = or(not(controlled), checked == valueProp)
390
+ // a rejected request leaves the check as it found it
391
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), checked == base)
392
+ // an accepted request committed exactly its intent
393
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), checked == requested)
394
+ }