@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,512 @@
1
+ // WAI-ARIA APG — grid: optional cell / row / column selection.
2
+ //
3
+ // Modelled from https://www.w3.org/WAI/ARIA/apg/patterns/grid/ :
4
+ // - "Keyboard Interaction For Data Grids" (selection keys: Control+Space selects
5
+ // the column, Shift+Space the row, Control+A all cells, Shift+Arrow extends
6
+ // the selection by one cell) and the same list under "Keyboard Interaction
7
+ // For Layout Grids";
8
+ // - "WAI-ARIA Roles, States, and Properties" (aria-selected=true on selected
9
+ // cells or rows; on every cell of a selected column; the attribute exists only
10
+ // if the grid supports selection);
11
+ // and the selection behaviour of https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/advanced-data-grid/
12
+ //
13
+ // The selection is one rectangle [selR0..selR1] x [selC0..selC1] plus an anchor
14
+ // cell. A row selection is a full-width rectangle, a column selection a
15
+ // full-height one, Control+A the whole grid, Shift+Arrow the bounding box of the
16
+ // anchor and the moving focus cell. aria-selected is derived from the rectangle.
17
+ // Focus (row, col) is kept here only because Shift+Arrow needs it; full
18
+ // navigation (Home/End/Page keys, wrap, edit mode) is grid.qnt.
19
+ //
20
+ // Deliberate decisions where APG is silent:
21
+ // - a plain arrow (or click) collapses the selection and re-anchors on the new
22
+ // focus cell; Escape clears the selection;
23
+ // - Shift+Space / Control+Space / Control+A re-anchor on the focus cell;
24
+ // - Shift+Arrow at the grid edge still selects the focus cell (no movement);
25
+ // - on a grid that is not selectable, Shift+Arrow is a plain move and the
26
+ // selection keys do nothing; turning selectability off clears the selection;
27
+ // - while the grid has no focus, keys are ignored; blur keeps the selection.
28
+ //
29
+ // Request vs commit (map ticket rt1p). The selection — "nothing" or the active
30
+ // rectangle — is the value, packed into `selectionPacked` (0 = nothing selected).
31
+ // Every event a user sees as a selection change — `key` (plain / Shift arrows,
32
+ // Shift+Space, Control+Space, Control+A, Escape), `click`, `shift_click` and the
33
+ // `set_selectable` prop clearing an active selection — is a *request* carrying an
34
+ // intent (`requested`); it fires onValueChange (`requests` counts the calls) when
35
+ // the intent differs from the current value, and then:
36
+ // uncontrolled the request is accepted: the selection commits the intent
37
+ // controlled, holds the host ignores it ("rejected"): the selection is untouched
38
+ // controlled, accepts the host answers with the same value (`accepting`): the
39
+ // selection commits through the controlled value, not the event
40
+ // `valueProp` is the only thing that changes the selection while `controlled`;
41
+ // `setValue` / `hostToggle` are the host writing it. `control` / `acceptRequests`
42
+ // are configuration events (init is literal-only). Focus, the anchor and the
43
+ // collapsed rectangle still move with the event even when the request is rejected
44
+ // (roving focus), which is why the four shape/containment invariants are scoped to
45
+ // the uncontrolled path: a controlled host owns the value and may hand the widget
46
+ // any rectangle. Invariants for the model: ControlledFollowsProp,
47
+ // RejectedRequestUntouched, AcceptedRequestCommits.
48
+ //
49
+ // Deliberate omissions: Tab / Shift+Tab (focus leaves via focus_out), Home/End,
50
+ // PageUp/PageDown and wrapping (grid.qnt), non-rectangular multi-selection
51
+ // (Control+click toggling), the row-selection checkbox column, cut/copy/paste,
52
+ // aria-multiselectable, header cells.
53
+ //
54
+ // Generator gaps hit: no sets/lists/maps, so the selection is a rectangle of
55
+ // scalar bounds rather than a set of cells; no `const` (sizes are zero-argument
56
+ // `val`s); one nondet per action (cell index / key name are single pools).
57
+ module apg_grid_selection {
58
+ var row: int // focused row, 0-based
59
+ var col: int // focused column, 0-based
60
+ var anchorRow: int // selection anchor
61
+ var anchorCol: int
62
+ var selActive: bool // a selection exists
63
+ var selR0: int // selection rectangle (inclusive); the focus cell when inactive
64
+ var selR1: int
65
+ var selC0: int
66
+ var selC1: int
67
+ var hasFocus: bool // DOM focus is inside the grid
68
+ var selectable: bool // the grid supports selection (aria-selected exists)
69
+ var controlled: bool // the host owns the selection (`value` prop present)
70
+ var accepting: bool // controlled host that answers every request with its intent
71
+ var valueProp: int // the controlled value prop (packed; 0 = nothing selected)
72
+ var requested: int // intent of the last request (packed)
73
+ var outcome: str // "none" | "accepted" | "rejected"
74
+ var base: int // value when the last request was received (packed)
75
+ var requests: int // number of requests that fired onValueChange
76
+
77
+ // bounded grid: keep CELL pool of click / shift_click in step
78
+ val ROWS: int = 4
79
+ val COLS: int = 3
80
+ val LAST_ROW: int = ROWS - 1
81
+ val LAST_COL: int = COLS - 1
82
+
83
+ def lowerOf(a: int, b: int): int = if (a < b) a else b
84
+ def upperOf(a: int, b: int): int = if (a > b) a else b
85
+ def clampRow(r: int): int = if (r < 0) 0 else if (r > LAST_ROW) LAST_ROW else r
86
+ def clampCol(c: int): int = if (c < 0) 0 else if (c > LAST_COL) LAST_COL else c
87
+
88
+ def dRow(k: str): int =
89
+ if (or(k == "ArrowDown", k == "Shift+ArrowDown")) 1
90
+ else if (or(k == "ArrowUp", k == "Shift+ArrowUp")) -1
91
+ else 0
92
+ def dCol(k: str): int =
93
+ if (or(k == "ArrowRight", k == "Shift+ArrowRight")) 1
94
+ else if (or(k == "ArrowLeft", k == "Shift+ArrowLeft")) -1
95
+ else 0
96
+ def newRow(k: str): int = clampRow(row + dRow(k))
97
+ def newCol(k: str): int = clampCol(col + dCol(k))
98
+
99
+ def isPlainArrow(k: str): bool =
100
+ or(k == "ArrowRight", k == "ArrowLeft", k == "ArrowDown", k == "ArrowUp")
101
+ def isShiftArrow(k: str): bool =
102
+ or(k == "Shift+ArrowRight", k == "Shift+ArrowLeft", k == "Shift+ArrowDown", k == "Shift+ArrowUp")
103
+
104
+ // key classes (all gated on DOM focus inside the grid)
105
+ def collapses(k: str): bool =
106
+ and(hasFocus, or(isPlainArrow(k), and(isShiftArrow(k), not(selectable))))
107
+ def extendsSel(k: str): bool = and(hasFocus, selectable, isShiftArrow(k))
108
+ def selectsRow(k: str): bool = and(hasFocus, selectable, k == "Shift+Space")
109
+ def selectsCol(k: str): bool = and(hasFocus, selectable, k == "Control+Space")
110
+ def selectsAll(k: str): bool = and(hasFocus, selectable, k == "Control+a")
111
+ def clears(k: str): bool = and(hasFocus, k == "Escape")
112
+ def selects(k: str): bool = or(extendsSel(k), selectsRow(k), selectsCol(k), selectsAll(k))
113
+ def moves(k: str): bool = or(collapses(k), extendsSel(k))
114
+ // re-anchors on the focus cell (selection keys and Escape)
115
+ def reanchors(k: str): bool = or(selectsRow(k), selectsCol(k), selectsAll(k), clears(k))
116
+
117
+ // --- ARIA contract (derived) ---
118
+ def cellSelected(r: int, c: int): bool =
119
+ and(selActive, r >= selR0, r <= selR1, c >= selC0, c <= selC1)
120
+ def rowSelected(r: int): bool =
121
+ and(selActive, r >= selR0, r <= selR1, selC0 == 0, selC1 == LAST_COL)
122
+ def colSelected(c: int): bool =
123
+ and(selActive, c >= selC0, c <= selC1, selR0 == 0, selR1 == LAST_ROW)
124
+ // aria-selected is only present on a grid that supports selection
125
+ def ariaSelectedPresent: bool = selectable
126
+ def cellAriaSelected(r: int, c: int): bool = and(selectable, cellSelected(r, c))
127
+ def selectedCellCount: int =
128
+ if (selActive) (selR1 - selR0 + 1) * (selC1 - selC0 + 1) else 0
129
+
130
+ // --- request / commit (map ticket rt1p) ----------------------------------
131
+ def moved(before: int, after: int): int = if (before == after) 0 else 1
132
+ // injective for the 60 ordered rectangles: 0 = none, else 1 + r0 + 4*r1 + 16*c0 + 48*c1
133
+ def selectionPacked(active: bool, r0: int, r1: int, c0: int, c1: int): int =
134
+ if (active) 1 + r0 + 4 * r1 + 16 * c0 + 48 * c1 else 0
135
+ def unpackR0(v: int): int = (v - 1) % 4
136
+ def unpackR1(v: int): int = ((v - 1) / 4) % 4
137
+ def unpackC0(v: int): int = ((v - 1) / 16) % 3
138
+ def unpackC1(v: int): int = ((v - 1) / 48) % 3
139
+ def nowPacked: int = selectionPacked(selActive, selR0, selR1, selC0, selC1)
140
+ // controlled host that keeps the old value: the request is rejected
141
+ def holds: bool = and(controlled, not(accepting))
142
+ // controlled host that writes the intent back through the value prop
143
+ def follows: bool = and(controlled, accepting)
144
+ def verdict: str = if (holds) "rejected" else "accepted"
145
+ // the value after a request for `want`
146
+ def landed(h: bool, current: bool, want: bool): bool = if (h) current else want
147
+ def landedI(h: bool, current: int, want: int): int = if (h) current else want
148
+ // the value prop after a request for `want`
149
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
150
+ // the selection intent of a key, per member (mirrors the old action RHS)
151
+ def keyWantActive(k: str): bool =
152
+ if (selects(k)) true else if (or(collapses(k), clears(k))) false else selActive
153
+ def keyWantR0(k: str): int =
154
+ if (extendsSel(k)) lowerOf(anchorRow, newRow(k))
155
+ else if (selectsRow(k)) row
156
+ else if (or(selectsCol(k), selectsAll(k))) 0
157
+ else if (collapses(k)) newRow(k)
158
+ else if (clears(k)) row
159
+ else selR0
160
+ def keyWantR1(k: str): int =
161
+ if (extendsSel(k)) upperOf(anchorRow, newRow(k))
162
+ else if (selectsRow(k)) row
163
+ else if (or(selectsCol(k), selectsAll(k))) LAST_ROW
164
+ else if (collapses(k)) newRow(k)
165
+ else if (clears(k)) row
166
+ else selR1
167
+ def keyWantC0(k: str): int =
168
+ if (extendsSel(k)) lowerOf(anchorCol, newCol(k))
169
+ else if (selectsCol(k)) col
170
+ else if (or(selectsRow(k), selectsAll(k))) 0
171
+ else if (collapses(k)) newCol(k)
172
+ else if (clears(k)) col
173
+ else selC0
174
+ def keyWantC1(k: str): int =
175
+ if (extendsSel(k)) upperOf(anchorCol, newCol(k))
176
+ else if (selectsCol(k)) col
177
+ else if (or(selectsRow(k), selectsAll(k))) LAST_COL
178
+ else if (collapses(k)) newCol(k)
179
+ else if (clears(k)) col
180
+ else selC1
181
+ def keyWantPacked(k: str): int =
182
+ selectionPacked(keyWantActive(k), keyWantR0(k), keyWantR1(k), keyWantC0(k), keyWantC1(k))
183
+ def keyMoves(k: str): bool = keyWantPacked(k) != nowPacked
184
+ // a click collapses the selection: the intent is "nothing selected"
185
+ def clickMoves: bool = nowPacked != 0
186
+ // Shift+click extends from the anchor; on a non-selectable grid it degrades to a click
187
+ def shiftWantR0(cell: int): int = if (selectable) lowerOf(anchorRow, cell / COLS) else cell / COLS
188
+ def shiftWantR1(cell: int): int = if (selectable) upperOf(anchorRow, cell / COLS) else cell / COLS
189
+ def shiftWantC0(cell: int): int = if (selectable) lowerOf(anchorCol, cell % COLS) else cell % COLS
190
+ def shiftWantC1(cell: int): int = if (selectable) upperOf(anchorCol, cell % COLS) else cell % COLS
191
+ def shiftWantPacked(cell: int): int =
192
+ selectionPacked(selectable, shiftWantR0(cell), shiftWantR1(cell), shiftWantC0(cell), shiftWantC1(cell))
193
+ def shiftMoves(cell: int): bool = shiftWantPacked(cell) != nowPacked
194
+ // turning selection support off clears an existing selection: a real request only then
195
+ def setSelMoves(flag: bool): bool = and(not(flag), nowPacked != 0)
196
+ // the host flips its own copy: nothing -> the focus cell, else nothing
197
+ def togglePacked: int =
198
+ if (selActive) 0 else selectionPacked(true, row, row, col, col)
199
+
200
+ action init = all {
201
+ row' = 0,
202
+ col' = 0,
203
+ anchorRow' = 0,
204
+ anchorCol' = 0,
205
+ selActive' = false,
206
+ selR0' = 0,
207
+ selR1' = 0,
208
+ selC0' = 0,
209
+ selC1' = 0,
210
+ hasFocus' = false,
211
+ selectable' = true,
212
+ controlled' = false,
213
+ accepting' = false,
214
+ valueProp' = 0,
215
+ requested' = 0,
216
+ outcome' = "none",
217
+ base' = 0,
218
+ requests' = 0
219
+ }
220
+
221
+ // request: a keydown while the grid has DOM focus
222
+ action key = {
223
+ nondet key = Set(
224
+ "ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp",
225
+ "Shift+ArrowRight", "Shift+ArrowLeft", "Shift+ArrowDown", "Shift+ArrowUp",
226
+ "Shift+Space", "Control+Space", "Control+a", "Escape"
227
+ ).oneOf()
228
+ all {
229
+ row' = if (moves(key)) newRow(key) else row,
230
+ col' = if (moves(key)) newCol(key) else col,
231
+ anchorRow' =
232
+ if (collapses(key)) newRow(key)
233
+ else if (reanchors(key)) row
234
+ else anchorRow,
235
+ anchorCol' =
236
+ if (collapses(key)) newCol(key)
237
+ else if (reanchors(key)) col
238
+ else anchorCol,
239
+ selActive' = landed(holds, selActive, keyWantActive(key)),
240
+ selR0' = landedI(holds, selR0, keyWantR0(key)),
241
+ selR1' = landedI(holds, selR1, keyWantR1(key)),
242
+ selC0' = landedI(holds, selC0, keyWantC0(key)),
243
+ selC1' = landedI(holds, selC1, keyWantC1(key)),
244
+ hasFocus' = hasFocus,
245
+ selectable' = selectable,
246
+ controlled' = controlled,
247
+ accepting' = accepting,
248
+ valueProp' = if (keyMoves(key)) propAfter(follows, valueProp, keyWantPacked(key)) else valueProp,
249
+ requested' = if (keyMoves(key)) keyWantPacked(key) else requested,
250
+ outcome' = if (keyMoves(key)) verdict else outcome,
251
+ base' = if (keyMoves(key)) nowPacked else base,
252
+ requests' = if (keyMoves(key)) requests + 1 else requests
253
+ }
254
+ }
255
+
256
+ // request: pointer on a cell: focuses it, collapses the selection onto it
257
+ action click = {
258
+ nondet cell = Set(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11).oneOf()
259
+ all {
260
+ row' = cell / COLS,
261
+ col' = cell % COLS,
262
+ anchorRow' = cell / COLS,
263
+ anchorCol' = cell % COLS,
264
+ selActive' = landed(holds, selActive, false),
265
+ selR0' = landedI(holds, selR0, cell / COLS),
266
+ selR1' = landedI(holds, selR1, cell / COLS),
267
+ selC0' = landedI(holds, selC0, cell % COLS),
268
+ selC1' = landedI(holds, selC1, cell % COLS),
269
+ hasFocus' = true,
270
+ selectable' = selectable,
271
+ controlled' = controlled,
272
+ accepting' = accepting,
273
+ valueProp' = if (clickMoves) propAfter(follows, valueProp, 0) else valueProp,
274
+ requested' = if (clickMoves) 0 else requested,
275
+ outcome' = if (clickMoves) verdict else outcome,
276
+ base' = if (clickMoves) nowPacked else base,
277
+ requests' = if (clickMoves) requests + 1 else requests
278
+ }
279
+ }
280
+
281
+ // request: Shift+click extends from the anchor to the clicked cell
282
+ action shift_click = {
283
+ nondet cell = Set(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11).oneOf()
284
+ all {
285
+ row' = cell / COLS,
286
+ col' = cell % COLS,
287
+ anchorRow' = if (selectable) anchorRow else cell / COLS,
288
+ anchorCol' = if (selectable) anchorCol else cell % COLS,
289
+ selActive' = landed(holds, selActive, selectable),
290
+ selR0' = landedI(holds, selR0, shiftWantR0(cell)),
291
+ selR1' = landedI(holds, selR1, shiftWantR1(cell)),
292
+ selC0' = landedI(holds, selC0, shiftWantC0(cell)),
293
+ selC1' = landedI(holds, selC1, shiftWantC1(cell)),
294
+ hasFocus' = true,
295
+ selectable' = selectable,
296
+ controlled' = controlled,
297
+ accepting' = accepting,
298
+ valueProp' = if (shiftMoves(cell)) propAfter(follows, valueProp, shiftWantPacked(cell)) else valueProp,
299
+ requested' = if (shiftMoves(cell)) shiftWantPacked(cell) else requested,
300
+ outcome' = if (shiftMoves(cell)) verdict else outcome,
301
+ base' = if (shiftMoves(cell)) nowPacked else base,
302
+ requests' = if (shiftMoves(cell)) requests + 1 else requests
303
+ }
304
+ }
305
+
306
+ action focus_in = all {
307
+ row' = row,
308
+ col' = col,
309
+ anchorRow' = anchorRow,
310
+ anchorCol' = anchorCol,
311
+ selActive' = selActive,
312
+ selR0' = selR0,
313
+ selR1' = selR1,
314
+ selC0' = selC0,
315
+ selC1' = selC1,
316
+ hasFocus' = true,
317
+ selectable' = selectable,
318
+ controlled' = controlled,
319
+ accepting' = accepting,
320
+ valueProp' = valueProp,
321
+ requested' = requested,
322
+ outcome' = outcome,
323
+ base' = base,
324
+ requests' = requests
325
+ }
326
+
327
+ // blur keeps the selection
328
+ action focus_out = all {
329
+ row' = row,
330
+ col' = col,
331
+ anchorRow' = anchorRow,
332
+ anchorCol' = anchorCol,
333
+ selActive' = selActive,
334
+ selR0' = selR0,
335
+ selR1' = selR1,
336
+ selC0' = selC0,
337
+ selC1' = selC1,
338
+ hasFocus' = false,
339
+ selectable' = selectable,
340
+ controlled' = controlled,
341
+ accepting' = accepting,
342
+ valueProp' = valueProp,
343
+ requested' = requested,
344
+ outcome' = outcome,
345
+ base' = base,
346
+ requests' = requests
347
+ }
348
+
349
+ // request: turning selection support off clears the selection
350
+ action set_selectable = {
351
+ nondet flag = Set(true, false).oneOf()
352
+ all {
353
+ row' = row,
354
+ col' = col,
355
+ anchorRow' = if (flag) anchorRow else row,
356
+ anchorCol' = if (flag) anchorCol else col,
357
+ selActive' = if (flag) selActive else landed(holds, selActive, false),
358
+ selR0' = if (flag) selR0 else landedI(holds, selR0, row),
359
+ selR1' = if (flag) selR1 else landedI(holds, selR1, row),
360
+ selC0' = if (flag) selC0 else landedI(holds, selC0, col),
361
+ selC1' = if (flag) selC1 else landedI(holds, selC1, col),
362
+ hasFocus' = hasFocus,
363
+ selectable' = flag,
364
+ controlled' = controlled,
365
+ accepting' = accepting,
366
+ valueProp' = if (setSelMoves(flag)) propAfter(follows, valueProp, 0) else valueProp,
367
+ requested' = if (setSelMoves(flag)) 0 else requested,
368
+ outcome' = if (setSelMoves(flag)) verdict else outcome,
369
+ base' = if (setSelMoves(flag)) nowPacked else base,
370
+ requests' = if (setSelMoves(flag)) requests + 1 else requests
371
+ }
372
+ }
373
+
374
+ // commit: the host writes the controlled selection (a no-op while uncontrolled)
375
+ action setValue = {
376
+ // A representative spread (none, a cell, a rectangle, the full grid) keeps
377
+ // the commit path covered without exploding the bounded exploration.
378
+ nondet v = Set(0, 1, 6, 11, 49, 65, 97, 144).oneOf()
379
+ all {
380
+ row' = row,
381
+ col' = col,
382
+ anchorRow' = anchorRow,
383
+ anchorCol' = anchorCol,
384
+ selActive' = if (controlled) v != 0 else selActive,
385
+ selR0' = if (controlled) (if (v == 0) row else unpackR0(v)) else selR0,
386
+ selR1' = if (controlled) (if (v == 0) row else unpackR1(v)) else selR1,
387
+ selC0' = if (controlled) (if (v == 0) col else unpackC0(v)) else selC0,
388
+ selC1' = if (controlled) (if (v == 0) col else unpackC1(v)) else selC1,
389
+ hasFocus' = hasFocus,
390
+ selectable' = selectable,
391
+ controlled' = controlled,
392
+ accepting' = accepting,
393
+ valueProp' = if (controlled) v else valueProp,
394
+ requested' = requested,
395
+ outcome' = if (controlled) "none" else outcome,
396
+ base' = base,
397
+ requests' = requests
398
+ }
399
+ }
400
+
401
+ // commit: the host flips its own copy (nothing -> the focus cell, else nothing)
402
+ action hostToggle = all {
403
+ row' = row,
404
+ col' = col,
405
+ anchorRow' = anchorRow,
406
+ anchorCol' = anchorCol,
407
+ selActive' = if (controlled) togglePacked != 0 else selActive,
408
+ selR0' = if (controlled) (if (togglePacked == 0) row else unpackR0(togglePacked)) else selR0,
409
+ selR1' = if (controlled) (if (togglePacked == 0) row else unpackR1(togglePacked)) else selR1,
410
+ selC0' = if (controlled) (if (togglePacked == 0) col else unpackC0(togglePacked)) else selC0,
411
+ selC1' = if (controlled) (if (togglePacked == 0) col else unpackC1(togglePacked)) else selC1,
412
+ hasFocus' = hasFocus,
413
+ selectable' = selectable,
414
+ controlled' = controlled,
415
+ accepting' = accepting,
416
+ valueProp' = if (controlled) togglePacked else valueProp,
417
+ requested' = requested,
418
+ outcome' = if (controlled) "none" else outcome,
419
+ base' = base,
420
+ requests' = requests
421
+ }
422
+
423
+ // configuration: the host starts owning the selection, adopting the current one
424
+ action control = all {
425
+ row' = row,
426
+ col' = col,
427
+ anchorRow' = anchorRow,
428
+ anchorCol' = anchorCol,
429
+ selActive' = selActive,
430
+ selR0' = selR0,
431
+ selR1' = selR1,
432
+ selC0' = selC0,
433
+ selC1' = selC1,
434
+ hasFocus' = hasFocus,
435
+ selectable' = selectable,
436
+ controlled' = true,
437
+ accepting' = accepting,
438
+ valueProp' = nowPacked,
439
+ requested' = requested,
440
+ outcome' = outcome,
441
+ base' = base,
442
+ requests' = requests
443
+ }
444
+
445
+ // configuration: the controlled host answers every request with its intent
446
+ action acceptRequests = all {
447
+ row' = row,
448
+ col' = col,
449
+ anchorRow' = anchorRow,
450
+ anchorCol' = anchorCol,
451
+ selActive' = selActive,
452
+ selR0' = selR0,
453
+ selR1' = selR1,
454
+ selC0' = selC0,
455
+ selC1' = selC1,
456
+ hasFocus' = hasFocus,
457
+ selectable' = selectable,
458
+ controlled' = controlled,
459
+ accepting' = true,
460
+ valueProp' = valueProp,
461
+ requested' = requested,
462
+ outcome' = outcome,
463
+ base' = base,
464
+ requests' = requests
465
+ }
466
+
467
+ action step = any {
468
+ init, key, click, shift_click, focus_in, focus_out, set_selectable,
469
+ setValue, hostToggle, control, acceptRequests
470
+ }
471
+
472
+ val FocusInBounds: bool = and(row >= 0, row < ROWS, col >= 0, col < COLS)
473
+ val AnchorInBounds: bool = and(anchorRow >= 0, anchorRow < ROWS, anchorCol >= 0, anchorCol < COLS)
474
+ // the rectangle is well-formed and inside the grid, active or not
475
+ val SelectionRectOrdered: bool = and(
476
+ selR0 >= 0, selR0 <= selR1, selR1 < ROWS,
477
+ selC0 >= 0, selC0 <= selC1, selC1 < COLS
478
+ )
479
+ // the focused cell is always part of an active selection (so it has aria-selected=true);
480
+ // a controlled host owns the value, so focus may leave the held rectangle
481
+ val ActiveSelectionContainsFocus: bool = or(controlled, or(not(selActive), cellSelected(row, col)))
482
+ // ... and so is the anchor (uncontrolled)
483
+ val ActiveSelectionContainsAnchor: bool = or(controlled, or(not(selActive), cellSelected(anchorRow, anchorCol)))
484
+ // no selection: the rectangle and anchor collapse onto the focus cell (uncontrolled)
485
+ val InactiveSelectionCollapsed: bool = or(
486
+ controlled,
487
+ or(
488
+ selActive,
489
+ and(selR0 == row, selR1 == row, selC0 == col, selC1 == col, anchorRow == row, anchorCol == col)
490
+ )
491
+ )
492
+ // a grid without selection support has no selection (uncontrolled)
493
+ val SelectionOnlyWhenSelectable: bool = or(controlled, or(not(selActive), selectable))
494
+ // a full-width selection marks every cell of the focused row selected
495
+ val FullWidthSelectionIsRowSelection: bool = or(
496
+ not(rowSelected(row)),
497
+ and(cellSelected(row, 0), cellSelected(row, 1), cellSelected(row, 2))
498
+ )
499
+ // a full-height selection marks every cell of the focused column selected
500
+ val FullHeightSelectionIsColumnSelection: bool = or(
501
+ not(colSelected(col)),
502
+ and(cellSelected(0, col), cellSelected(1, col), cellSelected(2, col), cellSelected(3, col))
503
+ )
504
+ // callbacks only ever count up from zero
505
+ val RequestsNonNegative: bool = requests >= 0
506
+ // controlled: only the value prop sets the state, so no event commits
507
+ val ControlledFollowsProp: bool = or(not(controlled), nowPacked == valueProp)
508
+ // a rejected request leaves the state as it found it
509
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), nowPacked == base)
510
+ // an accepted request committed exactly its intent
511
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), nowPacked == requested)
512
+ }