@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,764 @@
1
+ // APG combobox — the editable combobox with a listbox popup and the three
2
+ // autocomplete behaviours: aria-autocomplete none / list / both.
3
+ //
4
+ // Sources (W3C WAI-ARIA Authoring Practices Guide):
5
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
6
+ // - "About This Pattern" (the four forms of autocomplete), "Keyboard
7
+ // Interaction" (Combobox + Listbox Popup), "WAI-ARIA Roles, States, and
8
+ // Properties"
9
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-none/
10
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/
11
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/
12
+ // - "Keyboard Support" (Textbox, Listbox Popup), "Role, Property, State, and
13
+ // Tabindex Attributes" (Textbox, Listbox Popup, Button)
14
+ //
15
+ // Modelled behaviour
16
+ // - DOM focus stays on the input (`focused`); the popup's visual focus is
17
+ // aria-activedescendant (`active`). A popup can only be open while the input
18
+ // has DOM focus; active != NONE implies the popup is open and the referenced
19
+ // option is currently visible. active == NONE with the popup open means
20
+ // visual focus is on the textbox itself.
21
+ // - Autocomplete mode (`mode`, aria-autocomplete): 0 none = the suggestion list
22
+ // is the same whatever is typed; 1 list = suggestions are filtered by the
23
+ // typed text, nothing is selected automatically (manual selection);
24
+ // 2 both = like list, plus the first match is selected automatically after an
25
+ // edit and its completion string is shown inline (`inlineCompletion`).
26
+ // - Typing / Backspace edit the text; the popup opens iff there are
27
+ // suggestions (and, for filtered modes, text is non-empty) and closes when
28
+ // the filter has no match. Editing returns visual focus to the textbox
29
+ // (active = NONE) except in "both", which re-selects the first match.
30
+ // - ArrowDown / ArrowUp open the popup (first / last visible option) or move
31
+ // the visual focus with wrap-around; with an auto-selected suggestion,
32
+ // ArrowDown goes to the following one. Alt+ArrowDown opens without moving
33
+ // focus; Alt+ArrowUp closes. Left / Right / Home / End / Delete return the
34
+ // visual focus to the textbox without closing the popup.
35
+ // - Enter commits the visually focused option (`picked`) and closes; without a
36
+ // focused option it just closes. Escape closes an open popup keeping the
37
+ // value (inline completion dropped); with the popup closed it clears the
38
+ // textbox, except in mode none (whose APG example has no such Escape).
39
+ // Blur / Tab close the popup; in "both" the auto-selected suggestion is
40
+ // accepted as the value, in none / list the typed text stays.
41
+ // - Pointer: click on the input / open button toggles the popup and keeps DOM
42
+ // focus on the input; clicking an option commits it.
43
+ // - Prop changes: `set_autocomplete` swaps the mode (popup closes), `set_value`
44
+ // replaces the committed suggestion from outside (popup closes).
45
+ // - The ARIA contract is derived: aria-autocomplete, aria-expanded,
46
+ // aria-activedescendant (only meaningful while open), aria-selected (exactly
47
+ // the active option).
48
+ //
49
+ // Text model: the typed text is a node of a tiny prefix trie over the corpus
50
+ // 0 Alabama, 1 Alaska, 2 Arizona, 3 Colorado, 4 Connecticut, 5 Delaware
51
+ // (node 0 "", 1 "A", 2 "Al", 3 "Ar", 4 "C", 5 "Co", 6 "Col", 7 "D", 8 any
52
+ // non-matching text), typed with the character pool A l r C o D Z. A committed
53
+ // suggestion is `picked` (the textbox then shows its full name); typing after
54
+ // a commit appends to a full name, which matches nothing (node 8).
55
+ //
56
+ // Deliberate omissions
57
+ // - Cursor position / text selection and the standard text-editing keys
58
+ // (Left/Right/Home/End/Delete are modelled only for their effect on visual
59
+ // focus); the textual content of the inline completion.
60
+ // - Autocomplete none's `Escape` on a closed popup is a no-op, as in the APG
61
+ // example; the pattern's other optional Escape / Alt behaviours are chosen,
62
+ // not parameterised.
63
+ // - Grid / tree / dialog popups (see combobox-grid / -tree / -dialog), the
64
+ // Open button's own focus (it is out of the Tab sequence, tabindex=-1),
65
+ // asynchronous suggestions, scrolling the active option into view, hover,
66
+ // multi-select and `aria-controls` / label wiring.
67
+ //
68
+ // Generator notes: no action preconditions (key actions guard their
69
+ // assignments with `focused`), only str/int/bool vars (int sentinels, `NONE`),
70
+ // and a single nondet per action.
71
+ module combobox_autocomplete {
72
+ var mode: int // aria-autocomplete: 0 none, 1 list, 2 both
73
+ var open: bool // popup displayed == aria-expanded
74
+ var text: int // typed text (prefix-trie node)
75
+ var picked: int // committed suggestion shown in the textbox, NONE = typed text
76
+ var active: int // aria-activedescendant option index, NONE = visual focus on textbox
77
+ var focused: bool // DOM focus is on the input
78
+ var lastEvent: str // name of the last event, for transition invariants
79
+ var valueChanged: bool // the last event changed the committed text / suggestion
80
+ var wasOpen: bool // the popup was open before the last event
81
+ var controlled: bool // the host owns the picked suggestion (`value` prop present)
82
+ var accepting: bool // controlled host that answers every request with its intent
83
+ var valueProp: int // the controlled picked suggestion
84
+ var requested: int // intent of the last request
85
+ var outcome: str // "none" | "accepted" | "rejected"
86
+ var base: int // picked when the last request was received
87
+ var requests: int // number of requests that fired a change callback
88
+
89
+ // --- configuration ------------------------------------------------------
90
+ def NONE: int = -1
91
+ def OPTION_COUNT: int = 6
92
+
93
+ // --- helpers ------------------------------------------------------------
94
+ def cyc(from: int, k: int): int = (from + k) % OPTION_COUNT
95
+ def cycBack(from: int, k: int): int = (from + OPTION_COUNT - k) % OPTION_COUNT
96
+
97
+ // corpus prefix trie
98
+ def matches(i: int, t: int): bool =
99
+ if (t == 0) true
100
+ else if (t == 1) i <= 2
101
+ else if (t == 2) i <= 1
102
+ else if (t == 3) i == 2
103
+ else if (t == 4) or(i == 3, i == 4)
104
+ else if (t == 5) or(i == 3, i == 4)
105
+ else if (t == 6) i == 3
106
+ else if (t == 7) i == 5
107
+ else false
108
+ // characters: 0 A, 1 l, 2 r, 3 C, 4 o, 5 D, 6 Z
109
+ def typeNext(t: int, c: int): int =
110
+ if (t == 0) (if (c == 0) 1 else if (c == 3) 4 else if (c == 5) 7 else 8)
111
+ else if (t == 1) (if (c == 1) 2 else if (c == 2) 3 else 8)
112
+ else if (t == 4) (if (c == 4) 5 else 8)
113
+ else if (t == 5) (if (c == 1) 6 else 8)
114
+ else 8
115
+ def parentNode(t: int): int =
116
+ if (t == 2) 1 else if (t == 3) 1 else if (t == 5) 4 else if (t == 6) 5 else 0
117
+ def firstLetterNode(i: int): int = if (i <= 2) 1 else if (i <= 4) 4 else 7
118
+
119
+ // visibility of an option under (mode, picked, text)
120
+ def matchesFor(i: int, m: int, t: int): bool = if (m == 0) true else matches(i, t)
121
+ def visFor(i: int, m: int, p: int, t: int): bool =
122
+ if (and(p >= 0, m != 0)) i == p else matchesFor(i, m, t)
123
+ def vis(i: int): bool = visFor(i, mode, picked, text)
124
+ def count(m: int, p: int, t: int): int =
125
+ (if (visFor(0, m, p, t)) 1 else 0) + (if (visFor(1, m, p, t)) 1 else 0) +
126
+ (if (visFor(2, m, p, t)) 1 else 0) + (if (visFor(3, m, p, t)) 1 else 0) +
127
+ (if (visFor(4, m, p, t)) 1 else 0) + (if (visFor(5, m, p, t)) 1 else 0)
128
+ def firstFor(m: int, p: int, t: int): int =
129
+ if (visFor(0, m, p, t)) 0 else if (visFor(1, m, p, t)) 1
130
+ else if (visFor(2, m, p, t)) 2 else if (visFor(3, m, p, t)) 3
131
+ else if (visFor(4, m, p, t)) 4 else if (visFor(5, m, p, t)) 5 else NONE
132
+ def lastFor(m: int, p: int, t: int): int =
133
+ if (visFor(5, m, p, t)) 5 else if (visFor(4, m, p, t)) 4
134
+ else if (visFor(3, m, p, t)) 3 else if (visFor(2, m, p, t)) 2
135
+ else if (visFor(1, m, p, t)) 1 else if (visFor(0, m, p, t)) 0 else NONE
136
+ def visCount: int = count(mode, picked, text)
137
+ def firstVis: int = firstFor(mode, picked, text)
138
+ def lastVis: int = lastFor(mode, picked, text)
139
+ def nextVis(from: int): int =
140
+ if (vis(cyc(from, 1))) cyc(from, 1) else if (vis(cyc(from, 2))) cyc(from, 2)
141
+ else if (vis(cyc(from, 3))) cyc(from, 3) else if (vis(cyc(from, 4))) cyc(from, 4)
142
+ else if (vis(cyc(from, 5))) cyc(from, 5) else if (vis(cyc(from, 6))) cyc(from, 6)
143
+ else NONE
144
+ def prevVis(from: int): int =
145
+ if (vis(cycBack(from, 1))) cycBack(from, 1) else if (vis(cycBack(from, 2))) cycBack(from, 2)
146
+ else if (vis(cycBack(from, 3))) cycBack(from, 3) else if (vis(cycBack(from, 4))) cycBack(from, 4)
147
+ else if (vis(cycBack(from, 5))) cycBack(from, 5) else if (vis(cycBack(from, 6))) cycBack(from, 6)
148
+ else NONE
149
+
150
+ // after an edit the textbox holds typed text (picked == NONE)
151
+ def editBase: int = if (picked >= 0) 8 else text
152
+ def typedText(c: int): int = typeNext(editBase, c)
153
+ def backText: int = if (picked >= 0) firstLetterNode(picked) else parentNode(text)
154
+ def edits: bool = or(text != 0, picked >= 0)
155
+ def editOpen(m: int, t: int): bool =
156
+ and(count(m, NONE, t) > 0, or(m == 0, t != 0))
157
+ // list / none return visual focus to the textbox; both re-selects the first match
158
+ def editActive(m: int, t: int): int =
159
+ if (and(editOpen(m, t), m == 2)) firstFor(m, NONE, t) else NONE
160
+
161
+ def canOpen: bool = visCount > 0
162
+ def downActive: int =
163
+ if (open) (if (active >= 0) nextVis(active) else firstVis)
164
+ else (if (canOpen) firstVis else NONE)
165
+ def upActive: int =
166
+ if (open) (if (active >= 0) prevVis(active) else lastVis)
167
+ else (if (canOpen) lastVis else NONE)
168
+ // Enter accepts the visually focused option
169
+ def accepts: bool = and(focused, active >= 0)
170
+ // leaving the input accepts the auto-selected suggestion in mode both only
171
+ def blurAccepts: bool = and(mode == 2, active >= 0)
172
+ def escapeClears: bool = and(and(focused, not(open)), and(mode != 0, edits))
173
+
174
+ // --- derived ARIA attribute contract -----------------------------------
175
+ def ariaAutocomplete: str = if (mode == 0) "none" else if (mode == 1) "list" else "both"
176
+ def ariaExpanded: bool = open
177
+ def ariaActivedescendant: int = if (open) active else NONE
178
+ def ariaSelected(i: int): bool = and(open, i == active)
179
+ def inlineCompletion: bool = and(mode == 2, active >= 0)
180
+ def selectedCount: int =
181
+ (if (ariaSelected(0)) 1 else 0) + (if (ariaSelected(1)) 1 else 0) +
182
+ (if (ariaSelected(2)) 1 else 0) + (if (ariaSelected(3)) 1 else 0) +
183
+ (if (ariaSelected(4)) 1 else 0) + (if (ariaSelected(5)) 1 else 0)
184
+ def isEdit(e: str): bool = or(e == "type_char", e == "backspace")
185
+ def isNavigation(e: str): bool =
186
+ or(e == "arrow_down", e == "arrow_up", e == "alt_arrow_down", e == "alt_arrow_up",
187
+ e == "arrow_left", e == "arrow_right", e == "home", e == "end", e == "delete")
188
+ def mayChangeValue(e: str): bool =
189
+ or(e == "enter", e == "tab", e == "blur", e == "click_option", e == "set_value",
190
+ e == "type_char", e == "backspace", e == "escape")
191
+
192
+ // --- request / commit (map ticket rt1p) --------------------------------
193
+ // controlled host that keeps the old value: the request is rejected
194
+ def holds: bool = and(controlled, not(accepting))
195
+ // controlled host that writes the intent back through the value prop
196
+ def follows: bool = and(controlled, accepting)
197
+ def verdict: str = if (holds) "rejected" else "accepted"
198
+ def landedInt(h: bool, current: int, want: int): int = if (h) current else want
199
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
200
+
201
+ action init = all {
202
+ mode' = 1,
203
+ open' = false,
204
+ text' = 0,
205
+ picked' = NONE,
206
+ active' = NONE,
207
+ focused' = false,
208
+ lastEvent' = "init",
209
+ valueChanged' = false,
210
+ wasOpen' = false,
211
+ controlled' = false,
212
+ accepting' = false,
213
+ valueProp' = NONE,
214
+ requested' = NONE,
215
+ outcome' = "none",
216
+ base' = NONE,
217
+ requests' = 0
218
+ }
219
+
220
+ // --- focus / blur --------------------------------------------------------
221
+ action focus = all {
222
+ focused' = true,
223
+ lastEvent' = "focus",
224
+ valueChanged' = false,
225
+ wasOpen' = open,
226
+ mode' = mode,
227
+ open' = open,
228
+ text' = text,
229
+ picked' = landedInt(holds, picked, picked),
230
+ controlled' = controlled,
231
+ accepting' = accepting,
232
+ valueProp' = propAfter(follows, valueProp, picked),
233
+ requested' = if ((picked) != picked) picked else requested,
234
+ outcome' = if ((picked) != picked) verdict else outcome,
235
+ base' = if ((picked) != picked) picked else base,
236
+ requests' = if ((picked) != picked) requests + 1 else requests,
237
+ active' = active
238
+ }
239
+
240
+ // focus leaves the input by pointer
241
+ action blur = all {
242
+ open' = false,
243
+ active' = NONE,
244
+ picked' = landedInt(holds, picked, if (blurAccepts) active else picked),
245
+ controlled' = controlled,
246
+ accepting' = accepting,
247
+ valueProp' = propAfter(follows, valueProp, if (blurAccepts) active else picked),
248
+ requested' = if ((if (blurAccepts) active else picked) != picked) if (blurAccepts) active else picked else requested,
249
+ outcome' = if ((if (blurAccepts) active else picked) != picked) verdict else outcome,
250
+ base' = if ((if (blurAccepts) active else picked) != picked) picked else base,
251
+ requests' = if ((if (blurAccepts) active else picked) != picked) requests + 1 else requests,
252
+ focused' = false,
253
+ lastEvent' = "blur",
254
+ valueChanged' = and(blurAccepts, picked != active),
255
+ wasOpen' = open,
256
+ mode' = mode,
257
+ text' = text
258
+ }
259
+
260
+ // Tab: same as blur; the popup is out of the Tab sequence
261
+ action tab = all {
262
+ open' = if (focused) false else open,
263
+ active' = if (focused) NONE else active,
264
+ picked' = landedInt(holds, picked, if (and(focused, blurAccepts)) active else picked),
265
+ controlled' = controlled,
266
+ accepting' = accepting,
267
+ valueProp' = propAfter(follows, valueProp, if (and(focused, blurAccepts)) active else picked),
268
+ requested' = if ((if (and(focused, blurAccepts)) active else picked) != picked) if (and(focused, blurAccepts)) active else picked else requested,
269
+ outcome' = if ((if (and(focused, blurAccepts)) active else picked) != picked) verdict else outcome,
270
+ base' = if ((if (and(focused, blurAccepts)) active else picked) != picked) picked else base,
271
+ requests' = if ((if (and(focused, blurAccepts)) active else picked) != picked) requests + 1 else requests,
272
+ focused' = false,
273
+ lastEvent' = "tab",
274
+ valueChanged' = and(and(focused, blurAccepts), picked != active),
275
+ wasOpen' = open,
276
+ mode' = mode,
277
+ text' = text
278
+ }
279
+
280
+ // --- text editing ---------------------------------------------------------
281
+ action type_char = {
282
+ nondet ch = Set(0, 1, 2, 3, 4, 5, 6).oneOf()
283
+ all {
284
+ text' = if (focused) typedText(ch) else text,
285
+ picked' = landedInt(holds, picked, if (focused) NONE else picked),
286
+ controlled' = controlled,
287
+ accepting' = accepting,
288
+ valueProp' = propAfter(follows, valueProp, if (focused) NONE else picked),
289
+ requested' = if ((if (focused) NONE else picked) != picked) if (focused) NONE else picked else requested,
290
+ outcome' = if ((if (focused) NONE else picked) != picked) verdict else outcome,
291
+ base' = if ((if (focused) NONE else picked) != picked) picked else base,
292
+ requests' = if ((if (focused) NONE else picked) != picked) requests + 1 else requests,
293
+ open' = if (focused) editOpen(mode, typedText(ch)) else open,
294
+ active' = if (focused) editActive(mode, typedText(ch)) else active,
295
+ lastEvent' = "type_char",
296
+ valueChanged' = and(focused, or(typedText(ch) != text, picked >= 0)),
297
+ wasOpen' = open,
298
+ mode' = mode,
299
+ focused' = focused
300
+ }
301
+ }
302
+
303
+ // with nothing to delete only the visual focus returns to the textbox
304
+ action backspace = all {
305
+ text' = if (and(focused, edits)) backText else text,
306
+ picked' = landedInt(holds, picked, if (focused) NONE else picked),
307
+ controlled' = controlled,
308
+ accepting' = accepting,
309
+ valueProp' = propAfter(follows, valueProp, if (focused) NONE else picked),
310
+ requested' = if ((if (focused) NONE else picked) != picked) if (focused) NONE else picked else requested,
311
+ outcome' = if ((if (focused) NONE else picked) != picked) verdict else outcome,
312
+ base' = if ((if (focused) NONE else picked) != picked) picked else base,
313
+ requests' = if ((if (focused) NONE else picked) != picked) requests + 1 else requests,
314
+ open' = if (and(focused, edits)) editOpen(mode, backText) else open,
315
+ active' = if (focused) (if (edits) editActive(mode, backText) else NONE) else active,
316
+ lastEvent' = "backspace",
317
+ valueChanged' = and(and(focused, edits), or(backText != text, picked >= 0)),
318
+ wasOpen' = open,
319
+ mode' = mode,
320
+ focused' = focused
321
+ }
322
+
323
+ // Delete drops the selected suggestion / inline completion; the popup stays
324
+ action delete = all {
325
+ active' = if (focused) NONE else active,
326
+ lastEvent' = "delete",
327
+ valueChanged' = false,
328
+ wasOpen' = open,
329
+ mode' = mode,
330
+ open' = open,
331
+ text' = text,
332
+ picked' = landedInt(holds, picked, picked),
333
+ controlled' = controlled,
334
+ accepting' = accepting,
335
+ valueProp' = propAfter(follows, valueProp, picked),
336
+ requested' = if ((picked) != picked) picked else requested,
337
+ outcome' = if ((picked) != picked) verdict else outcome,
338
+ base' = if ((picked) != picked) picked else base,
339
+ requests' = if ((picked) != picked) requests + 1 else requests,
340
+ focused' = focused
341
+ }
342
+
343
+ // --- navigation keys --------------------------------------------------------
344
+ action arrow_down = all {
345
+ open' = if (focused) or(open, canOpen) else open,
346
+ active' = if (focused) downActive else active,
347
+ lastEvent' = "arrow_down",
348
+ valueChanged' = false,
349
+ wasOpen' = open,
350
+ mode' = mode,
351
+ text' = text,
352
+ picked' = landedInt(holds, picked, picked),
353
+ controlled' = controlled,
354
+ accepting' = accepting,
355
+ valueProp' = propAfter(follows, valueProp, picked),
356
+ requested' = if ((picked) != picked) picked else requested,
357
+ outcome' = if ((picked) != picked) verdict else outcome,
358
+ base' = if ((picked) != picked) picked else base,
359
+ requests' = if ((picked) != picked) requests + 1 else requests,
360
+ focused' = focused
361
+ }
362
+
363
+ action arrow_up = all {
364
+ open' = if (focused) or(open, canOpen) else open,
365
+ active' = if (focused) upActive else active,
366
+ lastEvent' = "arrow_up",
367
+ valueChanged' = false,
368
+ wasOpen' = open,
369
+ mode' = mode,
370
+ text' = text,
371
+ picked' = landedInt(holds, picked, picked),
372
+ controlled' = controlled,
373
+ accepting' = accepting,
374
+ valueProp' = propAfter(follows, valueProp, picked),
375
+ requested' = if ((picked) != picked) picked else requested,
376
+ outcome' = if ((picked) != picked) verdict else outcome,
377
+ base' = if ((picked) != picked) picked else base,
378
+ requests' = if ((picked) != picked) requests + 1 else requests,
379
+ focused' = focused
380
+ }
381
+
382
+ // opens without moving the visual focus
383
+ action alt_arrow_down = all {
384
+ open' = if (focused) or(open, canOpen) else open,
385
+ lastEvent' = "alt_arrow_down",
386
+ valueChanged' = false,
387
+ wasOpen' = open,
388
+ mode' = mode,
389
+ text' = text,
390
+ picked' = landedInt(holds, picked, picked),
391
+ controlled' = controlled,
392
+ accepting' = accepting,
393
+ valueProp' = propAfter(follows, valueProp, picked),
394
+ requested' = if ((picked) != picked) picked else requested,
395
+ outcome' = if ((picked) != picked) verdict else outcome,
396
+ base' = if ((picked) != picked) picked else base,
397
+ requests' = if ((picked) != picked) requests + 1 else requests,
398
+ active' = active,
399
+ focused' = focused
400
+ }
401
+
402
+ // closes the popup; visual focus returns to the textbox
403
+ action alt_arrow_up = all {
404
+ open' = if (focused) false else open,
405
+ active' = if (focused) NONE else active,
406
+ lastEvent' = "alt_arrow_up",
407
+ valueChanged' = false,
408
+ wasOpen' = open,
409
+ mode' = mode,
410
+ text' = text,
411
+ picked' = landedInt(holds, picked, picked),
412
+ controlled' = controlled,
413
+ accepting' = accepting,
414
+ valueProp' = propAfter(follows, valueProp, picked),
415
+ requested' = if ((picked) != picked) picked else requested,
416
+ outcome' = if ((picked) != picked) verdict else outcome,
417
+ base' = if ((picked) != picked) picked else base,
418
+ requests' = if ((picked) != picked) requests + 1 else requests,
419
+ focused' = focused
420
+ }
421
+
422
+ // Left / Right / Home / End: visual focus back to the textbox, popup stays open
423
+ action arrow_left = all {
424
+ active' = if (focused) NONE else active,
425
+ lastEvent' = "arrow_left",
426
+ valueChanged' = false,
427
+ wasOpen' = open,
428
+ mode' = mode,
429
+ open' = open,
430
+ text' = text,
431
+ picked' = landedInt(holds, picked, picked),
432
+ controlled' = controlled,
433
+ accepting' = accepting,
434
+ valueProp' = propAfter(follows, valueProp, picked),
435
+ requested' = if ((picked) != picked) picked else requested,
436
+ outcome' = if ((picked) != picked) verdict else outcome,
437
+ base' = if ((picked) != picked) picked else base,
438
+ requests' = if ((picked) != picked) requests + 1 else requests,
439
+ focused' = focused
440
+ }
441
+
442
+ action arrow_right = all {
443
+ active' = if (focused) NONE else active,
444
+ lastEvent' = "arrow_right",
445
+ valueChanged' = false,
446
+ wasOpen' = open,
447
+ mode' = mode,
448
+ open' = open,
449
+ text' = text,
450
+ picked' = landedInt(holds, picked, picked),
451
+ controlled' = controlled,
452
+ accepting' = accepting,
453
+ valueProp' = propAfter(follows, valueProp, picked),
454
+ requested' = if ((picked) != picked) picked else requested,
455
+ outcome' = if ((picked) != picked) verdict else outcome,
456
+ base' = if ((picked) != picked) picked else base,
457
+ requests' = if ((picked) != picked) requests + 1 else requests,
458
+ focused' = focused
459
+ }
460
+
461
+ action home = all {
462
+ active' = if (focused) NONE else active,
463
+ lastEvent' = "home",
464
+ valueChanged' = false,
465
+ wasOpen' = open,
466
+ mode' = mode,
467
+ open' = open,
468
+ text' = text,
469
+ picked' = landedInt(holds, picked, picked),
470
+ controlled' = controlled,
471
+ accepting' = accepting,
472
+ valueProp' = propAfter(follows, valueProp, picked),
473
+ requested' = if ((picked) != picked) picked else requested,
474
+ outcome' = if ((picked) != picked) verdict else outcome,
475
+ base' = if ((picked) != picked) picked else base,
476
+ requests' = if ((picked) != picked) requests + 1 else requests,
477
+ focused' = focused
478
+ }
479
+
480
+ action end = all {
481
+ active' = if (focused) NONE else active,
482
+ lastEvent' = "end",
483
+ valueChanged' = false,
484
+ wasOpen' = open,
485
+ mode' = mode,
486
+ open' = open,
487
+ text' = text,
488
+ picked' = landedInt(holds, picked, picked),
489
+ controlled' = controlled,
490
+ accepting' = accepting,
491
+ valueProp' = propAfter(follows, valueProp, picked),
492
+ requested' = if ((picked) != picked) picked else requested,
493
+ outcome' = if ((picked) != picked) verdict else outcome,
494
+ base' = if ((picked) != picked) picked else base,
495
+ requests' = if ((picked) != picked) requests + 1 else requests,
496
+ focused' = focused
497
+ }
498
+
499
+ // --- commit / dismiss ---------------------------------------------------
500
+ // accepts the focused option and closes; without one it only closes
501
+ action enter = all {
502
+ open' = if (focused) false else open,
503
+ picked' = landedInt(holds, picked, if (accepts) active else picked),
504
+ controlled' = controlled,
505
+ accepting' = accepting,
506
+ valueProp' = propAfter(follows, valueProp, if (accepts) active else picked),
507
+ requested' = if ((if (accepts) active else picked) != picked) if (accepts) active else picked else requested,
508
+ outcome' = if ((if (accepts) active else picked) != picked) verdict else outcome,
509
+ base' = if ((if (accepts) active else picked) != picked) picked else base,
510
+ requests' = if ((if (accepts) active else picked) != picked) requests + 1 else requests,
511
+ active' = if (focused) NONE else active,
512
+ lastEvent' = "enter",
513
+ valueChanged' = and(accepts, picked != active),
514
+ wasOpen' = open,
515
+ mode' = mode,
516
+ text' = text,
517
+ focused' = focused
518
+ }
519
+
520
+ // open: close, keep the value; closed: clear the textbox (not in mode none)
521
+ action escape = all {
522
+ open' = if (focused) false else open,
523
+ active' = if (focused) NONE else active,
524
+ text' = if (escapeClears) 0 else text,
525
+ picked' = landedInt(holds, picked, if (escapeClears) NONE else picked),
526
+ controlled' = controlled,
527
+ accepting' = accepting,
528
+ valueProp' = propAfter(follows, valueProp, if (escapeClears) NONE else picked),
529
+ requested' = if ((if (escapeClears) NONE else picked) != picked) if (escapeClears) NONE else picked else requested,
530
+ outcome' = if ((if (escapeClears) NONE else picked) != picked) verdict else outcome,
531
+ base' = if ((if (escapeClears) NONE else picked) != picked) picked else base,
532
+ requests' = if ((if (escapeClears) NONE else picked) != picked) requests + 1 else requests,
533
+ lastEvent' = "escape",
534
+ valueChanged' = escapeClears,
535
+ wasOpen' = open,
536
+ mode' = mode,
537
+ focused' = focused
538
+ }
539
+
540
+ // --- pointer -------------------------------------------------------------
541
+ // click on the input toggles the popup; DOM focus stays on the input
542
+ action click_input = all {
543
+ focused' = true,
544
+ open' = if (open) false else canOpen,
545
+ active' = NONE,
546
+ lastEvent' = "click_input",
547
+ valueChanged' = false,
548
+ wasOpen' = open,
549
+ mode' = mode,
550
+ text' = text,
551
+ picked' = picked,
552
+ controlled' = controlled,
553
+ accepting' = accepting,
554
+ valueProp' = valueProp,
555
+ requested' = requested,
556
+ outcome' = outcome,
557
+ base' = base,
558
+ requests' = requests
559
+ }
560
+
561
+ // the Open button (tabindex -1) toggles the popup and focuses the input
562
+ action click_button = all {
563
+ focused' = true,
564
+ open' = if (open) false else canOpen,
565
+ active' = NONE,
566
+ lastEvent' = "click_button",
567
+ valueChanged' = false,
568
+ wasOpen' = open,
569
+ mode' = mode,
570
+ text' = text,
571
+ picked' = picked,
572
+ controlled' = controlled,
573
+ accepting' = accepting,
574
+ valueProp' = valueProp,
575
+ requested' = requested,
576
+ outcome' = outcome,
577
+ base' = base,
578
+ requests' = requests
579
+ }
580
+
581
+ // clicking a visible option of the open popup commits it
582
+ action click_option = {
583
+ nondet i = Set(0, 1, 2, 3, 4, 5).oneOf()
584
+ all {
585
+ picked' = landedInt(holds, picked, if (and(open, vis(i))) i else picked),
586
+ controlled' = controlled,
587
+ accepting' = accepting,
588
+ valueProp' = propAfter(follows, valueProp, if (and(open, vis(i))) i else picked),
589
+ requested' = if ((if (and(open, vis(i))) i else picked) != picked) if (and(open, vis(i))) i else picked else requested,
590
+ outcome' = if ((if (and(open, vis(i))) i else picked) != picked) verdict else outcome,
591
+ base' = if ((if (and(open, vis(i))) i else picked) != picked) picked else base,
592
+ requests' = if ((if (and(open, vis(i))) i else picked) != picked) requests + 1 else requests,
593
+ open' = if (and(open, vis(i))) false else open,
594
+ active' = if (and(open, vis(i))) NONE else active,
595
+ focused' = if (and(open, vis(i))) true else focused,
596
+ lastEvent' = "click_option",
597
+ valueChanged' = and(and(open, vis(i)), picked != i),
598
+ wasOpen' = open,
599
+ mode' = mode,
600
+ text' = text
601
+ }
602
+ }
603
+
604
+ // --- prop changes ---------------------------------------------------------
605
+ // aria-autocomplete prop swapped: the popup closes, the value is kept
606
+ action set_autocomplete = {
607
+ nondet m = Set(0, 1, 2).oneOf()
608
+ all {
609
+ mode' = m,
610
+ open' = false,
611
+ active' = NONE,
612
+ lastEvent' = "set_autocomplete",
613
+ valueChanged' = false,
614
+ wasOpen' = open,
615
+ text' = text,
616
+ picked' = landedInt(holds, picked, picked),
617
+ controlled' = controlled,
618
+ accepting' = accepting,
619
+ valueProp' = propAfter(follows, valueProp, picked),
620
+ requested' = if ((picked) != picked) picked else requested,
621
+ outcome' = if ((picked) != picked) verdict else outcome,
622
+ base' = if ((picked) != picked) picked else base,
623
+ requests' = if ((picked) != picked) requests + 1 else requests,
624
+ focused' = focused
625
+ }
626
+ }
627
+
628
+ // the committed suggestion is replaced from outside: the popup closes
629
+ action set_value = {
630
+ nondet i = Set(0, 1, 2, 3, 4, 5).oneOf()
631
+ all {
632
+ picked' = i,
633
+ open' = false,
634
+ active' = NONE,
635
+ lastEvent' = "set_value",
636
+ valueChanged' = picked != i,
637
+ wasOpen' = open,
638
+ mode' = mode,
639
+ text' = text,
640
+ focused' = focused,
641
+ controlled' = controlled,
642
+ accepting' = accepting,
643
+ valueProp' = if (controlled) i else valueProp,
644
+ requested' = requested,
645
+ outcome' = "none",
646
+ base' = base,
647
+ requests' = requests
648
+ }
649
+ }
650
+
651
+ // commit: the host flips its own copy of the picked suggestion
652
+ action hostToggle = all {
653
+ mode' = mode,
654
+ open' = open,
655
+ text' = text,
656
+ picked' = if (controlled) (if (picked >= 0) NONE else 0) else picked,
657
+ active' = NONE,
658
+ focused' = focused,
659
+ lastEvent' = "hostToggle",
660
+ valueChanged' = false,
661
+ wasOpen' = open,
662
+ controlled' = controlled,
663
+ accepting' = accepting,
664
+ valueProp' = if (controlled) (if (picked >= 0) NONE else 0) else valueProp,
665
+ requested' = requested,
666
+ outcome' = if (controlled) "none" else outcome,
667
+ base' = base,
668
+ requests' = requests
669
+ }
670
+
671
+ // configuration: the host starts owning the picked suggestion, adopting it
672
+ action control = all {
673
+ mode' = mode,
674
+ open' = open,
675
+ text' = text,
676
+ picked' = picked,
677
+ active' = active,
678
+ focused' = focused,
679
+ lastEvent' = lastEvent,
680
+ valueChanged' = valueChanged,
681
+ wasOpen' = open,
682
+ controlled' = true,
683
+ accepting' = accepting,
684
+ valueProp' = picked,
685
+ requested' = requested,
686
+ outcome' = outcome,
687
+ base' = base,
688
+ requests' = requests
689
+ }
690
+
691
+ // configuration: the controlled host answers every request with its intent
692
+ action acceptRequests = all {
693
+ mode' = mode,
694
+ open' = open,
695
+ text' = text,
696
+ picked' = picked,
697
+ active' = active,
698
+ focused' = focused,
699
+ lastEvent' = lastEvent,
700
+ valueChanged' = valueChanged,
701
+ wasOpen' = open,
702
+ controlled' = controlled,
703
+ accepting' = true,
704
+ valueProp' = valueProp,
705
+ requested' = requested,
706
+ outcome' = outcome,
707
+ base' = base,
708
+ requests' = requests
709
+ }
710
+
711
+ action step = any {
712
+ init, focus, blur, tab, type_char, backspace, delete, arrow_down, arrow_up,
713
+ alt_arrow_down, alt_arrow_up, arrow_left, arrow_right, home, end, enter,
714
+ escape, click_input, click_button, click_option, set_autocomplete, set_value,
715
+ hostToggle, control, acceptRequests
716
+ }
717
+
718
+ // --- invariants (APG guarantees) ----------------------------------------
719
+ // aria-activedescendant refers to a currently visible option of an open popup
720
+ // (a controlled host may reject an edit, so the committed picked suggestion
721
+ // can lag the text the visual focus was computed from)
722
+ val ActiveDescendantRefersToVisibleOption: bool =
723
+ or(controlled, or(active == NONE, and(open, vis(active))))
724
+ // an open popup is never empty
725
+ val OpenPopupHasSuggestions: bool = or(controlled, or(not(open), visCount > 0))
726
+ // DOM focus stays on the combobox while its popup is open
727
+ val DomFocusStaysOnCombobox: bool = or(not(open), focused)
728
+ // rendered attributes agree with the state; the inline completion is only
729
+ // ever exposed under aria-autocomplete="both"
730
+ val AriaContractConsistent: bool = and(
731
+ ariaActivedescendant == active,
732
+ selectedCount == (if (active >= 0) 1 else 0),
733
+ or(not(inlineCompletion), ariaAutocomplete == "both")
734
+ )
735
+ // editing returns visual focus to the textbox (none / list never select
736
+ // automatically) ...
737
+ val EditingReturnsVisualFocusToTextbox: bool =
738
+ or(not(isEdit(lastEvent)), mode == 2, active == NONE)
739
+ // ... except that mode "both" selects the first match automatically
740
+ val BothModeAutoSelectsFirstMatchOnEdit: bool =
741
+ or(controlled, or(not(isEdit(lastEvent)), mode != 2, active == NONE, active == firstVis))
742
+ // Escape on an open popup never changes the value
743
+ val EscapeOnOpenPopupKeepsValue: bool =
744
+ or(not(lastEvent == "escape"), not(wasOpen), not(valueChanged))
745
+ // navigating never changes the committed value
746
+ val NavigationKeepsValue: bool = or(not(isNavigation(lastEvent)), not(valueChanged))
747
+ // the value changes only on commit / edit / dismiss / prop events
748
+ val ValueChangesOnlyOnCommitOrEdit: bool = or(not(valueChanged), mayChangeValue(lastEvent))
749
+ // losing focus closes the popup
750
+ val FocusLossClosesPopup: bool =
751
+ or(not(or(lastEvent == "blur", lastEvent == "tab")), and(not(open), not(focused)))
752
+ val StateInRange: bool = and(
753
+ mode >= 0, mode <= 2, text >= 0, text <= 8,
754
+ picked >= NONE, picked < OPTION_COUNT, active >= NONE, active < OPTION_COUNT
755
+ )
756
+ // callbacks only ever count up from zero
757
+ val RequestsNonNegative: bool = requests >= 0
758
+ // controlled: only the value prop sets the picked suggestion, so no event commits
759
+ val ControlledFollowsProp: bool = or(not(controlled), picked == valueProp)
760
+ // a rejected request leaves the picked suggestion as it found it
761
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), picked == base)
762
+ // an accepted request committed exactly its intent
763
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), picked == requested)
764
+ }