@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,626 @@
1
+ // APG combobox — the select-only combobox (no text input, listbox popup).
2
+ //
3
+ // Sources (W3C WAI-ARIA Authoring Practices Guide):
4
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
5
+ // - "About This Pattern", "Keyboard Interaction" (Combobox + Listbox Popup),
6
+ // "WAI-ARIA Roles, States, and Properties"
7
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
8
+ // - "Accessibility Features", "Keyboard Support" (Closed Combobox, Listbox
9
+ // Popup), "Role, Property, State, and Tabindex Attributes"
10
+ //
11
+ // Modelled behaviour
12
+ // - DOM focus never leaves the combobox element: the popup's options are
13
+ // visually focused through aria-activedescendant (`active`), never by
14
+ // moving DOM focus. A popup can only be open while the combobox has DOM
15
+ // focus; blur / Tab commit the active option and close it.
16
+ // - Navigating the options (arrows, Home/End, PageUp/PageDown, typeahead)
17
+ // never changes the value. The value is committed by Enter, Space, Tab,
18
+ // Alt+ArrowUp, blur, an option click, or an external `set_value` prop
19
+ // change. Escape and a click on the closed-again input keep the value.
20
+ // - Closed combobox: ArrowDown / Alt+ArrowDown / Enter / Space open the popup
21
+ // without moving the visual focus off the current option (the selected
22
+ // option, or the first when none is selected); ArrowUp opens on the first
23
+ // option; Home / End open on the first / last option; a printable character
24
+ // opens the popup on the first option starting with it.
25
+ // - Typeahead: a character matches the first option starting with it; the
26
+ // same character typed in succession cycles among options with that letter.
27
+ // - The ARIA attribute contract is derived: aria-expanded,
28
+ // aria-activedescendant (present iff open), aria-selected (exactly the
29
+ // active option, only while open).
30
+ //
31
+ // Request vs commit (map ticket rt1p). The widget's observable state is the pair
32
+ // (selected value, open): both are user-changeable and host-ownable, so they are
33
+ // packed into one controlled value with `stateOf` (injective for value -1..5 and
34
+ // the open bit). Every event a user sees as a change — blur, the arrow /
35
+ // Home / End / typing open keys, Enter / Space / Tab / Alt+ArrowUp, the pointer
36
+ // clicks — is a *request* carrying its intent (`requested`); it fires
37
+ // onValueChange / onOpenChange (`requests` counts the calls) when the intent
38
+ // differs from the current state, and then:
39
+ // uncontrolled the request is accepted: the state commits the intent
40
+ // controlled, holds the host ignores it ("rejected"): the state is untouched
41
+ // controlled, accepts the host answers with the same value (`accepting`): the
42
+ // state commits through the controlled value, not the event
43
+ // `valueProp` is the only thing that changes the state while `controlled`;
44
+ // `set_value` / `setOpen` / `hostToggle` are the host writing it. `control` and
45
+ // `acceptRequests` are configuration events (init is literal-only).
46
+ //
47
+ // Invariants for the model: ControlledFollowsProp, RejectedRequestUntouched,
48
+ // AcceptedRequestCommits.
49
+ //
50
+ // `outcome` is "none" | "accepted" | "rejected" for the last request; a host
51
+ // write settles it back to "none". `base` is the state when it was received.
52
+ //
53
+ // Configuration variants are zero-argument defs / nondet pools: the option
54
+ // count and PageUp/PageDown stride (`OPTION_COUNT`, `PAGE_SIZE`), the option
55
+ // first letters (`letterOf`), and the typed-character pool (`type_char`).
56
+ //
57
+ // Deliberate omissions
58
+ // - Typeahead of multi-character strings typed in quick succession (the
59
+ // timeout-based buffer); only single-character matching and same-letter
60
+ // cycling are modelled (`typed` is the last character, reset by any other
61
+ // event, standing in for the timeout).
62
+ // - Scrolling the active option into view, hover styling, the label,
63
+ // aria-controls / aria-labelledby wiring (static ids).
64
+ // - Disabled / required props, multi-select, option groups.
65
+ // - Pointer hover over an option does not move the active option.
66
+ //
67
+ // Generator notes: the generator supports no action preconditions, so every
68
+ // key action guards its assignments with `focused`; vars hold int sentinels
69
+ // (`NONE = -1`) because only str/int/bool vars are supported.
70
+ module combobox_select_only {
71
+ import aria.* from "../../shared/aria"
72
+
73
+ var open: bool // popup (listbox) displayed == aria-expanded
74
+ var active: int // aria-activedescendant option index (NONE when closed)
75
+ var value: int // committed value: option index, NONE = placeholder
76
+ var focused: bool // DOM focus is on the combobox element
77
+ var typed: int // last typeahead character, NONE when the buffer is empty
78
+ var lastEvent: str // name of the last event, for transition invariants
79
+ var valueChanged: bool // the last event changed `value`
80
+ var controlled: bool // the host owns (value, open) (`value`/`open` props present)
81
+ var accepting: bool // controlled host that answers every request with its intent
82
+ var valueProp: int // the controlled state, packed with `stateOf`
83
+ var requested: int // intent of the last request, packed
84
+ var outcome: str // "none" | "accepted" | "rejected"
85
+ var base: int // state when the last request was received, packed
86
+ var requests: int // number of requests that fired a change callback
87
+
88
+ // --- configuration ------------------------------------------------------
89
+ def NONE: int = -1
90
+ def OPTION_COUNT: int = 6
91
+ def PAGE_SIZE: int = 4
92
+ // options: 0 Apple, 1 Banana, 2 Blueberry, 3 Cherry, 4 Cranberry, 5 Durian
93
+ def letterOf(i: int): int =
94
+ if (i == 0) 0 else if (i == 1) 1 else if (i == 2) 1 else if (i == 3) 2 else if (i == 4) 2 else 3
95
+
96
+ // --- helpers ------------------------------------------------------------
97
+ def minI(a: int, b: int): int = if (a < b) a else b
98
+ def maxI(a: int, b: int): int = if (a > b) a else b
99
+ def refersToOption(i: int): bool = and(i >= 0, i < OPTION_COUNT)
100
+
101
+ // the listbox opens on the selected option, or the first when none
102
+ def openActive: int = maxI(value, 0)
103
+ def openIfFocused: bool = if (focused) true else open
104
+ // a commit happens only while the key reaches an open popup with a focused option
105
+ def commits: bool = and(focused, open)
106
+ def commitsActive: bool = and(commits, active >= 0)
107
+ def committedValue: int = if (commitsActive) active else value
108
+ def commitChanged: bool = and(commitsActive, active != value)
109
+
110
+ // the first option with letter ch among OPTION_COUNT candidates from start
111
+ // (candidates and the start rule are shared/aria.qnt: ariaCycle0, ariaTaRepeatStart0)
112
+ def candidate(start: int, k: int): int = ariaCycle0(OPTION_COUNT, start, k)
113
+ def matchFrom(start: int, ch: int): int =
114
+ if (letterOf(candidate(start, 0)) == ch) candidate(start, 0)
115
+ else if (letterOf(candidate(start, 1)) == ch) candidate(start, 1)
116
+ else if (letterOf(candidate(start, 2)) == ch) candidate(start, 2)
117
+ else if (letterOf(candidate(start, 3)) == ch) candidate(start, 3)
118
+ else if (letterOf(candidate(start, 4)) == ch) candidate(start, 4)
119
+ else if (letterOf(candidate(start, 5)) == ch) candidate(start, 5)
120
+ else NONE
121
+ // a repeated character cycles on from the option after base, a new one starts at the top
122
+ def found(ch: int, base: int): int = matchFrom(ariaTaRepeatStart0(OPTION_COUNT, base, typed, ch), ch)
123
+ def typeActive(ch: int, base: int): int =
124
+ if (found(ch, base) == NONE) base else found(ch, base)
125
+
126
+ // --- request / commit (map ticket rt1p) --------------------------------
127
+ def b2i(b: bool): int = if (b) 1 else 0
128
+ // injective for value -1..5 and the open bit: (value + 1) * 2 + open
129
+ def stateOf(o: bool, v: int): int = (v + 1) * 2 + b2i(o)
130
+ def openOf(s: int): bool = s % 2 == 1
131
+ def valueOf(s: int): int = s / 2 - 1
132
+ def nowState: int = stateOf(open, value)
133
+ // controlled host that keeps the old value: the request is rejected
134
+ def holds: bool = and(controlled, not(accepting))
135
+ // controlled host that writes the intent back through the value prop
136
+ def follows: bool = and(controlled, accepting)
137
+ def verdict: str = if (holds) "rejected" else "accepted"
138
+ def landed(h: bool, current: bool, want: bool): bool = if (h) current else want
139
+ def landedInt(h: bool, current: int, want: int): int = if (h) current else want
140
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
141
+ // the state / prop / ledger after a request for the packed `want`
142
+ def openAt(w: int): bool = landed(holds, open, openOf(w))
143
+ def valueAt(w: int): int = landedInt(holds, value, valueOf(w))
144
+ def propAt(w: int): int = propAfter(follows, valueProp, w)
145
+ def reqState(w: int): int = if (w != nowState) w else requested
146
+ def reqOutcome(w: int): str = if (w != nowState) verdict else outcome
147
+ def reqBase(w: int): int = if (w != nowState) nowState else base
148
+ def reqCount(w: int): int = if (w != nowState) requests + 1 else requests
149
+ // the packed intent of each change event
150
+ def blurW: int = stateOf(false, if (and(open, active >= 0)) active else value)
151
+ def openW: int = stateOf(openIfFocused, value)
152
+ def enterW: int = stateOf(if (focused) not(open) else open, committedValue)
153
+ def tabW: int = stateOf(if (focused) false else open, committedValue)
154
+ def escapeW: int = stateOf(if (focused) false else open, value)
155
+ def altUpW: int = stateOf(if (focused) false else open, committedValue)
156
+ def clickComboW: int = stateOf(not(open), value)
157
+ def clickOptionW(i: int): int = stateOf(false, if (open) i else value)
158
+
159
+ // --- derived ARIA attribute contract -----------------------------------
160
+ def ariaExpanded: bool = open
161
+ def ariaActivedescendant: int = if (open) active else NONE
162
+ def ariaSelected(i: int): bool = and(open, i == active)
163
+ def selectedCount: int =
164
+ (if (ariaSelected(0)) 1 else 0) + (if (ariaSelected(1)) 1 else 0) +
165
+ (if (ariaSelected(2)) 1 else 0) + (if (ariaSelected(3)) 1 else 0) +
166
+ (if (ariaSelected(4)) 1 else 0) + (if (ariaSelected(5)) 1 else 0)
167
+ def isCommitEvent(e: str): bool =
168
+ or(e == "enter", e == "space", e == "tab", e == "alt_arrow_up", e == "blur", e == "click_option", e == "set_value")
169
+
170
+ action init = all {
171
+ open' = false,
172
+ active' = NONE,
173
+ value' = NONE,
174
+ focused' = false,
175
+ typed' = NONE,
176
+ lastEvent' = "init",
177
+ valueChanged' = false,
178
+ controlled' = false,
179
+ accepting' = false,
180
+ valueProp' = stateOf(false, NONE),
181
+ requested' = stateOf(false, NONE),
182
+ outcome' = "none",
183
+ base' = stateOf(false, NONE),
184
+ requests' = 0
185
+ }
186
+
187
+ // --- focus / blur --------------------------------------------------------
188
+ action focus = all {
189
+ focused' = true,
190
+ typed' = NONE,
191
+ lastEvent' = "focus",
192
+ valueChanged' = false,
193
+ open' = open,
194
+ active' = active,
195
+ value' = value,
196
+ controlled' = controlled,
197
+ accepting' = accepting,
198
+ valueProp' = valueProp,
199
+ requested' = requested,
200
+ outcome' = outcome,
201
+ base' = base,
202
+ requests' = requests
203
+ }
204
+
205
+ // focus leaves the combobox: an open popup commits its active option
206
+ action blur = all {
207
+ focused' = false,
208
+ open' = openAt(blurW),
209
+ active' = NONE,
210
+ value' = valueAt(blurW),
211
+ typed' = NONE,
212
+ lastEvent' = "blur",
213
+ valueChanged' = valueAt(blurW) != value,
214
+ controlled' = controlled,
215
+ accepting' = accepting,
216
+ valueProp' = propAt(blurW),
217
+ requested' = reqState(blurW),
218
+ outcome' = reqOutcome(blurW),
219
+ base' = reqBase(blurW),
220
+ requests' = reqCount(blurW)
221
+ }
222
+
223
+ // --- closed + open arrow keys -------------------------------------------
224
+ action arrow_down = all {
225
+ open' = openAt(openW),
226
+ active' = if (focused) (if (open) minI(active + 1, OPTION_COUNT - 1) else openActive) else active,
227
+ typed' = NONE,
228
+ lastEvent' = "arrow_down",
229
+ valueChanged' = false,
230
+ value' = valueAt(openW),
231
+ focused' = focused,
232
+ controlled' = controlled,
233
+ accepting' = accepting,
234
+ valueProp' = propAt(openW),
235
+ requested' = reqState(openW),
236
+ outcome' = reqOutcome(openW),
237
+ base' = reqBase(openW),
238
+ requests' = reqCount(openW)
239
+ }
240
+
241
+ action alt_arrow_down = all {
242
+ open' = openAt(openW),
243
+ active' = if (focused) (if (open) active else openActive) else active,
244
+ typed' = NONE,
245
+ lastEvent' = "alt_arrow_down",
246
+ valueChanged' = false,
247
+ value' = valueAt(openW),
248
+ focused' = focused,
249
+ controlled' = controlled,
250
+ accepting' = accepting,
251
+ valueProp' = propAt(openW),
252
+ requested' = reqState(openW),
253
+ outcome' = reqOutcome(openW),
254
+ base' = reqBase(openW),
255
+ requests' = reqCount(openW)
256
+ }
257
+
258
+ action arrow_up = all {
259
+ open' = openAt(openW),
260
+ active' = if (focused) (if (open) maxI(active - 1, 0) else 0) else active,
261
+ typed' = NONE,
262
+ lastEvent' = "arrow_up",
263
+ valueChanged' = false,
264
+ value' = valueAt(openW),
265
+ focused' = focused,
266
+ controlled' = controlled,
267
+ accepting' = accepting,
268
+ valueProp' = propAt(openW),
269
+ requested' = reqState(openW),
270
+ outcome' = reqOutcome(openW),
271
+ base' = reqBase(openW),
272
+ requests' = reqCount(openW)
273
+ }
274
+
275
+ action home = all {
276
+ open' = openAt(openW),
277
+ active' = if (focused) 0 else active,
278
+ typed' = NONE,
279
+ lastEvent' = "home",
280
+ valueChanged' = false,
281
+ value' = valueAt(openW),
282
+ focused' = focused,
283
+ controlled' = controlled,
284
+ accepting' = accepting,
285
+ valueProp' = propAt(openW),
286
+ requested' = reqState(openW),
287
+ outcome' = reqOutcome(openW),
288
+ base' = reqBase(openW),
289
+ requests' = reqCount(openW)
290
+ }
291
+
292
+ action end = all {
293
+ open' = openAt(openW),
294
+ active' = if (focused) OPTION_COUNT - 1 else active,
295
+ typed' = NONE,
296
+ lastEvent' = "end",
297
+ valueChanged' = false,
298
+ value' = valueAt(openW),
299
+ focused' = focused,
300
+ controlled' = controlled,
301
+ accepting' = accepting,
302
+ valueProp' = propAt(openW),
303
+ requested' = reqState(openW),
304
+ outcome' = reqOutcome(openW),
305
+ base' = reqBase(openW),
306
+ requests' = reqCount(openW)
307
+ }
308
+
309
+ action page_up = all {
310
+ active' = if (commits) maxI(active - PAGE_SIZE, 0) else active,
311
+ typed' = NONE,
312
+ lastEvent' = "page_up",
313
+ valueChanged' = false,
314
+ open' = open,
315
+ value' = value,
316
+ focused' = focused,
317
+ controlled' = controlled,
318
+ accepting' = accepting,
319
+ valueProp' = valueProp,
320
+ requested' = requested,
321
+ outcome' = outcome,
322
+ base' = base,
323
+ requests' = requests
324
+ }
325
+
326
+ action page_down = all {
327
+ active' = if (commits) minI(active + PAGE_SIZE, OPTION_COUNT - 1) else active,
328
+ typed' = NONE,
329
+ lastEvent' = "page_down",
330
+ valueChanged' = false,
331
+ open' = open,
332
+ value' = value,
333
+ focused' = focused,
334
+ controlled' = controlled,
335
+ accepting' = accepting,
336
+ valueProp' = valueProp,
337
+ requested' = requested,
338
+ outcome' = outcome,
339
+ base' = base,
340
+ requests' = requests
341
+ }
342
+
343
+ // --- activation keys -----------------------------------------------------
344
+ // closed: open without moving; open: commit the active option and close
345
+ action enter = all {
346
+ open' = openAt(enterW),
347
+ active' = if (focused) (if (open) NONE else openActive) else active,
348
+ value' = valueAt(enterW),
349
+ typed' = NONE,
350
+ lastEvent' = "enter",
351
+ valueChanged' = valueAt(enterW) != value,
352
+ focused' = focused,
353
+ controlled' = controlled,
354
+ accepting' = accepting,
355
+ valueProp' = propAt(enterW),
356
+ requested' = reqState(enterW),
357
+ outcome' = reqOutcome(enterW),
358
+ base' = reqBase(enterW),
359
+ requests' = reqCount(enterW)
360
+ }
361
+
362
+ action space = all {
363
+ open' = openAt(enterW),
364
+ active' = if (focused) (if (open) NONE else openActive) else active,
365
+ value' = valueAt(enterW),
366
+ typed' = NONE,
367
+ lastEvent' = "space",
368
+ valueChanged' = valueAt(enterW) != value,
369
+ focused' = focused,
370
+ controlled' = controlled,
371
+ accepting' = accepting,
372
+ valueProp' = propAt(enterW),
373
+ requested' = reqState(enterW),
374
+ outcome' = reqOutcome(enterW),
375
+ base' = reqBase(enterW),
376
+ requests' = reqCount(enterW)
377
+ }
378
+
379
+ // Tab commits an open popup and moves DOM focus to the next page element
380
+ action tab = all {
381
+ open' = openAt(tabW),
382
+ active' = if (focused) NONE else active,
383
+ value' = valueAt(tabW),
384
+ focused' = false,
385
+ typed' = NONE,
386
+ lastEvent' = "tab",
387
+ valueChanged' = valueAt(tabW) != value,
388
+ controlled' = controlled,
389
+ accepting' = accepting,
390
+ valueProp' = propAt(tabW),
391
+ requested' = reqState(tabW),
392
+ outcome' = reqOutcome(tabW),
393
+ base' = reqBase(tabW),
394
+ requests' = reqCount(tabW)
395
+ }
396
+
397
+ // Escape closes the popup and keeps the previous value
398
+ action escape = all {
399
+ open' = openAt(escapeW),
400
+ active' = if (focused) NONE else active,
401
+ typed' = NONE,
402
+ lastEvent' = "escape",
403
+ valueChanged' = false,
404
+ value' = valueAt(escapeW),
405
+ focused' = focused,
406
+ controlled' = controlled,
407
+ accepting' = accepting,
408
+ valueProp' = propAt(escapeW),
409
+ requested' = reqState(escapeW),
410
+ outcome' = reqOutcome(escapeW),
411
+ base' = reqBase(escapeW),
412
+ requests' = reqCount(escapeW)
413
+ }
414
+
415
+ // Alt+ArrowUp on an open popup commits the active option and closes
416
+ action alt_arrow_up = all {
417
+ open' = openAt(altUpW),
418
+ active' = if (focused) NONE else active,
419
+ value' = valueAt(altUpW),
420
+ typed' = NONE,
421
+ lastEvent' = "alt_arrow_up",
422
+ valueChanged' = valueAt(altUpW) != value,
423
+ focused' = focused,
424
+ controlled' = controlled,
425
+ accepting' = accepting,
426
+ valueProp' = propAt(altUpW),
427
+ requested' = reqState(altUpW),
428
+ outcome' = reqOutcome(altUpW),
429
+ base' = reqBase(altUpW),
430
+ requests' = reqCount(altUpW)
431
+ }
432
+
433
+ // --- typeahead -----------------------------------------------------------
434
+ action type_char = {
435
+ nondet ch = Set(0, 1, 2, 3, 9).oneOf()
436
+ all {
437
+ open' = openAt(openW),
438
+ active' = if (focused) typeActive(ch, if (open) active else openActive) else active,
439
+ typed' = if (focused) ch else typed,
440
+ lastEvent' = "type_char",
441
+ valueChanged' = false,
442
+ value' = valueAt(openW),
443
+ focused' = focused,
444
+ controlled' = controlled,
445
+ accepting' = accepting,
446
+ valueProp' = propAt(openW),
447
+ requested' = reqState(openW),
448
+ outcome' = reqOutcome(openW),
449
+ base' = reqBase(openW),
450
+ requests' = reqCount(openW)
451
+ }
452
+ }
453
+
454
+ // --- pointer -------------------------------------------------------------
455
+ // clicking the input focuses it and toggles the popup; the value is kept
456
+ action click_combobox = all {
457
+ focused' = true,
458
+ open' = openAt(clickComboW),
459
+ active' = if (open) NONE else openActive,
460
+ typed' = NONE,
461
+ lastEvent' = "click_combobox",
462
+ valueChanged' = false,
463
+ value' = valueAt(clickComboW),
464
+ controlled' = controlled,
465
+ accepting' = accepting,
466
+ valueProp' = propAt(clickComboW),
467
+ requested' = reqState(clickComboW),
468
+ outcome' = reqOutcome(clickComboW),
469
+ base' = reqBase(clickComboW),
470
+ requests' = reqCount(clickComboW)
471
+ }
472
+
473
+ // clicking an option of the open popup commits it and returns visual focus
474
+ action click_option = {
475
+ nondet i = Set(0, 1, 2, 3, 4, 5).oneOf()
476
+ all {
477
+ open' = openAt(clickOptionW(i)),
478
+ active' = NONE,
479
+ value' = valueAt(clickOptionW(i)),
480
+ focused' = if (open) true else focused,
481
+ typed' = NONE,
482
+ lastEvent' = "click_option",
483
+ valueChanged' = valueAt(clickOptionW(i)) != value,
484
+ controlled' = controlled,
485
+ accepting' = accepting,
486
+ valueProp' = propAt(clickOptionW(i)),
487
+ requested' = reqState(clickOptionW(i)),
488
+ outcome' = reqOutcome(clickOptionW(i)),
489
+ base' = reqBase(clickOptionW(i)),
490
+ requests' = reqCount(clickOptionW(i))
491
+ }
492
+ }
493
+
494
+ // --- prop change ---------------------------------------------------------
495
+ // the external value replaces the committed one; popup state is untouched
496
+ action set_value = {
497
+ nondet i = Set(0, 1, 2, 3, 4, 5).oneOf()
498
+ all {
499
+ value' = i,
500
+ typed' = NONE,
501
+ lastEvent' = "set_value",
502
+ valueChanged' = i != value,
503
+ open' = open,
504
+ active' = active,
505
+ focused' = focused,
506
+ controlled' = controlled,
507
+ accepting' = accepting,
508
+ valueProp' = if (controlled) stateOf(open, i) else valueProp,
509
+ requested' = requested,
510
+ outcome' = "none",
511
+ base' = base,
512
+ requests' = requests
513
+ }
514
+ }
515
+
516
+ // commit: the host writes the open state (a no-op while uncontrolled)
517
+ action setOpen = {
518
+ nondet o = Set(true, false).oneOf()
519
+ all {
520
+ open' = if (controlled) o else open,
521
+ active' = if (controlled) active else active,
522
+ value' = value,
523
+ focused' = focused,
524
+ typed' = NONE,
525
+ lastEvent' = "setOpen",
526
+ valueChanged' = false,
527
+ controlled' = controlled,
528
+ accepting' = accepting,
529
+ valueProp' = if (controlled) stateOf(o, value) else valueProp,
530
+ requested' = requested,
531
+ outcome' = if (controlled) "none" else outcome,
532
+ base' = base,
533
+ requests' = requests
534
+ }
535
+ }
536
+
537
+ // commit: the host flips the open state of its own copy (an outside button)
538
+ action hostToggle = all {
539
+ open' = if (controlled) not(open) else open,
540
+ active' = active,
541
+ value' = value,
542
+ focused' = focused,
543
+ typed' = NONE,
544
+ lastEvent' = "hostToggle",
545
+ valueChanged' = false,
546
+ controlled' = controlled,
547
+ accepting' = accepting,
548
+ valueProp' = if (controlled) stateOf(not(open), value) else valueProp,
549
+ requested' = requested,
550
+ outcome' = if (controlled) "none" else outcome,
551
+ base' = base,
552
+ requests' = requests
553
+ }
554
+
555
+ // configuration: the host starts owning (value, open), adopting the current
556
+ action control = all {
557
+ open' = open,
558
+ active' = active,
559
+ value' = value,
560
+ focused' = focused,
561
+ typed' = typed,
562
+ lastEvent' = lastEvent,
563
+ valueChanged' = valueChanged,
564
+ controlled' = true,
565
+ accepting' = accepting,
566
+ valueProp' = nowState,
567
+ requested' = requested,
568
+ outcome' = outcome,
569
+ base' = base,
570
+ requests' = requests
571
+ }
572
+
573
+ // configuration: the controlled host answers every request with its intent
574
+ action acceptRequests = all {
575
+ open' = open,
576
+ active' = active,
577
+ value' = value,
578
+ focused' = focused,
579
+ typed' = typed,
580
+ lastEvent' = lastEvent,
581
+ valueChanged' = valueChanged,
582
+ controlled' = controlled,
583
+ accepting' = true,
584
+ valueProp' = valueProp,
585
+ requested' = requested,
586
+ outcome' = outcome,
587
+ base' = base,
588
+ requests' = requests
589
+ }
590
+
591
+ action step = any {
592
+ init, focus, blur, arrow_down, alt_arrow_down, arrow_up, home, end,
593
+ page_up, page_down, enter, space, tab, escape, alt_arrow_up, type_char,
594
+ click_combobox, click_option, set_value, setOpen, hostToggle, control,
595
+ acceptRequests
596
+ }
597
+
598
+ // --- invariants (APG guarantees) ----------------------------------------
599
+ // aria-activedescendant refers to a real option iff the popup is open; a
600
+ // controlled host may keep the popup shut while the visual focus is moved,
601
+ // so this holds for the uncontrolled path only
602
+ val ActiveDescendantIffOpen: bool = or(controlled, refersToOption(active) == open)
603
+ // the rendered attributes agree with the state: no stale activedescendant,
604
+ // exactly one aria-selected option while open and none while closed
605
+ val AriaContractConsistent: bool = or(controlled,
606
+ and(ariaActivedescendant == active, selectedCount == (if (ariaExpanded) 1 else 0)))
607
+ val ActiveInRange: bool = and(active >= NONE, active < OPTION_COUNT)
608
+ val ValueInRange: bool = and(value >= NONE, value < OPTION_COUNT)
609
+ // DOM focus stays on the combobox while its popup is open (uncontrolled: a
610
+ // controlled host may reject the close and leave focus elsewhere)
611
+ val DomFocusStaysOnCombobox: bool = or(controlled, or(not(open), focused))
612
+ // Escape never changes the value
613
+ val EscapeKeepsValue: bool = or(not(lastEvent == "escape"), not(valueChanged))
614
+ // the value changes only on a commit event (never by navigating)
615
+ val CommitOnlyViaCommitEvents: bool = or(not(valueChanged), isCommitEvent(lastEvent))
616
+ // the typeahead buffer lives only between consecutive typed characters
617
+ val TypeaheadBufferIsTransient: bool = or(typed == NONE, lastEvent == "type_char")
618
+ // callbacks only ever count up from zero
619
+ val RequestsNonNegative: bool = requests >= 0
620
+ // controlled: only the value prop sets the state, so no event commits
621
+ val ControlledFollowsProp: bool = or(not(controlled), nowState == valueProp)
622
+ // a rejected request leaves the state as it found it
623
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), nowState == base)
624
+ // an accepted request committed exactly its intent
625
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), nowState == requested)
626
+ }