@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,799 @@
1
+ // APG Listbox, multi-select — https://www.w3.org/WAI/ARIA/apg/patterns/listbox/
2
+ //
3
+ // Sections modelled:
4
+ // - "Keyboard Interaction" -> "Multiple Selection": BOTH interaction models.
5
+ // recommended no modifier needed: Space toggles; Shift+Down/Up move focus
6
+ // and toggle the entered option; Shift+Space selects the
7
+ // contiguous run from the most recently selected option to the
8
+ // focused one; Ctrl+Shift+Home/End select to the first/last
9
+ // option and move focus there; Ctrl+A selects all (and, as in
10
+ // the rearrangeable example, unselects all when all are
11
+ // selected).
12
+ // alternative plain Up/Down/Home/End move focus AND collapse the selection
13
+ // to the focused option; Ctrl+Down/Up move focus only;
14
+ // Ctrl+Space toggles; Shift+Down/Up, Shift+Space,
15
+ // Ctrl+Shift+Home/End and Ctrl+A as above.
16
+ // and the multi-select focus rule: on entry focus the first selected option,
17
+ // else the first option, with no selection change.
18
+ // - "WAI-ARIA Roles, States, and Properties": aria-multiselectable=true,
19
+ // aria-selected on each option, aria-activedescendant.
20
+ // - Note 1 of the keyboard section: focus is distinct from selection.
21
+ // - Example listbox-rearrangeable, example 2 (the multi-select side).
22
+ //
23
+ // State: focus, the active option, the selection as a bitmask over five options,
24
+ // the selection anchor (the most recently selected option, the start of a
25
+ // Shift+Space run), the selection model (a prop), and lastOp — the kind of the
26
+ // last handled operation, which lets invariants state per-operation guarantees
27
+ // ("after a range select the run is selected") without a history.
28
+ //
29
+ // Request vs commit (map ticket rt1p). Every event a user would see as a
30
+ // selection change — the plain moves in the alternative model, Space/Ctrl+Space,
31
+ // the Shift chords, Ctrl+Shift+Home/End, Ctrl+A and click — is a *request*
32
+ // carrying the intended selection mask. It fires onValueChange (`requests`
33
+ // counts the calls) when the intent differs from the current selection, and the
34
+ // selection commits through `landed(holds, selected, intent)`:
35
+ // uncontrolled accepted: the selection commits the intent
36
+ // controlled, holds rejected: the selection is untouched
37
+ // controlled, accepts accepted through the controlled value (hostWrites)
38
+ // The controlled value is `valueProp0..valueProp4` (one bool per option), the
39
+ // only thing that changes the selection while `controlled`; `setValue` /
40
+ // `hostToggle` are the host writing it. `control` / `acceptRequests` are
41
+ // configuration events (init is literal-only). active focus stays
42
+ // widget-internal, so a rejected request still moves it.
43
+ //
44
+ // The per-operation invariants (AnchorSelectedOrNone, the alternative collapse
45
+ // rules, the range rules) are scoped to the uncontrolled path: a controlled host
46
+ // owns the value and may hand the widget any set, so its anchor/lastOp bookkeeping
47
+ // is not constrained.
48
+ //
49
+ // Choices where the pattern leaves a gap (ponytail):
50
+ // - in the recommended model Ctrl+Arrow and Ctrl+Space are not handled; in the
51
+ // alternative model plain Space is not handled (the pattern lists only
52
+ // Ctrl+Space there)
53
+ // - Shift+Arrow at the first/last option does nothing (no move, no toggle)
54
+ // - a pointer click toggles in the recommended model and selects only the
55
+ // clicked option in the alternative model (the example's click handler
56
+ // toggles; the alternative model's "selection collapses" rule is applied)
57
+ // - the anchor is the last option toggled ON; deselecting the anchor clears it,
58
+ // so the anchor is always a selected option or "none"
59
+ // - Ctrl+Shift+Home/End also move focus to the first/last option (the
60
+ // pattern's optional part)
61
+ //
62
+ // Deliberate omissions: typeahead (listbox-typeahead.qnt), rearranging
63
+ // (listbox-rearrangeable.qnt), scrolling, horizontal orientation, aria-checked,
64
+ // Shift+click and Ctrl+click ranges, labels, disabled options, wrap-around.
65
+ //
66
+ // Generator notes: no parameterized actions and no `let`, so a request spells
67
+ // `landed(...)` out per assignment and every action assigns every var.
68
+ module apg_listbox_multi {
69
+ var hasFocus: bool // the listbox is the focused element
70
+ var active: int // the active option (aria-activedescendant target)
71
+ var selected: int // selection bitmask over the options (bit i = option i)
72
+ var anchor: int // most recently selected option (noOption = none)
73
+ var selectionModel: str // prop: "recommended" | "alternative"
74
+ var lastOp: str // kind of the last handled operation
75
+ var controlled: bool // the host owns the value (`value` prop present)
76
+ var accepting: bool // controlled host that answers every request with its intent
77
+ var valueProp0: bool // the controlled value prop, one bool per option
78
+ var valueProp1: bool
79
+ var valueProp2: bool
80
+ var valueProp3: bool
81
+ var valueProp4: bool
82
+ var requested: int // intent of the last request (selection mask)
83
+ var outcome: str // "none" | "accepted" | "rejected"
84
+ var base: int // selection when the last request was received
85
+ var requests: int // number of requests that fired onValueChange
86
+
87
+ // five options
88
+ def optionCount: int = 5
89
+ def lastIndex: int = 4
90
+ def noOption: int = 5
91
+
92
+ def pow2(i: int): int =
93
+ if (i == 0) 1
94
+ else if (i == 1) 2
95
+ else if (i == 2) 4
96
+ else if (i == 3) 8
97
+ else if (i == 4) 16
98
+ else 32
99
+ // bit i of a selection mask: option i is selected
100
+ def bit(mask: int, i: int): bool = (mask / pow2(i)) % 2 == 1
101
+ def popcount(mask: int): int =
102
+ (if (bit(mask, 0)) 1 else 0)
103
+ + (if (bit(mask, 1)) 1 else 0)
104
+ + (if (bit(mask, 2)) 1 else 0)
105
+ + (if (bit(mask, 3)) 1 else 0)
106
+ + (if (bit(mask, 4)) 1 else 0)
107
+ def firstSelected(mask: int): int =
108
+ if (bit(mask, 0)) 0
109
+ else if (bit(mask, 1)) 1
110
+ else if (bit(mask, 2)) 2
111
+ else if (bit(mask, 3)) 3
112
+ else if (bit(mask, 4)) 4
113
+ else noOption
114
+ // Tab entry: the first selected option, else the first option
115
+ def entryIndex(mask: int): int = if (mask == 0) 0 else firstSelected(mask)
116
+ def nextIndex(i: int): int = if (i < lastIndex) i + 1 else i
117
+ def prevIndex(i: int): int = if (i > 0) i - 1 else i
118
+ def allMask: int = pow2(optionCount) - 1
119
+ def isAlt(model: str): bool = model == "alternative"
120
+
121
+ // --- mask arithmetic (the subset has no bitwise operators) ---------------
122
+ def bitVal(mask: int, i: int): int = if (bit(mask, i)) pow2(i) else 0
123
+ def orMask(a: int, b: int): int =
124
+ (if (or(bit(a, 0), bit(b, 0))) 1 else 0)
125
+ + (if (or(bit(a, 1), bit(b, 1))) 2 else 0)
126
+ + (if (or(bit(a, 2), bit(b, 2))) 4 else 0)
127
+ + (if (or(bit(a, 3), bit(b, 3))) 8 else 0)
128
+ + (if (or(bit(a, 4), bit(b, 4))) 16 else 0)
129
+ def toggle(mask: int, i: int): int =
130
+ if (bit(mask, i)) mask - pow2(i) else mask + pow2(i)
131
+ def loOf(a: int, b: int): int = if (a < b) a else b
132
+ def hiOf(a: int, b: int): int = if (a < b) b else a
133
+ // options lo..hi inclusive
134
+ def rangeMask(lo: int, hi: int): int =
135
+ if (lo > hi) 0 else pow2(hi + 1) - pow2(lo)
136
+
137
+ // --- selection rules -----------------------------------------------------
138
+ // the anchor after toggling option i (mask is the mask BEFORE the toggle)
139
+ def anchorAfterToggle(mask: int, anch: int, i: int): int =
140
+ if (bit(mask, i)) (if (anch == i) noOption else anch) else i
141
+ // alternative model: a plain move collapses the selection to the target
142
+ def selectionAfterPlainMove(model: str, mask: int, target: int): int =
143
+ if (isAlt(model)) pow2(target) else mask
144
+ def anchorAfterPlainMove(model: str, anch: int, target: int): int =
145
+ if (isAlt(model)) target else anch
146
+ def runStart(anch: int, act: int): int = if (anch == noOption) act else anch
147
+
148
+ // --- request / commit (map ticket rt1p) ---------------------------------
149
+ def b2i(b: bool): int = if (b) 1 else 0
150
+ // the controlled value prop, packed into a mask
151
+ def propsMask: int =
152
+ b2i(valueProp0)
153
+ + 2 * b2i(valueProp1)
154
+ + 4 * b2i(valueProp2)
155
+ + 8 * b2i(valueProp3)
156
+ + 16 * b2i(valueProp4)
157
+ // the host flips one selected option of its own copy of the value
158
+ def flipAt(n: int, i: int): int = if (bit(n, i)) n - pow2(i) else n + pow2(i)
159
+ // controlled host that keeps the old value: the request is rejected
160
+ def holds: bool = and(controlled, not(accepting))
161
+ // controlled host that writes the intent back through the value prop
162
+ def hostWrites: bool = and(controlled, accepting)
163
+ def verdict: str = if (holds) "rejected" else "accepted"
164
+ // the value after a request for `want`
165
+ def landed(h: bool, current: int, want: int): int = if (h) current else want
166
+ // one value-prop member after a request for `want`
167
+ def propBit(writes: bool, current: bool, want: int, i: int): bool =
168
+ if (writes) bit(want, i) else current
169
+ // the intended selection of each request
170
+ def downIntent: int =
171
+ if (hasFocus) selectionAfterPlainMove(selectionModel, selected, nextIndex(active)) else selected
172
+ def upIntent: int =
173
+ if (hasFocus) selectionAfterPlainMove(selectionModel, selected, prevIndex(active)) else selected
174
+ def homeIntent: int =
175
+ if (hasFocus) selectionAfterPlainMove(selectionModel, selected, 0) else selected
176
+ def endIntent: int =
177
+ if (hasFocus) selectionAfterPlainMove(selectionModel, selected, lastIndex) else selected
178
+ def spaceIntent: int =
179
+ if (and(hasFocus, not(isAlt(selectionModel)))) toggle(selected, active) else selected
180
+ def ctrlSpaceIntent: int =
181
+ if (and(hasFocus, isAlt(selectionModel))) toggle(selected, active) else selected
182
+ def shiftDownIntent: int =
183
+ if (and(hasFocus, nextIndex(active) != active)) toggle(selected, nextIndex(active)) else selected
184
+ def shiftUpIntent: int =
185
+ if (and(hasFocus, prevIndex(active) != active)) toggle(selected, prevIndex(active)) else selected
186
+ def shiftSpaceIntent: int =
187
+ if (hasFocus) orMask(selected, rangeMask(loOf(runStart(anchor, active), active), hiOf(runStart(anchor, active), active))) else selected
188
+ def ctrlShiftHomeIntent: int =
189
+ if (hasFocus) orMask(selected, rangeMask(0, active)) else selected
190
+ def ctrlShiftEndIntent: int =
191
+ if (hasFocus) orMask(selected, rangeMask(active, lastIndex)) else selected
192
+ def ctrlAIntent: int =
193
+ if (hasFocus) (if (selected == allMask) 0 else allMask) else selected
194
+ def clickIntent(index: int): int =
195
+ if (isAlt(selectionModel)) pow2(index) else toggle(selected, index)
196
+
197
+ // --- ARIA contract, derived from the state ------------------------------
198
+ def ariaMultiselectable: bool = true
199
+ def ariaActiveDescendant(focused: bool, idx: int): int =
200
+ if (focused) idx else noOption
201
+ def ariaSelected(mask: int, i: int): bool = bit(mask, i)
202
+ def listboxTabIndex: int = 0
203
+
204
+ action init = all {
205
+ hasFocus' = false,
206
+ active' = 0,
207
+ selected' = 0,
208
+ anchor' = noOption,
209
+ selectionModel' = "recommended",
210
+ lastOp' = "none",
211
+ controlled' = false,
212
+ accepting' = false,
213
+ valueProp0' = false,
214
+ valueProp1' = false,
215
+ valueProp2' = false,
216
+ valueProp3' = false,
217
+ valueProp4' = false,
218
+ requested' = 0,
219
+ outcome' = "none",
220
+ base' = 0,
221
+ requests' = 0
222
+ }
223
+
224
+ // mount: the uncontrolled initial selection (the host's `defaultValue`),
225
+ // active at the first option and no anchor, exactly as the primitive mounts
226
+ action seed = {
227
+ nondet v = Set(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31).oneOf()
228
+ all {
229
+ hasFocus' = false,
230
+ active' = 0,
231
+ selected' = v,
232
+ anchor' = noOption,
233
+ selectionModel' = selectionModel,
234
+ lastOp' = "none",
235
+ controlled' = false,
236
+ accepting' = false,
237
+ valueProp0' = false,
238
+ valueProp1' = false,
239
+ valueProp2' = false,
240
+ valueProp3' = false,
241
+ valueProp4' = false,
242
+ requested' = 0,
243
+ outcome' = "none",
244
+ base' = 0,
245
+ requests' = 0
246
+ }
247
+ }
248
+
249
+ // Tab into the listbox: focus the first selected option, else the first.
250
+ // No selection change; already focused is a no-op.
251
+ action focus_in = all {
252
+ hasFocus' = true,
253
+ active' = if (hasFocus) active else entryIndex(selected),
254
+ selected' = selected,
255
+ anchor' = anchor,
256
+ selectionModel' = selectionModel,
257
+ lastOp' = if (hasFocus) lastOp else "focus",
258
+ controlled' = controlled,
259
+ accepting' = accepting,
260
+ valueProp0' = valueProp0,
261
+ valueProp1' = valueProp1,
262
+ valueProp2' = valueProp2,
263
+ valueProp3' = valueProp3,
264
+ valueProp4' = valueProp4,
265
+ requested' = requested,
266
+ outcome' = outcome,
267
+ base' = base,
268
+ requests' = requests
269
+ }
270
+
271
+ // Tab out: the selection is kept.
272
+ action blur = all {
273
+ hasFocus' = false,
274
+ active' = active,
275
+ selected' = selected,
276
+ anchor' = anchor,
277
+ selectionModel' = selectionModel,
278
+ lastOp' = "blur",
279
+ controlled' = controlled,
280
+ accepting' = accepting,
281
+ valueProp0' = valueProp0,
282
+ valueProp1' = valueProp1,
283
+ valueProp2' = valueProp2,
284
+ valueProp3' = valueProp3,
285
+ valueProp4' = valueProp4,
286
+ requested' = requested,
287
+ outcome' = outcome,
288
+ base' = base,
289
+ requests' = requests
290
+ }
291
+
292
+ // Plain moves: focus only (recommended) / focus + collapse the selection
293
+ // to the target (alternative).
294
+ action arrow_down = all {
295
+ hasFocus' = hasFocus,
296
+ active' = if (hasFocus) nextIndex(active) else active,
297
+ selected' = landed(holds, selected, downIntent),
298
+ anchor' = if (hasFocus) anchorAfterPlainMove(selectionModel, anchor, nextIndex(active)) else anchor,
299
+ selectionModel' = selectionModel,
300
+ lastOp' = if (hasFocus) "move" else lastOp,
301
+ controlled' = controlled,
302
+ accepting' = accepting,
303
+ valueProp0' = propBit(hostWrites, valueProp0, downIntent, 0),
304
+ valueProp1' = propBit(hostWrites, valueProp1, downIntent, 1),
305
+ valueProp2' = propBit(hostWrites, valueProp2, downIntent, 2),
306
+ valueProp3' = propBit(hostWrites, valueProp3, downIntent, 3),
307
+ valueProp4' = propBit(hostWrites, valueProp4, downIntent, 4),
308
+ requested' = if (downIntent != selected) downIntent else requested,
309
+ outcome' = if (downIntent != selected) verdict else outcome,
310
+ base' = if (downIntent != selected) selected else base,
311
+ requests' = if (downIntent != selected) requests + 1 else requests
312
+ }
313
+
314
+ action arrow_up = all {
315
+ hasFocus' = hasFocus,
316
+ active' = if (hasFocus) prevIndex(active) else active,
317
+ selected' = landed(holds, selected, upIntent),
318
+ anchor' = if (hasFocus) anchorAfterPlainMove(selectionModel, anchor, prevIndex(active)) else anchor,
319
+ selectionModel' = selectionModel,
320
+ lastOp' = if (hasFocus) "move" else lastOp,
321
+ controlled' = controlled,
322
+ accepting' = accepting,
323
+ valueProp0' = propBit(hostWrites, valueProp0, upIntent, 0),
324
+ valueProp1' = propBit(hostWrites, valueProp1, upIntent, 1),
325
+ valueProp2' = propBit(hostWrites, valueProp2, upIntent, 2),
326
+ valueProp3' = propBit(hostWrites, valueProp3, upIntent, 3),
327
+ valueProp4' = propBit(hostWrites, valueProp4, upIntent, 4),
328
+ requested' = if (upIntent != selected) upIntent else requested,
329
+ outcome' = if (upIntent != selected) verdict else outcome,
330
+ base' = if (upIntent != selected) selected else base,
331
+ requests' = if (upIntent != selected) requests + 1 else requests
332
+ }
333
+
334
+ action home = all {
335
+ hasFocus' = hasFocus,
336
+ active' = if (hasFocus) 0 else active,
337
+ selected' = landed(holds, selected, homeIntent),
338
+ anchor' = if (hasFocus) anchorAfterPlainMove(selectionModel, anchor, 0) else anchor,
339
+ selectionModel' = selectionModel,
340
+ lastOp' = if (hasFocus) "move" else lastOp,
341
+ controlled' = controlled,
342
+ accepting' = accepting,
343
+ valueProp0' = propBit(hostWrites, valueProp0, homeIntent, 0),
344
+ valueProp1' = propBit(hostWrites, valueProp1, homeIntent, 1),
345
+ valueProp2' = propBit(hostWrites, valueProp2, homeIntent, 2),
346
+ valueProp3' = propBit(hostWrites, valueProp3, homeIntent, 3),
347
+ valueProp4' = propBit(hostWrites, valueProp4, homeIntent, 4),
348
+ requested' = if (homeIntent != selected) homeIntent else requested,
349
+ outcome' = if (homeIntent != selected) verdict else outcome,
350
+ base' = if (homeIntent != selected) selected else base,
351
+ requests' = if (homeIntent != selected) requests + 1 else requests
352
+ }
353
+
354
+ action end = all {
355
+ hasFocus' = hasFocus,
356
+ active' = if (hasFocus) lastIndex else active,
357
+ selected' = landed(holds, selected, endIntent),
358
+ anchor' = if (hasFocus) anchorAfterPlainMove(selectionModel, anchor, lastIndex) else anchor,
359
+ selectionModel' = selectionModel,
360
+ lastOp' = if (hasFocus) "move" else lastOp,
361
+ controlled' = controlled,
362
+ accepting' = accepting,
363
+ valueProp0' = propBit(hostWrites, valueProp0, endIntent, 0),
364
+ valueProp1' = propBit(hostWrites, valueProp1, endIntent, 1),
365
+ valueProp2' = propBit(hostWrites, valueProp2, endIntent, 2),
366
+ valueProp3' = propBit(hostWrites, valueProp3, endIntent, 3),
367
+ valueProp4' = propBit(hostWrites, valueProp4, endIntent, 4),
368
+ requested' = if (endIntent != selected) endIntent else requested,
369
+ outcome' = if (endIntent != selected) verdict else outcome,
370
+ base' = if (endIntent != selected) selected else base,
371
+ requests' = if (endIntent != selected) requests + 1 else requests
372
+ }
373
+
374
+ // Recommended model: Space toggles the active option.
375
+ action space = all {
376
+ hasFocus' = hasFocus,
377
+ active' = active,
378
+ selected' = landed(holds, selected, spaceIntent),
379
+ anchor' = if (and(hasFocus, not(isAlt(selectionModel)))) anchorAfterToggle(selected, anchor, active) else anchor,
380
+ selectionModel' = selectionModel,
381
+ lastOp' = if (and(hasFocus, not(isAlt(selectionModel)))) "toggle" else lastOp,
382
+ controlled' = controlled,
383
+ accepting' = accepting,
384
+ valueProp0' = propBit(hostWrites, valueProp0, spaceIntent, 0),
385
+ valueProp1' = propBit(hostWrites, valueProp1, spaceIntent, 1),
386
+ valueProp2' = propBit(hostWrites, valueProp2, spaceIntent, 2),
387
+ valueProp3' = propBit(hostWrites, valueProp3, spaceIntent, 3),
388
+ valueProp4' = propBit(hostWrites, valueProp4, spaceIntent, 4),
389
+ requested' = if (spaceIntent != selected) spaceIntent else requested,
390
+ outcome' = if (spaceIntent != selected) verdict else outcome,
391
+ base' = if (spaceIntent != selected) selected else base,
392
+ requests' = if (spaceIntent != selected) requests + 1 else requests
393
+ }
394
+
395
+ // Alternative model: Ctrl+Space toggles the active option.
396
+ action ctrl_space = all {
397
+ hasFocus' = hasFocus,
398
+ active' = active,
399
+ selected' = landed(holds, selected, ctrlSpaceIntent),
400
+ anchor' = if (and(hasFocus, isAlt(selectionModel))) anchorAfterToggle(selected, anchor, active) else anchor,
401
+ selectionModel' = selectionModel,
402
+ lastOp' = if (and(hasFocus, isAlt(selectionModel))) "toggle" else lastOp,
403
+ controlled' = controlled,
404
+ accepting' = accepting,
405
+ valueProp0' = propBit(hostWrites, valueProp0, ctrlSpaceIntent, 0),
406
+ valueProp1' = propBit(hostWrites, valueProp1, ctrlSpaceIntent, 1),
407
+ valueProp2' = propBit(hostWrites, valueProp2, ctrlSpaceIntent, 2),
408
+ valueProp3' = propBit(hostWrites, valueProp3, ctrlSpaceIntent, 3),
409
+ valueProp4' = propBit(hostWrites, valueProp4, ctrlSpaceIntent, 4),
410
+ requested' = if (ctrlSpaceIntent != selected) ctrlSpaceIntent else requested,
411
+ outcome' = if (ctrlSpaceIntent != selected) verdict else outcome,
412
+ base' = if (ctrlSpaceIntent != selected) selected else base,
413
+ requests' = if (ctrlSpaceIntent != selected) requests + 1 else requests
414
+ }
415
+
416
+ // Shift+Arrow (both models): move focus to and toggle the entered option.
417
+ action shift_arrow_down = all {
418
+ hasFocus' = hasFocus,
419
+ active' = if (and(hasFocus, nextIndex(active) != active)) nextIndex(active) else active,
420
+ selected' = landed(holds, selected, shiftDownIntent),
421
+ anchor' = if (and(hasFocus, nextIndex(active) != active)) anchorAfterToggle(selected, anchor, nextIndex(active)) else anchor,
422
+ selectionModel' = selectionModel,
423
+ lastOp' = if (and(hasFocus, nextIndex(active) != active)) "toggle" else lastOp,
424
+ controlled' = controlled,
425
+ accepting' = accepting,
426
+ valueProp0' = propBit(hostWrites, valueProp0, shiftDownIntent, 0),
427
+ valueProp1' = propBit(hostWrites, valueProp1, shiftDownIntent, 1),
428
+ valueProp2' = propBit(hostWrites, valueProp2, shiftDownIntent, 2),
429
+ valueProp3' = propBit(hostWrites, valueProp3, shiftDownIntent, 3),
430
+ valueProp4' = propBit(hostWrites, valueProp4, shiftDownIntent, 4),
431
+ requested' = if (shiftDownIntent != selected) shiftDownIntent else requested,
432
+ outcome' = if (shiftDownIntent != selected) verdict else outcome,
433
+ base' = if (shiftDownIntent != selected) selected else base,
434
+ requests' = if (shiftDownIntent != selected) requests + 1 else requests
435
+ }
436
+
437
+ action shift_arrow_up = all {
438
+ hasFocus' = hasFocus,
439
+ active' = if (and(hasFocus, prevIndex(active) != active)) prevIndex(active) else active,
440
+ selected' = landed(holds, selected, shiftUpIntent),
441
+ anchor' = if (and(hasFocus, prevIndex(active) != active)) anchorAfterToggle(selected, anchor, prevIndex(active)) else anchor,
442
+ selectionModel' = selectionModel,
443
+ lastOp' = if (and(hasFocus, prevIndex(active) != active)) "toggle" else lastOp,
444
+ controlled' = controlled,
445
+ accepting' = accepting,
446
+ valueProp0' = propBit(hostWrites, valueProp0, shiftUpIntent, 0),
447
+ valueProp1' = propBit(hostWrites, valueProp1, shiftUpIntent, 1),
448
+ valueProp2' = propBit(hostWrites, valueProp2, shiftUpIntent, 2),
449
+ valueProp3' = propBit(hostWrites, valueProp3, shiftUpIntent, 3),
450
+ valueProp4' = propBit(hostWrites, valueProp4, shiftUpIntent, 4),
451
+ requested' = if (shiftUpIntent != selected) shiftUpIntent else requested,
452
+ outcome' = if (shiftUpIntent != selected) verdict else outcome,
453
+ base' = if (shiftUpIntent != selected) selected else base,
454
+ requests' = if (shiftUpIntent != selected) requests + 1 else requests
455
+ }
456
+
457
+ // Alternative model: Ctrl+Arrow moves focus without touching the selection.
458
+ action ctrl_arrow_down = all {
459
+ hasFocus' = hasFocus,
460
+ active' = if (and(hasFocus, isAlt(selectionModel))) nextIndex(active) else active,
461
+ selected' = selected,
462
+ anchor' = anchor,
463
+ selectionModel' = selectionModel,
464
+ lastOp' = if (and(hasFocus, isAlt(selectionModel))) "ctrl_move" else lastOp,
465
+ controlled' = controlled,
466
+ accepting' = accepting,
467
+ valueProp0' = valueProp0,
468
+ valueProp1' = valueProp1,
469
+ valueProp2' = valueProp2,
470
+ valueProp3' = valueProp3,
471
+ valueProp4' = valueProp4,
472
+ requested' = requested,
473
+ outcome' = outcome,
474
+ base' = base,
475
+ requests' = requests
476
+ }
477
+
478
+ action ctrl_arrow_up = all {
479
+ hasFocus' = hasFocus,
480
+ active' = if (and(hasFocus, isAlt(selectionModel))) prevIndex(active) else active,
481
+ selected' = selected,
482
+ anchor' = anchor,
483
+ selectionModel' = selectionModel,
484
+ lastOp' = if (and(hasFocus, isAlt(selectionModel))) "ctrl_move" else lastOp,
485
+ controlled' = controlled,
486
+ accepting' = accepting,
487
+ valueProp0' = valueProp0,
488
+ valueProp1' = valueProp1,
489
+ valueProp2' = valueProp2,
490
+ valueProp3' = valueProp3,
491
+ valueProp4' = valueProp4,
492
+ requested' = requested,
493
+ outcome' = outcome,
494
+ base' = base,
495
+ requests' = requests
496
+ }
497
+
498
+ // Select the contiguous run from the anchor to the active option (the
499
+ // anchor starts as the active option when there is none).
500
+ action shift_space = all {
501
+ hasFocus' = hasFocus,
502
+ active' = active,
503
+ selected' = landed(holds, selected, shiftSpaceIntent),
504
+ anchor' = if (hasFocus) runStart(anchor, active) else anchor,
505
+ selectionModel' = selectionModel,
506
+ lastOp' = if (hasFocus) "range" else lastOp,
507
+ controlled' = controlled,
508
+ accepting' = accepting,
509
+ valueProp0' = propBit(hostWrites, valueProp0, shiftSpaceIntent, 0),
510
+ valueProp1' = propBit(hostWrites, valueProp1, shiftSpaceIntent, 1),
511
+ valueProp2' = propBit(hostWrites, valueProp2, shiftSpaceIntent, 2),
512
+ valueProp3' = propBit(hostWrites, valueProp3, shiftSpaceIntent, 3),
513
+ valueProp4' = propBit(hostWrites, valueProp4, shiftSpaceIntent, 4),
514
+ requested' = if (shiftSpaceIntent != selected) shiftSpaceIntent else requested,
515
+ outcome' = if (shiftSpaceIntent != selected) verdict else outcome,
516
+ base' = if (shiftSpaceIntent != selected) selected else base,
517
+ requests' = if (shiftSpaceIntent != selected) requests + 1 else requests
518
+ }
519
+
520
+ // Select the active option and everything above it; focus moves to the first.
521
+ action ctrl_shift_home = all {
522
+ hasFocus' = hasFocus,
523
+ active' = if (hasFocus) 0 else active,
524
+ selected' = landed(holds, selected, ctrlShiftHomeIntent),
525
+ anchor' = anchor,
526
+ selectionModel' = selectionModel,
527
+ lastOp' = if (hasFocus) "range_home" else lastOp,
528
+ controlled' = controlled,
529
+ accepting' = accepting,
530
+ valueProp0' = propBit(hostWrites, valueProp0, ctrlShiftHomeIntent, 0),
531
+ valueProp1' = propBit(hostWrites, valueProp1, ctrlShiftHomeIntent, 1),
532
+ valueProp2' = propBit(hostWrites, valueProp2, ctrlShiftHomeIntent, 2),
533
+ valueProp3' = propBit(hostWrites, valueProp3, ctrlShiftHomeIntent, 3),
534
+ valueProp4' = propBit(hostWrites, valueProp4, ctrlShiftHomeIntent, 4),
535
+ requested' = if (ctrlShiftHomeIntent != selected) ctrlShiftHomeIntent else requested,
536
+ outcome' = if (ctrlShiftHomeIntent != selected) verdict else outcome,
537
+ base' = if (ctrlShiftHomeIntent != selected) selected else base,
538
+ requests' = if (ctrlShiftHomeIntent != selected) requests + 1 else requests
539
+ }
540
+
541
+ // Select the active option and everything below it; focus moves to the last.
542
+ action ctrl_shift_end = all {
543
+ hasFocus' = hasFocus,
544
+ active' = if (hasFocus) lastIndex else active,
545
+ selected' = landed(holds, selected, ctrlShiftEndIntent),
546
+ anchor' = anchor,
547
+ selectionModel' = selectionModel,
548
+ lastOp' = if (hasFocus) "range_end" else lastOp,
549
+ controlled' = controlled,
550
+ accepting' = accepting,
551
+ valueProp0' = propBit(hostWrites, valueProp0, ctrlShiftEndIntent, 0),
552
+ valueProp1' = propBit(hostWrites, valueProp1, ctrlShiftEndIntent, 1),
553
+ valueProp2' = propBit(hostWrites, valueProp2, ctrlShiftEndIntent, 2),
554
+ valueProp3' = propBit(hostWrites, valueProp3, ctrlShiftEndIntent, 3),
555
+ valueProp4' = propBit(hostWrites, valueProp4, ctrlShiftEndIntent, 4),
556
+ requested' = if (ctrlShiftEndIntent != selected) ctrlShiftEndIntent else requested,
557
+ outcome' = if (ctrlShiftEndIntent != selected) verdict else outcome,
558
+ base' = if (ctrlShiftEndIntent != selected) selected else base,
559
+ requests' = if (ctrlShiftEndIntent != selected) requests + 1 else requests
560
+ }
561
+
562
+ // Select all; when everything is already selected, unselect all.
563
+ action ctrl_a = all {
564
+ hasFocus' = hasFocus,
565
+ active' = active,
566
+ selected' = landed(holds, selected, ctrlAIntent),
567
+ anchor' = if (and(hasFocus, selected == allMask)) noOption else anchor,
568
+ selectionModel' = selectionModel,
569
+ lastOp' = if (hasFocus) (if (selected == allMask) "clear_all" else "select_all") else lastOp,
570
+ controlled' = controlled,
571
+ accepting' = accepting,
572
+ valueProp0' = propBit(hostWrites, valueProp0, ctrlAIntent, 0),
573
+ valueProp1' = propBit(hostWrites, valueProp1, ctrlAIntent, 1),
574
+ valueProp2' = propBit(hostWrites, valueProp2, ctrlAIntent, 2),
575
+ valueProp3' = propBit(hostWrites, valueProp3, ctrlAIntent, 3),
576
+ valueProp4' = propBit(hostWrites, valueProp4, ctrlAIntent, 4),
577
+ requested' = if (ctrlAIntent != selected) ctrlAIntent else requested,
578
+ outcome' = if (ctrlAIntent != selected) verdict else outcome,
579
+ base' = if (ctrlAIntent != selected) selected else base,
580
+ requests' = if (ctrlAIntent != selected) requests + 1 else requests
581
+ }
582
+
583
+ // Pointer: focus the option; toggle it (recommended) or select only it
584
+ // (alternative).
585
+ action click = {
586
+ nondet index = Set(0, 1, 2, 3, 4).oneOf()
587
+ all {
588
+ hasFocus' = true,
589
+ active' = index,
590
+ selected' = landed(holds, selected, clickIntent(index)),
591
+ anchor' = if (isAlt(selectionModel)) index else anchorAfterToggle(selected, anchor, index),
592
+ selectionModel' = selectionModel,
593
+ lastOp' = "click",
594
+ controlled' = controlled,
595
+ accepting' = accepting,
596
+ valueProp0' = propBit(hostWrites, valueProp0, clickIntent(index), 0),
597
+ valueProp1' = propBit(hostWrites, valueProp1, clickIntent(index), 1),
598
+ valueProp2' = propBit(hostWrites, valueProp2, clickIntent(index), 2),
599
+ valueProp3' = propBit(hostWrites, valueProp3, clickIntent(index), 3),
600
+ valueProp4' = propBit(hostWrites, valueProp4, clickIntent(index), 4),
601
+ requested' = if (clickIntent(index) != selected) clickIntent(index) else requested,
602
+ outcome' = if (clickIntent(index) != selected) verdict else outcome,
603
+ base' = if (clickIntent(index) != selected) selected else base,
604
+ requests' = if (clickIntent(index) != selected) requests + 1 else requests
605
+ }
606
+ }
607
+
608
+ // Prop change between the two selection models; the selection is kept.
609
+ action set_selection_model = {
610
+ nondet model = Set("recommended", "alternative").oneOf()
611
+ all {
612
+ hasFocus' = hasFocus,
613
+ active' = active,
614
+ selected' = selected,
615
+ anchor' = anchor,
616
+ selectionModel' = model,
617
+ lastOp' = "config",
618
+ controlled' = controlled,
619
+ accepting' = accepting,
620
+ valueProp0' = valueProp0,
621
+ valueProp1' = valueProp1,
622
+ valueProp2' = valueProp2,
623
+ valueProp3' = valueProp3,
624
+ valueProp4' = valueProp4,
625
+ requested' = requested,
626
+ outcome' = outcome,
627
+ base' = base,
628
+ requests' = requests
629
+ }
630
+ }
631
+
632
+ // commit: the host writes the controlled value (a no-op while uncontrolled)
633
+ action setValue = {
634
+ nondet v = Set(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31).oneOf()
635
+ all {
636
+ hasFocus' = hasFocus,
637
+ active' = active,
638
+ selected' = if (controlled) v else selected,
639
+ anchor' = anchor,
640
+ selectionModel' = selectionModel,
641
+ lastOp' = lastOp,
642
+ controlled' = controlled,
643
+ accepting' = accepting,
644
+ valueProp0' = if (controlled) bit(v, 0) else valueProp0,
645
+ valueProp1' = if (controlled) bit(v, 1) else valueProp1,
646
+ valueProp2' = if (controlled) bit(v, 2) else valueProp2,
647
+ valueProp3' = if (controlled) bit(v, 3) else valueProp3,
648
+ valueProp4' = if (controlled) bit(v, 4) else valueProp4,
649
+ requested' = requested,
650
+ outcome' = if (controlled) "none" else outcome,
651
+ base' = base,
652
+ requests' = requests
653
+ }
654
+ }
655
+
656
+ // commit: the host flips one option of its own copy of the value
657
+ action hostToggle = {
658
+ nondet panel = Set(0, 1, 2, 3, 4).oneOf()
659
+ all {
660
+ hasFocus' = hasFocus,
661
+ active' = active,
662
+ selected' = if (controlled) flipAt(propsMask, panel) else selected,
663
+ anchor' = anchor,
664
+ selectionModel' = selectionModel,
665
+ lastOp' = lastOp,
666
+ controlled' = controlled,
667
+ accepting' = accepting,
668
+ valueProp0' = if (controlled) bit(flipAt(propsMask, panel), 0) else valueProp0,
669
+ valueProp1' = if (controlled) bit(flipAt(propsMask, panel), 1) else valueProp1,
670
+ valueProp2' = if (controlled) bit(flipAt(propsMask, panel), 2) else valueProp2,
671
+ valueProp3' = if (controlled) bit(flipAt(propsMask, panel), 3) else valueProp3,
672
+ valueProp4' = if (controlled) bit(flipAt(propsMask, panel), 4) else valueProp4,
673
+ requested' = requested,
674
+ outcome' = if (controlled) "none" else outcome,
675
+ base' = base,
676
+ requests' = requests
677
+ }
678
+ }
679
+
680
+ // configuration: the host starts owning the value, adopting the current one
681
+ action control = all {
682
+ hasFocus' = hasFocus,
683
+ active' = active,
684
+ selected' = selected,
685
+ anchor' = anchor,
686
+ selectionModel' = selectionModel,
687
+ lastOp' = lastOp,
688
+ controlled' = true,
689
+ accepting' = accepting,
690
+ valueProp0' = bit(selected, 0),
691
+ valueProp1' = bit(selected, 1),
692
+ valueProp2' = bit(selected, 2),
693
+ valueProp3' = bit(selected, 3),
694
+ valueProp4' = bit(selected, 4),
695
+ requested' = requested,
696
+ outcome' = outcome,
697
+ base' = base,
698
+ requests' = requests
699
+ }
700
+
701
+ // configuration: the controlled host answers every request with its intent
702
+ action acceptRequests = all {
703
+ hasFocus' = hasFocus,
704
+ active' = active,
705
+ selected' = selected,
706
+ anchor' = anchor,
707
+ selectionModel' = selectionModel,
708
+ lastOp' = lastOp,
709
+ controlled' = controlled,
710
+ accepting' = true,
711
+ valueProp0' = valueProp0,
712
+ valueProp1' = valueProp1,
713
+ valueProp2' = valueProp2,
714
+ valueProp3' = valueProp3,
715
+ valueProp4' = valueProp4,
716
+ requested' = requested,
717
+ outcome' = outcome,
718
+ base' = base,
719
+ requests' = requests
720
+ }
721
+
722
+ action step = any {
723
+ init, seed, focus_in, blur, arrow_down, arrow_up, home, end, space, ctrl_space,
724
+ shift_arrow_down, shift_arrow_up, ctrl_arrow_down, ctrl_arrow_up, shift_space,
725
+ ctrl_shift_home, ctrl_shift_end, ctrl_a, click, set_selection_model,
726
+ setValue, hostToggle, control, acceptRequests
727
+ }
728
+
729
+ // the mask only ever names real options
730
+ val SelectionInRange: bool = and(selected >= 0, selected < pow2(optionCount))
731
+ // the active option is always a real option
732
+ val ActiveInRange: bool = and(active >= 0, active <= lastIndex)
733
+ // aria-activedescendant is set exactly while the listbox holds focus
734
+ val ActiveDescendantTracksFocus: bool =
735
+ (ariaActiveDescendant(hasFocus, active) == noOption) == not(hasFocus)
736
+ // APG: a multi-select listbox sets aria-multiselectable=true
737
+ val AriaMultiselectableTrue: bool = ariaMultiselectable == true
738
+ // the anchor is a selected option or absent (uncontrolled)
739
+ val AnchorSelectedOrNone: bool =
740
+ or(controlled, or(anchor == noOption, and(anchor >= 0, anchor <= lastIndex, bit(selected, anchor))))
741
+ val SelectionModelKnown: bool =
742
+ or(selectionModel == "recommended", selectionModel == "alternative")
743
+ // alternative model: a plain move leaves exactly the focused option selected
744
+ val AlternativeMoveCollapses: bool =
745
+ or(controlled, not(isAlt(selectionModel)), lastOp != "move", selected == pow2(active))
746
+ // alternative model: a click selects only the clicked option
747
+ val AlternativeClickCollapses: bool =
748
+ or(controlled, not(isAlt(selectionModel)), lastOp != "click", selected == pow2(active))
749
+ // Shift+Space: everything between the anchor and the active option is selected
750
+ val RangeSelectsAnchorToActive: bool =
751
+ or(
752
+ controlled,
753
+ lastOp != "range",
754
+ and(
755
+ anchor != noOption,
756
+ orMask(selected, rangeMask(loOf(anchor, active), hiOf(anchor, active))) == selected
757
+ )
758
+ )
759
+ // Ctrl+Shift+Home/End: focus and selection reach the edge
760
+ val RangeToEdgeSelectsEdge: bool =
761
+ or(
762
+ controlled,
763
+ and(
764
+ or(lastOp != "range_home", and(active == 0, bit(selected, 0))),
765
+ or(lastOp != "range_end", and(active == lastIndex, bit(selected, lastIndex)))
766
+ )
767
+ )
768
+ // Ctrl+A: select all / unselect all
769
+ val SelectAllOutcome: bool =
770
+ or(
771
+ controlled,
772
+ and(
773
+ or(lastOp != "select_all", selected == allMask),
774
+ or(lastOp != "clear_all", selected == 0)
775
+ )
776
+ )
777
+ val LastOpKnown: bool =
778
+ or(lastOp == "none",
779
+ lastOp == "focus",
780
+ lastOp == "blur",
781
+ lastOp == "move",
782
+ lastOp == "ctrl_move",
783
+ lastOp == "toggle",
784
+ lastOp == "range",
785
+ lastOp == "range_home",
786
+ lastOp == "range_end",
787
+ lastOp == "select_all",
788
+ lastOp == "clear_all",
789
+ lastOp == "click",
790
+ lastOp == "config")
791
+ // callbacks only ever count up from zero
792
+ val RequestsNonNegative: bool = requests >= 0
793
+ // controlled: only the value prop sets the state, so no event commits
794
+ val ControlledFollowsProp: bool = or(not(controlled), selected == propsMask)
795
+ // a rejected request leaves the state as it found it
796
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), selected == base)
797
+ // an accepted request committed exactly its intent
798
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), selected == requested)
799
+ }