@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,578 @@
1
+ // WAI-ARIA APG — Accordion pattern (sections with show/hide functionality).
2
+ //
3
+ // Modelled from https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
4
+ // - "About This Pattern" headers are controls that reveal/hide panels
5
+ // - "Keyboard Interaction" Enter/Space toggle, Tab/Shift+Tab walk every
6
+ // focusable element (no roving tabindex)
7
+ // - "WAI-ARIA Roles, States, and Properties"
8
+ // aria-expanded mirrors panel visibility,
9
+ // aria-controls / region + aria-labelledby,
10
+ // aria-disabled on the sole expanded header
11
+ // when collapsing is not permitted
12
+ // and the worked example
13
+ // https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/
14
+ //
15
+ // State: three panels (expanded0..2), the focused element, and the
16
+ // configuration variants as props that can change at any time:
17
+ // multiple false = at most one panel expanded ("only one panel" impl.)
18
+ // collapsible false = one panel must stay expanded (aria-disabled on it)
19
+ // headingLevel the aria-level of the heading wrapping each header button
20
+ //
21
+ // Focus is a "slot" in DOM order (-1 = outside the accordion):
22
+ // 0 header0, 1 content0, 2 header1, 3 content1, 4 header2, 5 content2
23
+ // A content slot stands for the panel's focusable element (e.g. a form field)
24
+ // and is in the Tab sequence only while its panel is expanded.
25
+ //
26
+ // Request vs commit (map ticket rt1p). Everything a user sees as a change —
27
+ // click_header, key_enter, key_space — is a *request* carrying an intent (the
28
+ // toggled vector, packed into `requested`). It fires onExpandedChange
29
+ // (`requests` counts the calls) when the intent differs from the current value,
30
+ // and then:
31
+ // uncontrolled the request is accepted: the panels commit the intent
32
+ // controlled, holds the host ignores it ("rejected"): the panels are untouched
33
+ // controlled, accepts the host answers with the same value (`accepting`): the
34
+ // panels commit through the controlled value, not the event
35
+ // The controlled value (`valueProp`, the three expanded bits packed with
36
+ // `packed`) is the only thing that changes the panels while `controlled`;
37
+ // `setValue` / `hostToggle` are the host writing it. `control` and
38
+ // `acceptRequests` are configuration events (init is literal-only), like
39
+ // `disable` would be.
40
+ //
41
+ // The generator has one nondet pool per action and no collections, so the
42
+ // controlled value is a packed int and every action assigns every var; the
43
+ // per-panel request path is spelled out per member. The mode invariants
44
+ // (`SingleModeAtMostOneExpanded`, `NonCollapsibleKeepsOneExpanded`) are scoped
45
+ // to the uncontrolled path: a controlled host owns the value, so the widget
46
+ // renders whatever set it is given.
47
+ //
48
+ // Deliberate omissions:
49
+ // - the optional Down/Up/Home/End header navigation (not on the current page)
50
+ // - the optional region landmark and its ">6 panels" caution
51
+ // - static structure (role=button inside role=heading, the heading holding
52
+ // only the button, id wiring) beyond the id round-trip defs below
53
+ // - persistent adjacent controls (menubutton) and per-panel disabled headers
54
+ // - Enter vs Space keydown/keyup timing: both toggle identically here
55
+ // - Tab from outside enters at the first slot, Shift+Tab at the last: the
56
+ // page around the accordion is a single "outside" state
57
+ // - panel count is fixed at 3 (the generator has no collection state)
58
+ //
59
+ // Generator notes: constants/records are unsupported, so config is state, and
60
+ // every action assigns every var (guards live in the RHS).
61
+ module apg_accordion {
62
+ import aria.* from "../../shared/aria"
63
+
64
+ var expanded0: bool
65
+ var expanded1: bool
66
+ var expanded2: bool
67
+ var focus: int
68
+ var multiple: bool
69
+ var collapsible: bool
70
+ var headingLevel: int
71
+ var controlled: bool // the host owns the value (`value` prop present)
72
+ var accepting: bool // controlled host that answers every request with its intent
73
+ var valueProp: int // the controlled value prop, packed with `packed`
74
+ var requested: int // intent of the last request, packed
75
+ var outcome: str // "none" | "accepted" | "rejected"
76
+ var base: int // value when the last request was received, packed
77
+ var requests: int // number of requests that fired onExpandedChange
78
+
79
+ // ---- pure helpers ----------------------------------------------------
80
+
81
+ def isExpanded(i: int, a: bool, b: bool, c: bool): bool =
82
+ if (i == 0) a else if (i == 1) b else c
83
+
84
+ def expandedCount(a: bool, b: bool, c: bool): int =
85
+ (if (a) 1 else 0) + (if (b) 1 else 0) + (if (c) 1 else 0)
86
+
87
+ // a collapse is honoured when collapsing is allowed, or when another panel
88
+ // stays expanded
89
+ def canCollapse(a: bool, b: bool, c: bool, coll: bool): bool =
90
+ or(coll, expandedCount(a, b, c) >= 2)
91
+
92
+ // Enter/Space/click on header `i` (-1 = nothing activated): the new
93
+ // expanded flag of panel `j`
94
+ def toggledExpanded(j: int, i: int, a: bool, b: bool, c: bool, multi: bool, coll: bool): bool =
95
+ if (i < 0) isExpanded(j, a, b, c)
96
+ else if (isExpanded(i, a, b, c))
97
+ (if (j == i) not(canCollapse(a, b, c, coll)) else isExpanded(j, a, b, c))
98
+ else
99
+ (if (j == i) true else (if (multi) isExpanded(j, a, b, c) else false))
100
+
101
+ def lowerExpanded(j: int, a: bool, b: bool, c: bool): bool =
102
+ or(and(j > 0, a), and(j > 1, b))
103
+
104
+ // a prop change re-establishes the mode's constraints: single keeps the
105
+ // first expanded panel; non-collapsible expands panel 0 when none is open
106
+ def normExpanded(j: int, a: bool, b: bool, c: bool, multi: bool, coll: bool): bool =
107
+ if (and(not(coll), expandedCount(a, b, c) == 0)) (j == 0)
108
+ else if (multi) isExpanded(j, a, b, c)
109
+ else and(isExpanded(j, a, b, c), not(lowerExpanded(j, a, b, c)))
110
+
111
+ def onHeader(f: int): bool = and(f >= 0, f % 2 == 0)
112
+ def onContent(f: int): bool = and(f >= 0, f % 2 == 1)
113
+ def headerTarget(f: int): int = if (onHeader(f)) f / 2 else -1
114
+
115
+ // headers are always focusable; content only while its panel is expanded
116
+ def slotShown(s: int, a: bool, b: bool, c: bool): bool =
117
+ or(s % 2 == 0, isExpanded(s / 2, a, b, c))
118
+
119
+ // Tab: next focusable slot, or out of the accordion after the last
120
+ def nextSlot(f: int, a: bool, b: bool, c: bool): int =
121
+ if (f >= 5) -1
122
+ else if (slotShown(f + 1, a, b, c)) f + 1
123
+ else if (f + 2 > 5) -1
124
+ else f + 2
125
+
126
+ def prevFrom(s: int, a: bool, b: bool, c: bool): int =
127
+ if (slotShown(s, a, b, c)) s else s - 1
128
+
129
+ // Shift+Tab: previous focusable slot, or out before the first
130
+ def prevSlot(f: int, a: bool, b: bool, c: bool): int =
131
+ if (f == 0) -1
132
+ else prevFrom(if (f < 0) 5 else f - 1, a, b, c)
133
+
134
+ // focus inside a panel that just got hidden is lost
135
+ def focusAfter(f: int, a: bool, b: bool, c: bool): int =
136
+ if (onContent(f)) (if (isExpanded(f / 2, a, b, c)) f else -1) else f
137
+
138
+ // ---- request / commit (map ticket rt1p) -------------------------------
139
+
140
+ def b2i(b: bool): int = if (b) 1 else 0
141
+ // injective for three bools: bit 0 = panel 0, bit 1 = panel 1, bit 2 = panel 2
142
+ def packed(a: bool, b: bool, c: bool): int =
143
+ b2i(a) + 2 * b2i(b) + 4 * b2i(c)
144
+ def bitAt(i: int, n: int): bool =
145
+ if (i == 0) n % 2 == 1 else if (i == 1) (n / 2) % 2 == 1 else (n / 4) % 2 == 1
146
+ // controlled host that keeps the old value: the request is rejected
147
+ def holds: bool = and(controlled, not(accepting))
148
+ // controlled host that writes the intent back through the value prop
149
+ def follows: bool = and(controlled, accepting)
150
+ def verdict: str = if (holds) "rejected" else "accepted"
151
+ // the value after a request for `want`
152
+ def landed(h: bool, current: bool, want: bool): bool = if (h) current else want
153
+ // the value prop after a request for `want`
154
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
155
+ // the intent of a request on header `i`, per member and packed
156
+ def intent(j: int, i: int): bool =
157
+ toggledExpanded(j, i, expanded0, expanded1, expanded2, multiple, collapsible)
158
+ def wantPacked(i: int): int = packed(intent(0, i), intent(1, i), intent(2, i))
159
+ def nowPacked: int = packed(expanded0, expanded1, expanded2)
160
+ // the value a mode prop change re-establishes
161
+ def normAt(j: int, multi: bool, coll: bool): bool =
162
+ normExpanded(j, expanded0, expanded1, expanded2, multi, coll)
163
+ def normPacked(multi: bool, coll: bool): int =
164
+ packed(normAt(0, multi, coll), normAt(1, multi, coll), normAt(2, multi, coll))
165
+ // a request is real only when its intent differs from the current value
166
+ def moves(i: int): bool = wantPacked(i) != nowPacked
167
+ // the host flips one bit of its own copy of the value
168
+ def flipAt(n: int, i: int): int =
169
+ if (i == 0) (if (bitAt(0, n)) n - 1 else n + 1)
170
+ else if (i == 1) (if (bitAt(1, n)) n - 2 else n + 2)
171
+ else (if (bitAt(2, n)) n - 4 else n + 4)
172
+
173
+ // ---- the ARIA contract, derived from state ----------------------------
174
+
175
+ def expandedAt(i: int): bool = isExpanded(i, expanded0, expanded1, expanded2)
176
+ def ariaExpanded(i: int): str = ariaBoolToken(expandedAt(i))
177
+ def ariaDisabled(i: int): bool =
178
+ and(expandedAt(i), not(canCollapse(expanded0, expanded1, expanded2, collapsible)))
179
+ def ariaControls(i: int): int = i
180
+ def regionLabelledBy(i: int): int = i
181
+ def tabbable(s: int): bool = slotShown(s, expanded0, expanded1, expanded2)
182
+ def toggledAt(j: int, i: int): bool =
183
+ toggledExpanded(j, i, expanded0, expanded1, expanded2, multiple, collapsible)
184
+
185
+ // aria-disabled marks exactly the header whose activation is refused;
186
+ // an allow expanded header collapses; a collapsed header expands
187
+ def toggleContractHolds(i: int): bool = and(
188
+ or(not(ariaDisabled(i)), toggledAt(i, i)),
189
+ or(not(and(expandedAt(i), not(ariaDisabled(i)))), not(toggledAt(i, i))),
190
+ or(expandedAt(i), toggledAt(i, i))
191
+ )
192
+
193
+ // expanding a header collapses every other panel unless multiple
194
+ def expandRespectsMode(i: int): bool =
195
+ or(
196
+ expandedAt(i),
197
+ and(
198
+ toggledAt(i, i),
199
+ or(multiple, and(not(toggledAt((i + 1) % 3, i)), not(toggledAt((i + 2) % 3, i))))
200
+ )
201
+ )
202
+
203
+ // aria-expanded mirrors visibility: the header is always in the Tab
204
+ // sequence, the panel's content is in it iff aria-expanded is "true"
205
+ def expandedMirrorsVisibility(i: int): bool = and(
206
+ tabbable(2 * i),
207
+ (ariaExpanded(i) == "true") == tabbable(2 * i + 1)
208
+ )
209
+
210
+ // ---- actions -----------------------------------------------------------
211
+
212
+ action init = all {
213
+ expanded0' = false,
214
+ expanded1' = false,
215
+ expanded2' = false,
216
+ focus' = -1,
217
+ multiple' = false,
218
+ collapsible' = true,
219
+ headingLevel' = 3,
220
+ controlled' = false,
221
+ accepting' = false,
222
+ valueProp' = 0,
223
+ requested' = 0,
224
+ outcome' = "none",
225
+ base' = 0,
226
+ requests' = 0
227
+ }
228
+
229
+ // pointer: click moves focus to the header and requests a toggle
230
+ action click_header = {
231
+ nondet panel = Set(0, 1, 2).oneOf()
232
+ all {
233
+ expanded0' = landed(holds, expanded0, intent(0, panel)),
234
+ expanded1' = landed(holds, expanded1, intent(1, panel)),
235
+ expanded2' = landed(holds, expanded2, intent(2, panel)),
236
+ focus' = 2 * panel,
237
+ multiple' = multiple,
238
+ collapsible' = collapsible,
239
+ headingLevel' = headingLevel,
240
+ controlled' = controlled,
241
+ accepting' = accepting,
242
+ valueProp' = propAfter(follows, valueProp, wantPacked(panel)),
243
+ requested' = if (moves(panel)) wantPacked(panel) else requested,
244
+ outcome' = if (moves(panel)) verdict else outcome,
245
+ base' = if (moves(panel)) nowPacked else base,
246
+ requests' = if (moves(panel)) requests + 1 else requests
247
+ }
248
+ }
249
+
250
+ // pointer: click on a panel's focusable content (only reachable if shown)
251
+ action click_content = {
252
+ nondet panel = Set(0, 1, 2).oneOf()
253
+ all {
254
+ expanded0' = expanded0,
255
+ expanded1' = expanded1,
256
+ expanded2' = expanded2,
257
+ focus' = if (expandedAt(panel)) 2 * panel + 1 else focus,
258
+ multiple' = multiple,
259
+ collapsible' = collapsible,
260
+ headingLevel' = headingLevel,
261
+ controlled' = controlled,
262
+ accepting' = accepting,
263
+ valueProp' = valueProp,
264
+ requested' = requested,
265
+ outcome' = outcome,
266
+ base' = base,
267
+ requests' = requests
268
+ }
269
+ }
270
+
271
+ // request: Enter on the focused header
272
+ action key_enter = all {
273
+ expanded0' = landed(holds, expanded0, intent(0, headerTarget(focus))),
274
+ expanded1' = landed(holds, expanded1, intent(1, headerTarget(focus))),
275
+ expanded2' = landed(holds, expanded2, intent(2, headerTarget(focus))),
276
+ focus' = focus,
277
+ multiple' = multiple,
278
+ collapsible' = collapsible,
279
+ headingLevel' = headingLevel,
280
+ controlled' = controlled,
281
+ accepting' = accepting,
282
+ valueProp' = propAfter(follows, valueProp, wantPacked(headerTarget(focus))),
283
+ requested' = if (moves(headerTarget(focus))) wantPacked(headerTarget(focus)) else requested,
284
+ outcome' = if (moves(headerTarget(focus))) verdict else outcome,
285
+ base' = if (moves(headerTarget(focus))) nowPacked else base,
286
+ requests' = if (moves(headerTarget(focus))) requests + 1 else requests
287
+ }
288
+
289
+ // request: Space on the focused header (same effect as Enter)
290
+ action key_space = all {
291
+ expanded0' = landed(holds, expanded0, intent(0, headerTarget(focus))),
292
+ expanded1' = landed(holds, expanded1, intent(1, headerTarget(focus))),
293
+ expanded2' = landed(holds, expanded2, intent(2, headerTarget(focus))),
294
+ focus' = focus,
295
+ multiple' = multiple,
296
+ collapsible' = collapsible,
297
+ headingLevel' = headingLevel,
298
+ controlled' = controlled,
299
+ accepting' = accepting,
300
+ valueProp' = propAfter(follows, valueProp, wantPacked(headerTarget(focus))),
301
+ requested' = if (moves(headerTarget(focus))) wantPacked(headerTarget(focus)) else requested,
302
+ outcome' = if (moves(headerTarget(focus))) verdict else outcome,
303
+ base' = if (moves(headerTarget(focus))) nowPacked else base,
304
+ requests' = if (moves(headerTarget(focus))) requests + 1 else requests
305
+ }
306
+
307
+ action key_tab = all {
308
+ expanded0' = expanded0,
309
+ expanded1' = expanded1,
310
+ expanded2' = expanded2,
311
+ focus' = nextSlot(focus, expanded0, expanded1, expanded2),
312
+ multiple' = multiple,
313
+ collapsible' = collapsible,
314
+ headingLevel' = headingLevel,
315
+ controlled' = controlled,
316
+ accepting' = accepting,
317
+ valueProp' = valueProp,
318
+ requested' = requested,
319
+ outcome' = outcome,
320
+ base' = base,
321
+ requests' = requests
322
+ }
323
+
324
+ action key_shift_tab = all {
325
+ expanded0' = expanded0,
326
+ expanded1' = expanded1,
327
+ expanded2' = expanded2,
328
+ focus' = prevSlot(focus, expanded0, expanded1, expanded2),
329
+ multiple' = multiple,
330
+ collapsible' = collapsible,
331
+ headingLevel' = headingLevel,
332
+ controlled' = controlled,
333
+ accepting' = accepting,
334
+ valueProp' = valueProp,
335
+ requested' = requested,
336
+ outcome' = outcome,
337
+ base' = base,
338
+ requests' = requests
339
+ }
340
+
341
+ // focus leaves the accordion (click elsewhere, window blur)
342
+ action blur = all {
343
+ expanded0' = expanded0,
344
+ expanded1' = expanded1,
345
+ expanded2' = expanded2,
346
+ focus' = -1,
347
+ multiple' = multiple,
348
+ collapsible' = collapsible,
349
+ headingLevel' = headingLevel,
350
+ controlled' = controlled,
351
+ accepting' = accepting,
352
+ valueProp' = valueProp,
353
+ requested' = requested,
354
+ outcome' = outcome,
355
+ base' = base,
356
+ requests' = requests
357
+ }
358
+
359
+ // prop change: single vs multiple expansion (uncontrolled only re-normalizes;
360
+ // a controlled host owns the value, so its panels stay put)
361
+ action set_multiple = {
362
+ nondet allow = Set(true, false).oneOf()
363
+ all {
364
+ expanded0' = if (controlled) expanded0 else normAt(0, allow, collapsible),
365
+ expanded1' = if (controlled) expanded1 else normAt(1, allow, collapsible),
366
+ expanded2' = if (controlled) expanded2 else normAt(2, allow, collapsible),
367
+ focus' = if (controlled) focus else focusAfter(
368
+ focus,
369
+ normAt(0, allow, collapsible),
370
+ normAt(1, allow, collapsible),
371
+ normAt(2, allow, collapsible)
372
+ ),
373
+ multiple' = allow,
374
+ collapsible' = collapsible,
375
+ headingLevel' = headingLevel,
376
+ controlled' = controlled,
377
+ accepting' = accepting,
378
+ valueProp' = valueProp,
379
+ requested' = if (controlled) requested else (if (normPacked(allow, collapsible) != nowPacked) normPacked(allow, collapsible) else requested),
380
+ outcome' = if (controlled) outcome else (if (normPacked(allow, collapsible) != nowPacked) "accepted" else outcome),
381
+ base' = if (controlled) base else (if (normPacked(allow, collapsible) != nowPacked) nowPacked else base),
382
+ requests' = if (controlled) requests else (if (normPacked(allow, collapsible) != nowPacked) requests + 1 else requests)
383
+ }
384
+ }
385
+
386
+ // prop change: collapsing the last expanded panel permitted or not
387
+ action set_collapsible = {
388
+ nondet allow = Set(true, false).oneOf()
389
+ all {
390
+ expanded0' = if (controlled) expanded0 else normAt(0, multiple, allow),
391
+ expanded1' = if (controlled) expanded1 else normAt(1, multiple, allow),
392
+ expanded2' = if (controlled) expanded2 else normAt(2, multiple, allow),
393
+ focus' = if (controlled) focus else focusAfter(
394
+ focus,
395
+ normAt(0, multiple, allow),
396
+ normAt(1, multiple, allow),
397
+ normAt(2, multiple, allow)
398
+ ),
399
+ multiple' = multiple,
400
+ collapsible' = allow,
401
+ headingLevel' = headingLevel,
402
+ controlled' = controlled,
403
+ accepting' = accepting,
404
+ valueProp' = valueProp,
405
+ requested' = if (controlled) requested else (if (normPacked(multiple, allow) != nowPacked) normPacked(multiple, allow) else requested),
406
+ outcome' = if (controlled) outcome else (if (normPacked(multiple, allow) != nowPacked) "accepted" else outcome),
407
+ base' = if (controlled) base else (if (normPacked(multiple, allow) != nowPacked) nowPacked else base),
408
+ requests' = if (controlled) requests else (if (normPacked(multiple, allow) != nowPacked) requests + 1 else requests)
409
+ }
410
+ }
411
+
412
+ // prop change: aria-level of the wrapping headings
413
+ action set_heading_level = {
414
+ nondet level = Set(1, 2, 3, 4, 5, 6).oneOf()
415
+ all {
416
+ expanded0' = expanded0,
417
+ expanded1' = expanded1,
418
+ expanded2' = expanded2,
419
+ focus' = focus,
420
+ multiple' = multiple,
421
+ collapsible' = collapsible,
422
+ headingLevel' = level,
423
+ controlled' = controlled,
424
+ accepting' = accepting,
425
+ valueProp' = valueProp,
426
+ requested' = requested,
427
+ outcome' = outcome,
428
+ base' = base,
429
+ requests' = requests
430
+ }
431
+ }
432
+
433
+ // commit: the host writes the controlled value (a no-op while uncontrolled)
434
+ action setValue = {
435
+ nondet v = Set(0, 1, 2, 3, 4, 5, 6, 7).oneOf()
436
+ all {
437
+ expanded0' = if (controlled) bitAt(0, v) else expanded0,
438
+ expanded1' = if (controlled) bitAt(1, v) else expanded1,
439
+ expanded2' = if (controlled) bitAt(2, v) else expanded2,
440
+ focus' = if (controlled) focusAfter(focus, bitAt(0, v), bitAt(1, v), bitAt(2, v)) else focus,
441
+ multiple' = multiple,
442
+ collapsible' = collapsible,
443
+ headingLevel' = headingLevel,
444
+ controlled' = controlled,
445
+ accepting' = accepting,
446
+ valueProp' = if (controlled) v else valueProp,
447
+ requested' = requested,
448
+ outcome' = if (controlled) "none" else outcome,
449
+ base' = base,
450
+ requests' = requests
451
+ }
452
+ }
453
+
454
+ // commit: the host flips one bit of its own copy of the value
455
+ action hostToggle = {
456
+ nondet panel = Set(0, 1, 2).oneOf()
457
+ all {
458
+ expanded0' = if (controlled) bitAt(0, flipAt(valueProp, panel)) else expanded0,
459
+ expanded1' = if (controlled) bitAt(1, flipAt(valueProp, panel)) else expanded1,
460
+ expanded2' = if (controlled) bitAt(2, flipAt(valueProp, panel)) else expanded2,
461
+ focus' = if (controlled) focusAfter(focus, bitAt(0, flipAt(valueProp, panel)), bitAt(1, flipAt(valueProp, panel)), bitAt(2, flipAt(valueProp, panel))) else focus,
462
+ multiple' = multiple,
463
+ collapsible' = collapsible,
464
+ headingLevel' = headingLevel,
465
+ controlled' = controlled,
466
+ accepting' = accepting,
467
+ valueProp' = if (controlled) flipAt(valueProp, panel) else valueProp,
468
+ requested' = requested,
469
+ outcome' = if (controlled) "none" else outcome,
470
+ base' = base,
471
+ requests' = requests
472
+ }
473
+ }
474
+
475
+ // configuration: the host starts owning the value, adopting the current one
476
+ action control = all {
477
+ expanded0' = expanded0,
478
+ expanded1' = expanded1,
479
+ expanded2' = expanded2,
480
+ focus' = focus,
481
+ multiple' = multiple,
482
+ collapsible' = collapsible,
483
+ headingLevel' = headingLevel,
484
+ controlled' = true,
485
+ accepting' = accepting,
486
+ valueProp' = nowPacked,
487
+ requested' = requested,
488
+ outcome' = outcome,
489
+ base' = base,
490
+ requests' = requests
491
+ }
492
+
493
+ // configuration: the controlled host answers every request with its intent
494
+ action acceptRequests = all {
495
+ expanded0' = expanded0,
496
+ expanded1' = expanded1,
497
+ expanded2' = expanded2,
498
+ focus' = focus,
499
+ multiple' = multiple,
500
+ collapsible' = collapsible,
501
+ headingLevel' = headingLevel,
502
+ controlled' = controlled,
503
+ accepting' = true,
504
+ valueProp' = valueProp,
505
+ requested' = requested,
506
+ outcome' = outcome,
507
+ base' = base,
508
+ requests' = requests
509
+ }
510
+
511
+ action step = any {
512
+ init,
513
+ click_header,
514
+ click_content,
515
+ key_enter,
516
+ key_space,
517
+ key_tab,
518
+ key_shift_tab,
519
+ blur,
520
+ set_multiple,
521
+ set_collapsible,
522
+ set_heading_level,
523
+ setValue,
524
+ hostToggle,
525
+ control,
526
+ acceptRequests
527
+ }
528
+
529
+ // ---- invariants ----------------------------------------------------------
530
+
531
+ val FocusInRange: bool = and(focus >= -1, focus <= 5)
532
+ // content of a collapsed (hidden) panel never holds focus
533
+ val FocusNotInCollapsedPanel: bool =
534
+ or(not(onContent(focus)), expandedAt(focus / 2))
535
+ // "only one panel expanded" implementations (uncontrolled: a controlled host
536
+ // owns the value and may hand us any set)
537
+ val SingleModeAtMostOneExpanded: bool =
538
+ ariaSingleHoldsAtMostOne(controlled, multiple, expandedCount(expanded0, expanded1, expanded2))
539
+ // "one panel must stay expanded" implementations (uncontrolled)
540
+ val NonCollapsibleKeepsOneExpanded: bool =
541
+ or(controlled, or(collapsible, expandedCount(expanded0, expanded1, expanded2) >= 1))
542
+ // aria-disabled only ever sits on an expanded header that cannot collapse
543
+ val AriaDisabledOnlyOnExpanded: bool = and(
544
+ or(not(ariaDisabled(0)), expandedAt(0)),
545
+ or(not(ariaDisabled(1)), expandedAt(1)),
546
+ or(not(ariaDisabled(2)), expandedAt(2))
547
+ )
548
+ val AriaDisabledMatchesToggle: bool = and(
549
+ toggleContractHolds(0),
550
+ toggleContractHolds(1),
551
+ toggleContractHolds(2)
552
+ )
553
+ val ExpandRespectsMode: bool = and(
554
+ expandRespectsMode(0),
555
+ expandRespectsMode(1),
556
+ expandRespectsMode(2)
557
+ )
558
+ val AriaExpandedMirrorsVisibility: bool = and(
559
+ expandedMirrorsVisibility(0),
560
+ expandedMirrorsVisibility(1),
561
+ expandedMirrorsVisibility(2)
562
+ )
563
+ // aria-controls on header i and aria-labelledby on its region round-trip
564
+ val ControlsLabelledByRoundTrip: bool = and(
565
+ regionLabelledBy(ariaControls(0)) == 0,
566
+ regionLabelledBy(ariaControls(1)) == 1,
567
+ regionLabelledBy(ariaControls(2)) == 2
568
+ )
569
+ val HeadingLevelValid: bool = and(headingLevel >= 1, headingLevel <= 6)
570
+ // callbacks only ever count up from zero
571
+ val RequestsNonNegative: bool = requests >= 0
572
+ // controlled: only the value prop sets the state, so no event commits
573
+ val ControlledFollowsProp: bool = or(not(controlled), nowPacked == valueProp)
574
+ // a rejected request leaves the state as it found it
575
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), nowPacked == base)
576
+ // an accepted request committed exactly its intent
577
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), nowPacked == requested)
578
+ }