@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,632 @@
1
+ // APG combobox — the editable combobox with a grid popup (aria-haspopup="grid").
2
+ //
3
+ // Sources (W3C WAI-ARIA Authoring Practices Guide):
4
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
5
+ // - "Keyboard Interaction" (Combobox + Grid Popup Keyboard Interaction and
6
+ // its notes on one suggestion per cell vs. per row), "WAI-ARIA Roles,
7
+ // States, and Properties" (aria-haspopup, aria-activedescendant,
8
+ // aria-selected on option / gridcell / row)
9
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/grid-combo/
10
+ // - "Keyboard Support" (Textbox, Grid Popup), "Role, Property, State, and
11
+ // Tabindex Attributes" (Textbox, Grid Popup)
12
+ //
13
+ // Modelled behaviour
14
+ // - DOM focus stays on the input; the visually focused grid cell is exposed
15
+ // through aria-activedescendant (`row`, `col`; both NONE = visual focus on
16
+ // the textbox). A grid cell can only be focused while the popup is open,
17
+ // and the popup can only be open while the input has DOM focus.
18
+ // - Layout variant (`cellValues`, prop `set_layout`): false = every row is one
19
+ // suggestion, so the row containing the focused cell is aria-selected and
20
+ // vertical arrows do not wrap across columns; true = every cell is its own
21
+ // suggestion, so exactly the focused cell is aria-selected and vertical
22
+ // arrows wrap from the bottom of one column to the top of the next.
23
+ // Horizontal arrows move cell by cell and wrap across rows in both layouts.
24
+ // - ArrowDown / ArrowUp open the grid and focus its first / last cell, or move
25
+ // the focus with wrap-around. Left / Right move between cells only while a
26
+ // cell is focused (otherwise they edit the text). Home / End / Delete return
27
+ // the visual focus to the textbox without closing the popup. Enter commits
28
+ // the suggestion under the focused cell and closes; Escape closes an open
29
+ // grid keeping the value, and clears the textbox when already closed.
30
+ // Typing / Backspace return the visual focus to the textbox. Blur / Tab
31
+ // close the grid (list autocomplete: nothing is committed).
32
+ // - Pointer: click on the input toggles the grid; clicking a cell commits it.
33
+ // - Prop changes: `set_layout` (the committed value is cleared because the
34
+ // suggestion id space changes), `set_value`.
35
+ //
36
+ // Deliberate omissions
37
+ // - Typed text content and filtering (see combobox-autocomplete): only whether
38
+ // the textbox holds typed text (`hasText`) matters for Escape-to-clear.
39
+ // - PageUp / PageDown / Ctrl+Home / Ctrl+End (optional in the pattern),
40
+ // asynchronous suggestions, scrolling, hover, grid headers / empty cells.
41
+ // - A fixed 3x3 grid (`ROWS`, `COLS`); sizes are constants, not pools.
42
+ //
43
+ // Generator notes: no action preconditions (key actions guard their assignments
44
+ // with `focused`), only str/int/bool vars, a single nondet per action.
45
+ module combobox_grid {
46
+ var open: bool // grid popup displayed == aria-expanded
47
+ var row: int // row of the visually focused cell, NONE = textbox
48
+ var col: int // column of the visually focused cell, NONE = textbox
49
+ var focused: bool // DOM focus is on the input
50
+ var hasText: bool // the textbox holds typed text
51
+ var picked: int // committed suggestion id, NONE = none
52
+ var cellValues: bool // layout: true = one suggestion per cell, false = per row
53
+ var lastEvent: str // name of the last event, for transition invariants
54
+ var valueChanged: bool // the last event changed the committed value / text
55
+ var wasOpen: bool // the grid was open before the last event
56
+ var controlled: bool // the host owns the picked suggestion (`value` prop present)
57
+ var accepting: bool // controlled host that answers every request with its intent
58
+ var valueProp: int // the controlled picked suggestion
59
+ var requested: int // intent of the last request
60
+ var outcome: str // "none" | "accepted" | "rejected"
61
+ var base: int // picked when the last request was received
62
+ var requests: int // number of requests that fired a change callback
63
+
64
+ // --- configuration ------------------------------------------------------
65
+ def NONE: int = -1
66
+ def ROWS: int = 3
67
+ def COLS: int = 3
68
+ def CELLS: int = 9
69
+
70
+ // --- helpers ------------------------------------------------------------
71
+ def hasActive: bool = row >= 0
72
+ def idx: int = row * COLS + col
73
+ def suggestionAt(k: int): int = if (cellValues) k else k / COLS
74
+ def focusedSuggestion: int = suggestionAt(idx)
75
+ def edits: bool = or(hasText, picked >= 0)
76
+
77
+ // vertical movement: per-row layout wraps within the column, per-cell layout
78
+ // wraps from the end of a column to the start of the next one
79
+ def stepDownRow: int =
80
+ if (cellValues) (if (row < ROWS - 1) row + 1 else 0) else (row + 1) % ROWS
81
+ def stepDownCol: int =
82
+ if (cellValues) (if (row < ROWS - 1) col else (col + 1) % COLS) else col
83
+ def stepUpRow: int =
84
+ if (cellValues) (if (row > 0) row - 1 else ROWS - 1) else (row + ROWS - 1) % ROWS
85
+ def stepUpCol: int =
86
+ if (cellValues) (if (row > 0) col else (col + COLS - 1) % COLS) else col
87
+ def downRow: int = if (hasActive) stepDownRow else 0
88
+ def downCol: int = if (hasActive) stepDownCol else 0
89
+ def upRow: int = if (hasActive) stepUpRow else ROWS - 1
90
+ def upCol: int = if (hasActive) stepUpCol else COLS - 1
91
+ // horizontal movement is linear and wraps across rows
92
+ def rightIdx: int = (idx + 1) % CELLS
93
+ def leftIdx: int = (idx + CELLS - 1) % CELLS
94
+ def moves: bool = and(focused, hasActive)
95
+
96
+ def escapeClears: bool = and(and(focused, not(open)), edits)
97
+
98
+ // --- derived ARIA attribute contract -----------------------------------
99
+ def ariaHasPopup: str = "grid"
100
+ def ariaExpanded: bool = open
101
+ def ariaActivedescendant: int = if (hasActive) idx else NONE
102
+ def rowSelected(r: int): bool = and(not(cellValues), and(hasActive, r == row))
103
+ def cellSelected(r: int, c: int): bool = and(cellValues, and(hasActive, r == row, c == col))
104
+ def selectedRows: int =
105
+ (if (rowSelected(0)) 1 else 0) + (if (rowSelected(1)) 1 else 0) + (if (rowSelected(2)) 1 else 0)
106
+ def selectedCells: int =
107
+ (if (cellSelected(0, 0)) 1 else 0) + (if (cellSelected(0, 1)) 1 else 0) + (if (cellSelected(0, 2)) 1 else 0) +
108
+ (if (cellSelected(1, 0)) 1 else 0) + (if (cellSelected(1, 1)) 1 else 0) + (if (cellSelected(1, 2)) 1 else 0) +
109
+ (if (cellSelected(2, 0)) 1 else 0) + (if (cellSelected(2, 1)) 1 else 0) + (if (cellSelected(2, 2)) 1 else 0)
110
+ def isNavigation(e: str): bool =
111
+ or(e == "arrow_down", e == "arrow_up", e == "arrow_left", e == "arrow_right",
112
+ e == "home", e == "end", e == "delete")
113
+ def mayChangeValue(e: str): bool =
114
+ or(e == "enter", e == "click_cell", e == "set_value", e == "set_layout",
115
+ e == "type_char", e == "backspace", e == "escape")
116
+
117
+ // --- request / commit (map ticket rt1p) --------------------------------
118
+ // controlled host that keeps the old value: the request is rejected
119
+ def holds: bool = and(controlled, not(accepting))
120
+ // controlled host that writes the intent back through the value prop
121
+ def follows: bool = and(controlled, accepting)
122
+ def verdict: str = if (holds) "rejected" else "accepted"
123
+ def landedInt(h: bool, current: int, want: int): int = if (h) current else want
124
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
125
+
126
+ action init = all {
127
+ open' = false,
128
+ row' = NONE,
129
+ col' = NONE,
130
+ focused' = false,
131
+ hasText' = false,
132
+ picked' = NONE,
133
+ cellValues' = false,
134
+ lastEvent' = "init",
135
+ valueChanged' = false,
136
+ wasOpen' = false,
137
+ controlled' = false,
138
+ accepting' = false,
139
+ valueProp' = NONE,
140
+ requested' = NONE,
141
+ outcome' = "none",
142
+ base' = NONE,
143
+ requests' = 0
144
+ }
145
+
146
+ // --- focus / blur --------------------------------------------------------
147
+ action focus = all {
148
+ focused' = true,
149
+ lastEvent' = "focus",
150
+ valueChanged' = false,
151
+ wasOpen' = open,
152
+ open' = open,
153
+ row' = row,
154
+ col' = col,
155
+ hasText' = hasText,
156
+ picked' = landedInt(holds, picked, picked),
157
+ controlled' = controlled,
158
+ accepting' = accepting,
159
+ valueProp' = propAfter(follows, valueProp, picked),
160
+ requested' = if ((picked) != picked) picked else requested,
161
+ outcome' = if ((picked) != picked) verdict else outcome,
162
+ base' = if ((picked) != picked) picked else base,
163
+ requests' = if ((picked) != picked) requests + 1 else requests,
164
+ cellValues' = cellValues
165
+ }
166
+
167
+ action blur = all {
168
+ open' = false,
169
+ row' = NONE,
170
+ col' = NONE,
171
+ focused' = false,
172
+ lastEvent' = "blur",
173
+ valueChanged' = false,
174
+ wasOpen' = open,
175
+ hasText' = hasText,
176
+ picked' = landedInt(holds, picked, picked),
177
+ controlled' = controlled,
178
+ accepting' = accepting,
179
+ valueProp' = propAfter(follows, valueProp, picked),
180
+ requested' = if ((picked) != picked) picked else requested,
181
+ outcome' = if ((picked) != picked) verdict else outcome,
182
+ base' = if ((picked) != picked) picked else base,
183
+ requests' = if ((picked) != picked) requests + 1 else requests,
184
+ cellValues' = cellValues
185
+ }
186
+
187
+ // Tab: the popup and its cells are out of the Tab sequence
188
+ action tab = all {
189
+ open' = if (focused) false else open,
190
+ row' = if (focused) NONE else row,
191
+ col' = if (focused) NONE else col,
192
+ focused' = false,
193
+ lastEvent' = "tab",
194
+ valueChanged' = false,
195
+ wasOpen' = open,
196
+ hasText' = hasText,
197
+ picked' = landedInt(holds, picked, picked),
198
+ controlled' = controlled,
199
+ accepting' = accepting,
200
+ valueProp' = propAfter(follows, valueProp, picked),
201
+ requested' = if ((picked) != picked) picked else requested,
202
+ outcome' = if ((picked) != picked) verdict else outcome,
203
+ base' = if ((picked) != picked) picked else base,
204
+ requests' = if ((picked) != picked) requests + 1 else requests,
205
+ cellValues' = cellValues
206
+ }
207
+
208
+ // --- text editing: visual focus returns to the textbox ------------------
209
+ action type_char = all {
210
+ hasText' = if (focused) true else hasText,
211
+ picked' = landedInt(holds, picked, if (focused) NONE else picked),
212
+ controlled' = controlled,
213
+ accepting' = accepting,
214
+ valueProp' = propAfter(follows, valueProp, if (focused) NONE else picked),
215
+ requested' = if ((if (focused) NONE else picked) != picked) if (focused) NONE else picked else requested,
216
+ outcome' = if ((if (focused) NONE else picked) != picked) verdict else outcome,
217
+ base' = if ((if (focused) NONE else picked) != picked) picked else base,
218
+ requests' = if ((if (focused) NONE else picked) != picked) requests + 1 else requests,
219
+ open' = if (focused) true else open,
220
+ row' = if (focused) NONE else row,
221
+ col' = if (focused) NONE else col,
222
+ lastEvent' = "type_char",
223
+ valueChanged' = and(focused, or(not(hasText), picked >= 0)),
224
+ wasOpen' = open,
225
+ focused' = focused,
226
+ cellValues' = cellValues
227
+ }
228
+
229
+ action backspace = all {
230
+ hasText' = if (focused) edits else hasText,
231
+ picked' = landedInt(holds, picked, if (focused) NONE else picked),
232
+ controlled' = controlled,
233
+ accepting' = accepting,
234
+ valueProp' = propAfter(follows, valueProp, if (focused) NONE else picked),
235
+ requested' = if ((if (focused) NONE else picked) != picked) if (focused) NONE else picked else requested,
236
+ outcome' = if ((if (focused) NONE else picked) != picked) verdict else outcome,
237
+ base' = if ((if (focused) NONE else picked) != picked) picked else base,
238
+ requests' = if ((if (focused) NONE else picked) != picked) requests + 1 else requests,
239
+ row' = if (focused) NONE else row,
240
+ col' = if (focused) NONE else col,
241
+ lastEvent' = "backspace",
242
+ valueChanged' = and(focused, picked >= 0),
243
+ wasOpen' = open,
244
+ open' = open,
245
+ focused' = focused,
246
+ cellValues' = cellValues
247
+ }
248
+
249
+ action delete = all {
250
+ row' = if (focused) NONE else row,
251
+ col' = if (focused) NONE else col,
252
+ lastEvent' = "delete",
253
+ valueChanged' = false,
254
+ wasOpen' = open,
255
+ open' = open,
256
+ focused' = focused,
257
+ hasText' = hasText,
258
+ picked' = landedInt(holds, picked, picked),
259
+ controlled' = controlled,
260
+ accepting' = accepting,
261
+ valueProp' = propAfter(follows, valueProp, picked),
262
+ requested' = if ((picked) != picked) picked else requested,
263
+ outcome' = if ((picked) != picked) verdict else outcome,
264
+ base' = if ((picked) != picked) picked else base,
265
+ requests' = if ((picked) != picked) requests + 1 else requests,
266
+ cellValues' = cellValues
267
+ }
268
+
269
+ // --- navigation keys --------------------------------------------------------
270
+ action arrow_down = all {
271
+ open' = if (focused) true else open,
272
+ row' = if (focused) downRow else row,
273
+ col' = if (focused) downCol else col,
274
+ lastEvent' = "arrow_down",
275
+ valueChanged' = false,
276
+ wasOpen' = open,
277
+ focused' = focused,
278
+ hasText' = hasText,
279
+ picked' = landedInt(holds, picked, picked),
280
+ controlled' = controlled,
281
+ accepting' = accepting,
282
+ valueProp' = propAfter(follows, valueProp, picked),
283
+ requested' = if ((picked) != picked) picked else requested,
284
+ outcome' = if ((picked) != picked) verdict else outcome,
285
+ base' = if ((picked) != picked) picked else base,
286
+ requests' = if ((picked) != picked) requests + 1 else requests,
287
+ cellValues' = cellValues
288
+ }
289
+
290
+ action arrow_up = all {
291
+ open' = if (focused) true else open,
292
+ row' = if (focused) upRow else row,
293
+ col' = if (focused) upCol else col,
294
+ lastEvent' = "arrow_up",
295
+ valueChanged' = false,
296
+ wasOpen' = open,
297
+ focused' = focused,
298
+ hasText' = hasText,
299
+ picked' = landedInt(holds, picked, picked),
300
+ controlled' = controlled,
301
+ accepting' = accepting,
302
+ valueProp' = propAfter(follows, valueProp, picked),
303
+ requested' = if ((picked) != picked) picked else requested,
304
+ outcome' = if ((picked) != picked) verdict else outcome,
305
+ base' = if ((picked) != picked) picked else base,
306
+ requests' = if ((picked) != picked) requests + 1 else requests,
307
+ cellValues' = cellValues
308
+ }
309
+
310
+ // Left / Right move between cells (wrapping across rows); with the visual
311
+ // focus on the textbox they edit the text instead
312
+ action arrow_right = all {
313
+ row' = if (moves) rightIdx / COLS else row,
314
+ col' = if (moves) rightIdx % COLS else col,
315
+ lastEvent' = "arrow_right",
316
+ valueChanged' = false,
317
+ wasOpen' = open,
318
+ open' = open,
319
+ focused' = focused,
320
+ hasText' = hasText,
321
+ picked' = landedInt(holds, picked, picked),
322
+ controlled' = controlled,
323
+ accepting' = accepting,
324
+ valueProp' = propAfter(follows, valueProp, picked),
325
+ requested' = if ((picked) != picked) picked else requested,
326
+ outcome' = if ((picked) != picked) verdict else outcome,
327
+ base' = if ((picked) != picked) picked else base,
328
+ requests' = if ((picked) != picked) requests + 1 else requests,
329
+ cellValues' = cellValues
330
+ }
331
+
332
+ action arrow_left = all {
333
+ row' = if (moves) leftIdx / COLS else row,
334
+ col' = if (moves) leftIdx % COLS else col,
335
+ lastEvent' = "arrow_left",
336
+ valueChanged' = false,
337
+ wasOpen' = open,
338
+ open' = open,
339
+ focused' = focused,
340
+ hasText' = hasText,
341
+ picked' = landedInt(holds, picked, picked),
342
+ controlled' = controlled,
343
+ accepting' = accepting,
344
+ valueProp' = propAfter(follows, valueProp, picked),
345
+ requested' = if ((picked) != picked) picked else requested,
346
+ outcome' = if ((picked) != picked) verdict else outcome,
347
+ base' = if ((picked) != picked) picked else base,
348
+ requests' = if ((picked) != picked) requests + 1 else requests,
349
+ cellValues' = cellValues
350
+ }
351
+
352
+ // Home / End: visual focus back to the textbox, grid stays open
353
+ action home = all {
354
+ row' = if (focused) NONE else row,
355
+ col' = if (focused) NONE else col,
356
+ lastEvent' = "home",
357
+ valueChanged' = false,
358
+ wasOpen' = open,
359
+ open' = open,
360
+ focused' = focused,
361
+ hasText' = hasText,
362
+ picked' = landedInt(holds, picked, picked),
363
+ controlled' = controlled,
364
+ accepting' = accepting,
365
+ valueProp' = propAfter(follows, valueProp, picked),
366
+ requested' = if ((picked) != picked) picked else requested,
367
+ outcome' = if ((picked) != picked) verdict else outcome,
368
+ base' = if ((picked) != picked) picked else base,
369
+ requests' = if ((picked) != picked) requests + 1 else requests,
370
+ cellValues' = cellValues
371
+ }
372
+
373
+ action end = all {
374
+ row' = if (focused) NONE else row,
375
+ col' = if (focused) NONE else col,
376
+ lastEvent' = "end",
377
+ valueChanged' = false,
378
+ wasOpen' = open,
379
+ open' = open,
380
+ focused' = focused,
381
+ hasText' = hasText,
382
+ picked' = landedInt(holds, picked, picked),
383
+ controlled' = controlled,
384
+ accepting' = accepting,
385
+ valueProp' = propAfter(follows, valueProp, picked),
386
+ requested' = if ((picked) != picked) picked else requested,
387
+ outcome' = if ((picked) != picked) verdict else outcome,
388
+ base' = if ((picked) != picked) picked else base,
389
+ requests' = if ((picked) != picked) requests + 1 else requests,
390
+ cellValues' = cellValues
391
+ }
392
+
393
+ // --- commit / dismiss ---------------------------------------------------
394
+ // Enter on a focused cell commits its suggestion (cell or whole row) and closes
395
+ action enter = all {
396
+ open' = if (moves) false else open,
397
+ picked' = landedInt(holds, picked, if (moves) focusedSuggestion else picked),
398
+ controlled' = controlled,
399
+ accepting' = accepting,
400
+ valueProp' = propAfter(follows, valueProp, if (moves) focusedSuggestion else picked),
401
+ requested' = if ((if (moves) focusedSuggestion else picked) != picked) if (moves) focusedSuggestion else picked else requested,
402
+ outcome' = if ((if (moves) focusedSuggestion else picked) != picked) verdict else outcome,
403
+ base' = if ((if (moves) focusedSuggestion else picked) != picked) picked else base,
404
+ requests' = if ((if (moves) focusedSuggestion else picked) != picked) requests + 1 else requests,
405
+ row' = if (moves) NONE else row,
406
+ col' = if (moves) NONE else col,
407
+ lastEvent' = "enter",
408
+ valueChanged' = and(moves, picked != focusedSuggestion),
409
+ wasOpen' = open,
410
+ focused' = focused,
411
+ hasText' = hasText,
412
+ cellValues' = cellValues
413
+ }
414
+
415
+ // open: close, keep the value; closed: clear the textbox
416
+ action escape = all {
417
+ open' = if (focused) false else open,
418
+ row' = if (focused) NONE else row,
419
+ col' = if (focused) NONE else col,
420
+ hasText' = if (escapeClears) false else hasText,
421
+ picked' = landedInt(holds, picked, if (escapeClears) NONE else picked),
422
+ controlled' = controlled,
423
+ accepting' = accepting,
424
+ valueProp' = propAfter(follows, valueProp, if (escapeClears) NONE else picked),
425
+ requested' = if ((if (escapeClears) NONE else picked) != picked) if (escapeClears) NONE else picked else requested,
426
+ outcome' = if ((if (escapeClears) NONE else picked) != picked) verdict else outcome,
427
+ base' = if ((if (escapeClears) NONE else picked) != picked) picked else base,
428
+ requests' = if ((if (escapeClears) NONE else picked) != picked) requests + 1 else requests,
429
+ lastEvent' = "escape",
430
+ valueChanged' = escapeClears,
431
+ wasOpen' = open,
432
+ focused' = focused,
433
+ cellValues' = cellValues
434
+ }
435
+
436
+ // --- pointer -------------------------------------------------------------
437
+ action click_input = all {
438
+ focused' = true,
439
+ open' = not(open),
440
+ row' = NONE,
441
+ col' = NONE,
442
+ lastEvent' = "click_input",
443
+ valueChanged' = false,
444
+ wasOpen' = open,
445
+ hasText' = hasText,
446
+ picked' = landedInt(holds, picked, picked),
447
+ controlled' = controlled,
448
+ accepting' = accepting,
449
+ valueProp' = propAfter(follows, valueProp, picked),
450
+ requested' = if ((picked) != picked) picked else requested,
451
+ outcome' = if ((picked) != picked) verdict else outcome,
452
+ base' = if ((picked) != picked) picked else base,
453
+ requests' = if ((picked) != picked) requests + 1 else requests,
454
+ cellValues' = cellValues
455
+ }
456
+
457
+ // clicking a cell of the open grid commits it and returns visual focus
458
+ action click_cell = {
459
+ nondet k = Set(0, 1, 2, 3, 4, 5, 6, 7, 8).oneOf()
460
+ all {
461
+ picked' = landedInt(holds, picked, if (open) suggestionAt(k) else picked),
462
+ controlled' = controlled,
463
+ accepting' = accepting,
464
+ valueProp' = propAfter(follows, valueProp, if (open) suggestionAt(k) else picked),
465
+ requested' = if ((if (open) suggestionAt(k) else picked) != picked) if (open) suggestionAt(k) else picked else requested,
466
+ outcome' = if ((if (open) suggestionAt(k) else picked) != picked) verdict else outcome,
467
+ base' = if ((if (open) suggestionAt(k) else picked) != picked) picked else base,
468
+ requests' = if ((if (open) suggestionAt(k) else picked) != picked) requests + 1 else requests,
469
+ open' = false,
470
+ row' = NONE,
471
+ col' = NONE,
472
+ focused' = if (open) true else focused,
473
+ lastEvent' = "click_cell",
474
+ valueChanged' = and(open, picked != suggestionAt(k)),
475
+ wasOpen' = open,
476
+ hasText' = hasText,
477
+ cellValues' = cellValues
478
+ }
479
+ }
480
+
481
+ // --- prop changes ---------------------------------------------------------
482
+ // the suggestion id space changes with the layout: popup closes, value cleared
483
+ action set_layout = {
484
+ nondet cells = Set(true, false).oneOf()
485
+ all {
486
+ cellValues' = cells,
487
+ open' = false,
488
+ row' = NONE,
489
+ col' = NONE,
490
+ picked' = NONE,
491
+ lastEvent' = "set_layout",
492
+ valueChanged' = picked >= 0,
493
+ wasOpen' = open,
494
+ focused' = focused,
495
+ hasText' = hasText,
496
+ controlled' = controlled,
497
+ accepting' = accepting,
498
+ valueProp' = if (controlled) NONE else valueProp,
499
+ requested' = requested,
500
+ outcome' = "none",
501
+ base' = base,
502
+ requests' = requests
503
+ }
504
+ }
505
+
506
+ action set_value = {
507
+ nondet i = Set(0, 1, 2).oneOf()
508
+ all {
509
+ picked' = i,
510
+ open' = false,
511
+ row' = NONE,
512
+ col' = NONE,
513
+ lastEvent' = "set_value",
514
+ valueChanged' = picked != i,
515
+ wasOpen' = open,
516
+ focused' = focused,
517
+ hasText' = hasText,
518
+ cellValues' = cellValues,
519
+ controlled' = controlled,
520
+ accepting' = accepting,
521
+ valueProp' = if (controlled) i else valueProp,
522
+ requested' = requested,
523
+ outcome' = "none",
524
+ base' = base,
525
+ requests' = requests
526
+ }
527
+ }
528
+
529
+ // commit: the host flips its own copy of the picked suggestion
530
+ action hostToggle = all {
531
+ open' = open,
532
+ row' = row,
533
+ col' = col,
534
+ focused' = focused,
535
+ hasText' = hasText,
536
+ picked' = if (controlled) (if (picked >= 0) NONE else 0) else picked,
537
+ cellValues' = cellValues,
538
+ lastEvent' = "hostToggle",
539
+ valueChanged' = false,
540
+ wasOpen' = open,
541
+ controlled' = controlled,
542
+ accepting' = accepting,
543
+ valueProp' = if (controlled) (if (picked >= 0) NONE else 0) else valueProp,
544
+ requested' = requested,
545
+ outcome' = if (controlled) "none" else outcome,
546
+ base' = base,
547
+ requests' = requests
548
+ }
549
+
550
+ // configuration: the host starts owning the picked suggestion, adopting it
551
+ action control = all {
552
+ open' = open,
553
+ row' = row,
554
+ col' = col,
555
+ focused' = focused,
556
+ hasText' = hasText,
557
+ picked' = picked,
558
+ cellValues' = cellValues,
559
+ lastEvent' = lastEvent,
560
+ valueChanged' = valueChanged,
561
+ wasOpen' = open,
562
+ controlled' = true,
563
+ accepting' = accepting,
564
+ valueProp' = picked,
565
+ requested' = requested,
566
+ outcome' = outcome,
567
+ base' = base,
568
+ requests' = requests
569
+ }
570
+
571
+ // configuration: the controlled host answers every request with its intent
572
+ action acceptRequests = all {
573
+ open' = open,
574
+ row' = row,
575
+ col' = col,
576
+ focused' = focused,
577
+ hasText' = hasText,
578
+ picked' = picked,
579
+ cellValues' = cellValues,
580
+ lastEvent' = lastEvent,
581
+ valueChanged' = valueChanged,
582
+ wasOpen' = open,
583
+ controlled' = controlled,
584
+ accepting' = true,
585
+ valueProp' = valueProp,
586
+ requested' = requested,
587
+ outcome' = outcome,
588
+ base' = base,
589
+ requests' = requests
590
+ }
591
+
592
+ action step = any {
593
+ init, focus, blur, tab, type_char, backspace, delete, arrow_down, arrow_up,
594
+ arrow_right, arrow_left, home, end, enter, escape, click_input, click_cell,
595
+ set_layout, set_value, hostToggle, control, acceptRequests
596
+ }
597
+
598
+ // --- invariants (APG guarantees) ----------------------------------------
599
+ // the focused cell is a real grid cell; row and column are set together
600
+ val GridPositionConsistent: bool = and(
601
+ row >= NONE, row < ROWS, col >= NONE, col < COLS, (row == NONE) == (col == NONE)
602
+ )
603
+ // aria-activedescendant (a focused cell) exists only while the grid is open
604
+ val ActiveCellRequiresOpenGrid: bool = or(row == NONE, open)
605
+ // DOM focus stays on the combobox while its popup is open
606
+ val DomFocusStaysOnCombobox: bool = or(not(open), focused)
607
+ // aria-selected: one row (per-row layout) or one cell (per-cell layout)
608
+ // while a cell is focused, nothing otherwise; selection follows focus
609
+ val AriaContractConsistent: bool = and(
610
+ ariaActivedescendant == (if (hasActive) idx else NONE),
611
+ selectedRows + selectedCells == (if (hasActive) 1 else 0),
612
+ or(not(cellValues), selectedRows == 0),
613
+ or(cellValues, selectedCells == 0)
614
+ )
615
+ val EscapeOnOpenGridKeepsValue: bool =
616
+ or(not(lastEvent == "escape"), not(wasOpen), not(valueChanged))
617
+ val NavigationKeepsValue: bool = or(not(isNavigation(lastEvent)), not(valueChanged))
618
+ val ValueChangesOnlyOnCommitOrEdit: bool = or(not(valueChanged), mayChangeValue(lastEvent))
619
+ // a committed suggestion id exists in the current layout
620
+ val PickedInRange: bool =
621
+ or(picked == NONE, picked < (if (cellValues) CELLS else ROWS))
622
+ val FocusLossClosesGrid: bool =
623
+ or(not(or(lastEvent == "blur", lastEvent == "tab")), and(not(open), not(focused)))
624
+ // callbacks only ever count up from zero
625
+ val RequestsNonNegative: bool = requests >= 0
626
+ // controlled: only the value prop sets the picked suggestion, so no event commits
627
+ val ControlledFollowsProp: bool = or(not(controlled), picked == valueProp)
628
+ // a rejected request leaves the picked suggestion as it found it
629
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), picked == base)
630
+ // an accepted request committed exactly its intent
631
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), picked == requested)
632
+ }