@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,330 @@
1
+ // WAI-ARIA APG — grid: sortable column headers (data grid example 2).
2
+ //
3
+ // Modelled from https://www.w3.org/WAI/ARIA/apg/patterns/grid/ ("WAI-ARIA Roles,
4
+ // States, and Properties": aria-sort on the header cell of the sorted column; "if
5
+ // column or row header cells do not provide functions, such as sort or filter,
6
+ // they do not need to be focusable") and from the "Sortable Data Grid With Editable
7
+ // Cells" example in https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/data-grids/
8
+ // (aria-sort ascending / descending / none; headers focusable because Date and
9
+ // Amount sort). Also the Grid and Table Properties Practice referenced there
10
+ // (one sorted column at a time).
11
+ //
12
+ // The machine is the header row only: roving focus among the COLS column headers,
13
+ // Enter / Space / click activate the sort button of a header. Columns 0 and 2 are
14
+ // sortable, column 1 is not. aria-sort per header is derived:
15
+ // absent - the column is not sortable (or sorting is disabled);
16
+ // "none" - sortable but not the sorted column;
17
+ // "ascending" / "descending" - the sorted column.
18
+ //
19
+ // Deliberate decisions where APG is silent:
20
+ // - activating an unsorted sortable header sorts it ascending and resets the
21
+ // previously sorted header to "none"; activating the sorted header toggles
22
+ // ascending <-> descending (it never returns to "none");
23
+ // - activating a non-sortable header only moves focus;
24
+ // - header focus does not wrap; keys are ignored without DOM focus;
25
+ // - disabling sorting clears the sort and removes aria-sort everywhere.
26
+ //
27
+ // Request vs commit (map ticket rt1p). The sort — a (column, direction) pair — is
28
+ // the value, packed into `sortPacked` (-1 = unsorted). Every event a user sees as
29
+ // a sort — `key` (Enter/Space on a sortable header) and `click` — is a *request*
30
+ // carrying an intent (`requested`); it fires onValueChange (`requests` counts the
31
+ // calls) when the intent differs from the current value, and then:
32
+ // uncontrolled the request is accepted: the sort commits the intent
33
+ // controlled, holds the host ignores it ("rejected"): the sort is untouched
34
+ // controlled, accepts the host answers with the same value (`accepting`): the
35
+ // sort commits through the controlled value, not the event
36
+ // `valueProp` is the only thing that changes the sort while `controlled`;
37
+ // `setValue` / `hostToggle` are the host writing it. `control` / `acceptRequests`
38
+ // are configuration events (init is literal-only) and `set_sort_enabled` is an
39
+ // ordinary prop change (a request when it clears an existing sort). Invariants for
40
+ // the model: ControlledFollowsProp, RejectedRequestUntouched,
41
+ // AcceptedRequestCommits; `NoSortWhenDisabled` is scoped to the uncontrolled path
42
+ // (a controlled host owns the value and may hold a sort while sorting is off).
43
+ //
44
+ // Deliberate omissions: Up/Down between the header row and the body cells (compose
45
+ // with grid.qnt by treating the header as row -1), the data rows' order itself,
46
+ // aria-live sort announcements, column hiding (aria-colindex gaps), the
47
+ // "headers are not focusable if they have no function" variant (here every header
48
+ // is focusable whenever sorting is enabled), filter buttons.
49
+ //
50
+ // Generator gaps hit: no sets/lists/maps (the sorted column is an int, -1 = none);
51
+ // no `const` (sizes are zero-argument `val`s); one nondet per action.
52
+ module apg_grid_sort {
53
+ var headerCol: int // focused header column, 0-based (the roving tab stop)
54
+ var hasFocus: bool // DOM focus is on a header cell
55
+ var sortEnabled: bool // the grid offers sorting at all
56
+ var sortCol: int // sorted column, -1 = unsorted
57
+ var sortDir: str // "none" | "ascending" | "descending"
58
+ var controlled: bool // the host owns the sort (`value` prop present)
59
+ var accepting: bool // controlled host that answers every request with its intent
60
+ var valueProp: int // the controlled value prop (packed, -1 = unsorted)
61
+ var requested: int // intent of the last request (packed)
62
+ var outcome: str // "none" | "accepted" | "rejected"
63
+ var base: int // value when the last request was received (packed)
64
+ var requests: int // number of requests that fired onValueChange
65
+
66
+ val COLS: int = 3
67
+ val LAST_COL: int = COLS - 1
68
+
69
+ // columns 0 (Date) and 2 (Amount) sort; column 1 (Type) does not
70
+ def sortable(c: int): bool = c != 1
71
+
72
+ // --- ARIA contract (derived) ---
73
+ // "absent" means the aria-sort attribute is omitted
74
+ def ariaSort(c: int): str =
75
+ if (not(and(sortEnabled, sortable(c)))) "absent"
76
+ else if (c == sortCol) sortDir
77
+ else "none"
78
+ def headerTabindex(c: int): int = if (c == headerCol) 0 else -1
79
+ def isSortedHeader(c: int): bool = or(ariaSort(c) == "ascending", ariaSort(c) == "descending")
80
+ def sortedHeaders: int =
81
+ (if (isSortedHeader(0)) 1 else 0) + (if (isSortedHeader(1)) 1 else 0) + (if (isSortedHeader(2)) 1 else 0)
82
+ def headerTabStops: int =
83
+ (if (headerTabindex(0) == 0) 1 else 0) + (if (headerTabindex(1) == 0) 1 else 0) + (if (headerTabindex(2) == 0) 1 else 0)
84
+
85
+ // sorting by column c (a no-op for unsortable columns / disabled sorting)
86
+ def sorts(c: int): bool = and(sortEnabled, sortable(c))
87
+ def nextDir(c: int): str =
88
+ if (and(c == sortCol, sortDir == "ascending")) "descending" else "ascending"
89
+
90
+ def keyCol(k: str): int =
91
+ if (k == "ArrowRight") (if (headerCol < LAST_COL) headerCol + 1 else headerCol)
92
+ else if (k == "ArrowLeft") (if (headerCol > 0) headerCol - 1 else headerCol)
93
+ else if (k == "Home") 0
94
+ else if (k == "End") LAST_COL
95
+ else headerCol
96
+ def activates(k: str): bool = and(hasFocus, or(k == "Enter", k == "Space"), sorts(headerCol))
97
+
98
+ // --- request / commit (map ticket rt1p) ----------------------------------
99
+ def moved(before: int, after: int): int = if (before == after) 0 else 1
100
+ // injective: -1 unsorted; else 2*column + (descending ? 1 : 0)
101
+ def sortPacked(c: int, d: str): int =
102
+ if (c == -1) -1 else 2 * c + (if (d == "descending") 1 else 0)
103
+ def unpackCol(v: int): int = if (v == -1) -1 else v / 2
104
+ def unpackDir(v: int): str =
105
+ if (v == -1) "none" else if (v % 2 == 1) "descending" else "ascending"
106
+ def nowPacked: int = sortPacked(sortCol, sortDir)
107
+ // controlled host that keeps the old value: the request is rejected
108
+ def holds: bool = and(controlled, not(accepting))
109
+ // controlled host that writes the intent back through the value prop
110
+ def follows: bool = and(controlled, accepting)
111
+ def verdict: str = if (holds) "rejected" else "accepted"
112
+ // the value after a request for `want`
113
+ def landedI(h: bool, current: int, want: int): int = if (h) current else want
114
+ def landedS(h: bool, current: str, want: str): str = if (h) current else want
115
+ // the value prop after a request for `want`
116
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
117
+ // the request intent of a key / a click, per member and packed
118
+ def keyWantCol(k: str): int = if (activates(k)) headerCol else sortCol
119
+ def keyWantDir(k: str): str = if (activates(k)) nextDir(headerCol) else sortDir
120
+ def keyWantPacked(k: str): int = sortPacked(keyWantCol(k), keyWantDir(k))
121
+ def keyMoves(k: str): bool = keyWantPacked(k) != nowPacked
122
+ def clickWantCol(c: int): int = if (sorts(c)) c else sortCol
123
+ def clickWantDir(c: int): str = if (sorts(c)) nextDir(c) else sortDir
124
+ def clickWantPacked(c: int): int = sortPacked(clickWantCol(c), clickWantDir(c))
125
+ def clickMoves(c: int): bool = clickWantPacked(c) != nowPacked
126
+ // disabling sorting clears an existing sort: a real request only then
127
+ def setEnabledMoves(f: bool): bool = and(not(f), nowPacked != -1)
128
+ // the host flips its own copy: None -> column 0 ascending, else flip the direction
129
+ def flipDir(d: str): str = if (d == "ascending") "descending" else "ascending"
130
+ def togglePacked: int =
131
+ if (sortCol == -1) sortPacked(0, "ascending") else sortPacked(sortCol, flipDir(sortDir))
132
+
133
+ action init = all {
134
+ headerCol' = 0,
135
+ hasFocus' = false,
136
+ sortEnabled' = true,
137
+ sortCol' = -1,
138
+ sortDir' = "none",
139
+ controlled' = false,
140
+ accepting' = false,
141
+ valueProp' = -1,
142
+ requested' = -1,
143
+ outcome' = "none",
144
+ base' = -1,
145
+ requests' = 0
146
+ }
147
+
148
+ // request: a keydown on the header row (Enter / Space activate a sortable column)
149
+ action key = {
150
+ nondet key = Set("ArrowRight", "ArrowLeft", "Home", "End", "Enter", "Space").oneOf()
151
+ all {
152
+ headerCol' = if (hasFocus) keyCol(key) else headerCol,
153
+ hasFocus' = hasFocus,
154
+ sortEnabled' = sortEnabled,
155
+ sortCol' = landedI(holds, sortCol, keyWantCol(key)),
156
+ sortDir' = landedS(holds, sortDir, keyWantDir(key)),
157
+ controlled' = controlled,
158
+ accepting' = accepting,
159
+ valueProp' = if (keyMoves(key)) propAfter(follows, valueProp, keyWantPacked(key)) else valueProp,
160
+ requested' = if (keyMoves(key)) keyWantPacked(key) else requested,
161
+ outcome' = if (keyMoves(key)) verdict else outcome,
162
+ base' = if (keyMoves(key)) nowPacked else base,
163
+ requests' = if (keyMoves(key)) requests + 1 else requests
164
+ }
165
+ }
166
+
167
+ // request: pointer on a header: focuses it and activates its sort button
168
+ action click = {
169
+ nondet column = Set(0, 1, 2).oneOf()
170
+ all {
171
+ headerCol' = column,
172
+ hasFocus' = true,
173
+ sortEnabled' = sortEnabled,
174
+ sortCol' = landedI(holds, sortCol, clickWantCol(column)),
175
+ sortDir' = landedS(holds, sortDir, clickWantDir(column)),
176
+ controlled' = controlled,
177
+ accepting' = accepting,
178
+ valueProp' = if (clickMoves(column)) propAfter(follows, valueProp, clickWantPacked(column)) else valueProp,
179
+ requested' = if (clickMoves(column)) clickWantPacked(column) else requested,
180
+ outcome' = if (clickMoves(column)) verdict else outcome,
181
+ base' = if (clickMoves(column)) nowPacked else base,
182
+ requests' = if (clickMoves(column)) requests + 1 else requests
183
+ }
184
+ }
185
+
186
+ action focus_in = all {
187
+ headerCol' = headerCol,
188
+ hasFocus' = true,
189
+ sortEnabled' = sortEnabled,
190
+ sortCol' = sortCol,
191
+ sortDir' = sortDir,
192
+ controlled' = controlled,
193
+ accepting' = accepting,
194
+ valueProp' = valueProp,
195
+ requested' = requested,
196
+ outcome' = outcome,
197
+ base' = base,
198
+ requests' = requests
199
+ }
200
+
201
+ action focus_out = all {
202
+ headerCol' = headerCol,
203
+ hasFocus' = false,
204
+ sortEnabled' = sortEnabled,
205
+ sortCol' = sortCol,
206
+ sortDir' = sortDir,
207
+ controlled' = controlled,
208
+ accepting' = accepting,
209
+ valueProp' = valueProp,
210
+ requested' = requested,
211
+ outcome' = outcome,
212
+ base' = base,
213
+ requests' = requests
214
+ }
215
+
216
+ // prop change: disabling sorting requests clearing the sort
217
+ action set_sort_enabled = {
218
+ nondet flag = Set(true, false).oneOf()
219
+ all {
220
+ headerCol' = headerCol,
221
+ hasFocus' = hasFocus,
222
+ sortEnabled' = flag,
223
+ sortCol' = if (flag) sortCol else landedI(holds, sortCol, -1),
224
+ sortDir' = if (flag) sortDir else landedS(holds, sortDir, "none"),
225
+ controlled' = controlled,
226
+ accepting' = accepting,
227
+ valueProp' = if (setEnabledMoves(flag)) propAfter(follows, valueProp, -1) else valueProp,
228
+ requested' = if (setEnabledMoves(flag)) -1 else requested,
229
+ outcome' = if (setEnabledMoves(flag)) verdict else outcome,
230
+ base' = if (setEnabledMoves(flag)) nowPacked else base,
231
+ requests' = if (setEnabledMoves(flag)) requests + 1 else requests
232
+ }
233
+ }
234
+
235
+ // commit: the host writes the controlled sort (a no-op while uncontrolled)
236
+ action setValue = {
237
+ nondet v = Set(-1, 0, 1, 4, 5).oneOf()
238
+ all {
239
+ headerCol' = headerCol,
240
+ hasFocus' = hasFocus,
241
+ sortEnabled' = sortEnabled,
242
+ sortCol' = if (controlled) unpackCol(v) else sortCol,
243
+ sortDir' = if (controlled) unpackDir(v) else sortDir,
244
+ controlled' = controlled,
245
+ accepting' = accepting,
246
+ valueProp' = if (controlled) v else valueProp,
247
+ requested' = requested,
248
+ outcome' = if (controlled) "none" else outcome,
249
+ base' = base,
250
+ requests' = requests
251
+ }
252
+ }
253
+
254
+ // commit: the host flips its own copy of the sort (an outside button)
255
+ action hostToggle = all {
256
+ headerCol' = headerCol,
257
+ hasFocus' = hasFocus,
258
+ sortEnabled' = sortEnabled,
259
+ sortCol' = if (controlled) unpackCol(togglePacked) else sortCol,
260
+ sortDir' = if (controlled) unpackDir(togglePacked) else sortDir,
261
+ controlled' = controlled,
262
+ accepting' = accepting,
263
+ valueProp' = if (controlled) togglePacked else valueProp,
264
+ requested' = requested,
265
+ outcome' = if (controlled) "none" else outcome,
266
+ base' = base,
267
+ requests' = requests
268
+ }
269
+
270
+ // configuration: the host starts owning the sort, adopting the current one
271
+ action control = all {
272
+ headerCol' = headerCol,
273
+ hasFocus' = hasFocus,
274
+ sortEnabled' = sortEnabled,
275
+ sortCol' = sortCol,
276
+ sortDir' = sortDir,
277
+ controlled' = true,
278
+ accepting' = accepting,
279
+ valueProp' = nowPacked,
280
+ requested' = requested,
281
+ outcome' = outcome,
282
+ base' = base,
283
+ requests' = requests
284
+ }
285
+
286
+ // configuration: the controlled host answers every request with its intent
287
+ action acceptRequests = all {
288
+ headerCol' = headerCol,
289
+ hasFocus' = hasFocus,
290
+ sortEnabled' = sortEnabled,
291
+ sortCol' = sortCol,
292
+ sortDir' = sortDir,
293
+ controlled' = controlled,
294
+ accepting' = true,
295
+ valueProp' = valueProp,
296
+ requested' = requested,
297
+ outcome' = outcome,
298
+ base' = base,
299
+ requests' = requests
300
+ }
301
+
302
+ action step = any {
303
+ init, key, click, focus_in, focus_out, set_sort_enabled,
304
+ setValue, hostToggle, control, acceptRequests
305
+ }
306
+
307
+ val HeaderFocusInBounds: bool = and(headerCol >= 0, headerCol < COLS)
308
+ // roving tabindex across the header row: exactly one header has tabindex=0
309
+ val ExactlyOneHeaderTabStop: bool = headerTabStops == 1
310
+ // at most one header carries ascending/descending
311
+ val AtMostOneSortedHeader: bool = sortedHeaders <= 1
312
+ // a sorted column is a sortable column; unsorted means direction "none"
313
+ val SortedColumnIsSortable: bool = or(sortCol == -1, and(sortCol >= 0, sortCol < COLS, sortable(sortCol)))
314
+ val UnsortedMeansNoDirection: bool = or(sortCol != -1, sortDir == "none")
315
+ val SortedMeansDirection: bool = or(sortCol == -1, or(sortDir == "ascending", sortDir == "descending"))
316
+ // no sort state (and so no aria-sort) when sorting is disabled; a controlled
317
+ // host owns the value and may hold a sort while sorting is turned off
318
+ val NoSortWhenDisabled: bool = or(controlled, or(sortEnabled, and(sortCol == -1, sortDir == "none")))
319
+ // a non-sortable header never exposes aria-sort
320
+ val NonSortableHeaderHasNoAriaSort: bool = ariaSort(1) == "absent"
321
+ val SortDirValid: bool = or(sortDir == "none", sortDir == "ascending", sortDir == "descending")
322
+ // callbacks only ever count up from zero
323
+ val RequestsNonNegative: bool = requests >= 0
324
+ // controlled: only the value prop sets the state, so no event commits
325
+ val ControlledFollowsProp: bool = or(not(controlled), nowPacked == valueProp)
326
+ // a rejected request leaves the state as it found it
327
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), nowPacked == base)
328
+ // an accepted request committed exactly its intent
329
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), nowPacked == requested)
330
+ }