@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,633 @@
1
+ // APG combobox — the editable combobox with a tree popup (aria-haspopup="tree").
2
+ //
3
+ // Sources (W3C WAI-ARIA Authoring Practices Guide):
4
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
5
+ // - "Keyboard Interaction" (Combobox + Tree Popup Keyboard Interaction and
6
+ // its note on parent nodes that are category labels), "WAI-ARIA Roles,
7
+ // States, and Properties" (aria-haspopup, aria-activedescendant,
8
+ // aria-selected on treeitem)
9
+ // https://www.w3.org/WAI/ARIA/apg/patterns/treeview/ (popup roles / states
10
+ // are defined there; only the combobox-relevant subset is modelled)
11
+ //
12
+ // Modelled behaviour
13
+ // - DOM focus stays on the input; the visually focused tree node is exposed
14
+ // through aria-activedescendant (`active`, NONE = visual focus on the
15
+ // textbox). A node can only be focused while the popup is open and the node
16
+ // is currently visible (all its ancestors expanded).
17
+ // - Tree: 0 Fruits (parent) {1 Apple, 2 Banana}, 3 Vegetables (parent)
18
+ // {4 Carrot, 5 Dill}, 6 Water (root leaf); parents 0 and 3 expand with
19
+ // Right / collapse with Left (`e0`, `e3`).
20
+ // - Variants of the "selection" note (`labelMode`, prop `set_label_mode`):
21
+ // 0 every node, parents included, is a selectable value, so selection
22
+ // follows focus everywhere;
23
+ // 1 parents are category labels; focusing one leaves the previously
24
+ // selected node selected ("sticky");
25
+ // 2 parents are category labels; focusing one leaves nothing selected.
26
+ // Focus is distinct from selection: `active` vs `selected` (aria-selected).
27
+ // - Down / Up move to the next / previous visible node without opening or
28
+ // closing nodes (no wrap); on a closed popup they open it on the first /
29
+ // last visible node. Right: closed parent -> expand (focus and selection do
30
+ // not move), open parent -> first child, leaf -> nothing. Left: open parent
31
+ // -> collapse, child that is a leaf / closed -> its parent, root -> nothing.
32
+ // Home / End focus the first / last visible node. Left / Right only act on
33
+ // tree nodes while one is focused.
34
+ // - Enter accepts the *selected* node (so a focused non-selectable parent in
35
+ // labelMode 2 accepts nothing) and closes; Escape closes an open popup keeping
36
+ // the value and clears the textbox when already closed; typing returns the
37
+ // visual focus to the textbox without closing the popup; blur / Tab close
38
+ // the popup committing nothing.
39
+ // - Pointer: click on the input toggles the popup; clicking a selectable node
40
+ // commits it, clicking a category label toggles it and focuses it.
41
+ // - Prop changes: `set_label_mode`, `set_value` (non-selectable values ignored).
42
+ //
43
+ // Deliberate omissions
44
+ // - Typed text and filtering (see combobox-autocomplete), horizontal
45
+ // orientation (Down/Right swap), more than two tree levels, typeahead for
46
+ // non-editable trees, aria-level / aria-setsize / aria-posinset, scrolling.
47
+ // - Programmatic expand/collapse of nodes by props and `*` (expand all).
48
+ //
49
+ // Generator notes: no action preconditions (key actions guard their assignments
50
+ // with `focused` / `open`), only str/int/bool vars, a single nondet per action.
51
+ module combobox_tree {
52
+ var open: bool // tree popup displayed == aria-expanded
53
+ var active: int // aria-activedescendant node, NONE = textbox
54
+ var selected: int // node with aria-selected=true, NONE = none
55
+ var e0: bool // node 0 (Fruits) expanded
56
+ var e3: bool // node 3 (Vegetables) expanded
57
+ var focused: bool // DOM focus is on the input
58
+ var labelMode: int // 0 all selectable, 1 labels + sticky selection, 2 labels + cleared selection
59
+ var picked: int // committed node, NONE = none
60
+ var lastEvent: str // name of the last event, for transition invariants
61
+ var valueChanged: bool // the last event changed the committed value
62
+ var wasOpen: bool // the popup was open before the last event
63
+ var controlled: bool // the host owns the picked node (`value` prop present)
64
+ var accepting: bool // controlled host that answers every request with its intent
65
+ var valueProp: int // the controlled picked node
66
+ var requested: int // intent of the last request
67
+ var outcome: str // "none" | "accepted" | "rejected"
68
+ var base: int // picked when the last request was received
69
+ var requests: int // number of requests that fired a change callback
70
+
71
+ // --- configuration ------------------------------------------------------
72
+ def NONE: int = -1
73
+ def NODES: int = 7
74
+
75
+ // --- tree structure -------------------------------------------------------
76
+ def isParent(n: int): bool = or(n == 0, n == 3)
77
+ def selectable(v: int, n: int): bool = or(v == 0, not(isParent(n)))
78
+ def parentOf(n: int): int =
79
+ if (or(n == 1, n == 2)) 0 else if (or(n == 4, n == 5)) 3 else NONE
80
+ def firstChild(n: int): int = if (n == 0) 1 else if (n == 3) 4 else NONE
81
+ def expandedNode(n: int): bool = if (n == 0) e0 else if (n == 3) e3 else false
82
+ def vis(n: int): bool =
83
+ if (n < 0) false
84
+ else if (or(n == 0, n == 3)) true
85
+ else if (or(n == 1, n == 2)) e0
86
+ else if (or(n == 4, n == 5)) e3
87
+ else n == 6
88
+ def nextVis(n: int): int =
89
+ if (vis(n + 1)) n + 1 else if (vis(n + 2)) n + 2 else if (vis(n + 3)) n + 3
90
+ else if (vis(n + 4)) n + 4 else if (vis(n + 5)) n + 5 else if (vis(n + 6)) n + 6
91
+ else n
92
+ def prevVis(n: int): int =
93
+ if (vis(n - 1)) n - 1 else if (vis(n - 2)) n - 2 else if (vis(n - 3)) n - 3
94
+ else if (vis(n - 4)) n - 4 else if (vis(n - 5)) n - 5 else if (vis(n - 6)) n - 6
95
+ else n
96
+
97
+ // --- helpers ------------------------------------------------------------
98
+ // focus moved onto node m: selection follows focus when m is selectable,
99
+ // otherwise it is kept (labelMode 1) or cleared (labelMode 2)
100
+ def selectedFor(m: int): int =
101
+ if (selectable(labelMode, m)) m else (if (labelMode == 1) selected else NONE)
102
+ def nav: bool = and(focused, open)
103
+ def onNode: bool = and(nav, active >= 0)
104
+ def downTarget: int = if (and(open, active >= 0)) nextVis(active) else 0
105
+ def upTarget: int = if (and(open, active >= 0)) prevVis(active) else NODES - 1
106
+ def parentOpen: bool = and(isParent(active), expandedNode(active))
107
+ def rightTarget: int = if (and(onNode, parentOpen)) firstChild(active) else active
108
+ def leftTarget: int =
109
+ if (and(onNode, and(not(parentOpen), parentOf(active) >= 0))) parentOf(active) else active
110
+ def commitsSelected: bool = and(nav, selected >= 0)
111
+ def escapeClears: bool = and(and(focused, not(open)), picked >= 0)
112
+
113
+ // --- derived ARIA attribute contract -----------------------------------
114
+ def ariaHasPopup: str = "tree"
115
+ def ariaExpanded: bool = open
116
+ def ariaActivedescendant: int = if (open) active else NONE
117
+ def ariaSelected(n: int): bool = and(open, n == selected)
118
+ def selectedCount: int =
119
+ (if (ariaSelected(0)) 1 else 0) + (if (ariaSelected(1)) 1 else 0) +
120
+ (if (ariaSelected(2)) 1 else 0) + (if (ariaSelected(3)) 1 else 0) +
121
+ (if (ariaSelected(4)) 1 else 0) + (if (ariaSelected(5)) 1 else 0) +
122
+ (if (ariaSelected(6)) 1 else 0)
123
+ def isNavigation(e: str): bool =
124
+ or(e == "arrow_down", e == "arrow_up", e == "arrow_left", e == "arrow_right",
125
+ e == "home", e == "end")
126
+ def mayChangeValue(e: str): bool =
127
+ or(e == "enter", e == "click_node", e == "set_value", e == "set_label_mode",
128
+ e == "type_char", e == "escape")
129
+
130
+ // --- request / commit (map ticket rt1p) --------------------------------
131
+ // controlled host that keeps the old value: the request is rejected
132
+ def holds: bool = and(controlled, not(accepting))
133
+ // controlled host that writes the intent back through the value prop
134
+ def follows: bool = and(controlled, accepting)
135
+ def verdict: str = if (holds) "rejected" else "accepted"
136
+ def landedInt(h: bool, current: int, want: int): int = if (h) current else want
137
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
138
+
139
+ action init = all {
140
+ open' = false,
141
+ active' = NONE,
142
+ selected' = NONE,
143
+ e0' = false,
144
+ e3' = false,
145
+ focused' = false,
146
+ labelMode' = 0,
147
+ picked' = NONE,
148
+ lastEvent' = "init",
149
+ valueChanged' = false,
150
+ wasOpen' = false,
151
+ controlled' = false,
152
+ accepting' = false,
153
+ valueProp' = NONE,
154
+ requested' = NONE,
155
+ outcome' = "none",
156
+ base' = NONE,
157
+ requests' = 0
158
+ }
159
+
160
+ // --- focus / blur --------------------------------------------------------
161
+ action focus = all {
162
+ focused' = true,
163
+ lastEvent' = "focus",
164
+ valueChanged' = false,
165
+ wasOpen' = open,
166
+ open' = open,
167
+ active' = active,
168
+ selected' = selected,
169
+ e0' = e0,
170
+ e3' = e3,
171
+ labelMode' = labelMode,
172
+ picked' = landedInt(holds, picked, picked),
173
+ controlled' = controlled,
174
+ accepting' = accepting,
175
+ valueProp' = propAfter(follows, valueProp, picked),
176
+ requested' = if ((picked) != picked) picked else requested,
177
+ outcome' = if ((picked) != picked) verdict else outcome,
178
+ base' = if ((picked) != picked) picked else base,
179
+ requests' = if ((picked) != picked) requests + 1 else requests,
180
+ }
181
+
182
+ action blur = all {
183
+ open' = false,
184
+ active' = NONE,
185
+ selected' = NONE,
186
+ focused' = false,
187
+ lastEvent' = "blur",
188
+ valueChanged' = false,
189
+ wasOpen' = open,
190
+ e0' = e0,
191
+ e3' = e3,
192
+ labelMode' = labelMode,
193
+ picked' = landedInt(holds, picked, picked),
194
+ controlled' = controlled,
195
+ accepting' = accepting,
196
+ valueProp' = propAfter(follows, valueProp, picked),
197
+ requested' = if ((picked) != picked) picked else requested,
198
+ outcome' = if ((picked) != picked) verdict else outcome,
199
+ base' = if ((picked) != picked) picked else base,
200
+ requests' = if ((picked) != picked) requests + 1 else requests,
201
+ }
202
+
203
+ // Tab: the popup and its nodes are out of the Tab sequence
204
+ action tab = all {
205
+ open' = if (focused) false else open,
206
+ active' = if (focused) NONE else active,
207
+ selected' = if (focused) NONE else selected,
208
+ focused' = false,
209
+ lastEvent' = "tab",
210
+ valueChanged' = false,
211
+ wasOpen' = open,
212
+ e0' = e0,
213
+ e3' = e3,
214
+ labelMode' = labelMode,
215
+ picked' = landedInt(holds, picked, picked),
216
+ controlled' = controlled,
217
+ accepting' = accepting,
218
+ valueProp' = propAfter(follows, valueProp, picked),
219
+ requested' = if ((picked) != picked) picked else requested,
220
+ outcome' = if ((picked) != picked) verdict else outcome,
221
+ base' = if ((picked) != picked) picked else base,
222
+ requests' = if ((picked) != picked) requests + 1 else requests,
223
+ }
224
+
225
+ // typing: visual focus back to the textbox, the popup opens / stays open
226
+ action type_char = all {
227
+ open' = if (focused) true else open,
228
+ active' = if (focused) NONE else active,
229
+ selected' = if (focused) NONE else selected,
230
+ picked' = landedInt(holds, picked, if (focused) NONE else picked),
231
+ controlled' = controlled,
232
+ accepting' = accepting,
233
+ valueProp' = propAfter(follows, valueProp, if (focused) NONE else picked),
234
+ requested' = if ((if (focused) NONE else picked) != picked) if (focused) NONE else picked else requested,
235
+ outcome' = if ((if (focused) NONE else picked) != picked) verdict else outcome,
236
+ base' = if ((if (focused) NONE else picked) != picked) picked else base,
237
+ requests' = if ((if (focused) NONE else picked) != picked) requests + 1 else requests,
238
+ lastEvent' = "type_char",
239
+ valueChanged' = and(focused, picked >= 0),
240
+ wasOpen' = open,
241
+ e0' = e0,
242
+ e3' = e3,
243
+ focused' = focused,
244
+ labelMode' = labelMode
245
+ }
246
+
247
+ // --- navigation keys --------------------------------------------------------
248
+ action arrow_down = all {
249
+ open' = if (focused) true else open,
250
+ active' = if (focused) downTarget else active,
251
+ selected' = if (focused) selectedFor(downTarget) else selected,
252
+ lastEvent' = "arrow_down",
253
+ valueChanged' = false,
254
+ wasOpen' = open,
255
+ e0' = e0,
256
+ e3' = e3,
257
+ focused' = focused,
258
+ labelMode' = labelMode,
259
+ picked' = landedInt(holds, picked, picked),
260
+ controlled' = controlled,
261
+ accepting' = accepting,
262
+ valueProp' = propAfter(follows, valueProp, picked),
263
+ requested' = if ((picked) != picked) picked else requested,
264
+ outcome' = if ((picked) != picked) verdict else outcome,
265
+ base' = if ((picked) != picked) picked else base,
266
+ requests' = if ((picked) != picked) requests + 1 else requests,
267
+ }
268
+
269
+ action arrow_up = all {
270
+ open' = if (focused) true else open,
271
+ active' = if (focused) upTarget else active,
272
+ selected' = if (focused) selectedFor(upTarget) else selected,
273
+ lastEvent' = "arrow_up",
274
+ valueChanged' = false,
275
+ wasOpen' = open,
276
+ e0' = e0,
277
+ e3' = e3,
278
+ focused' = focused,
279
+ labelMode' = labelMode,
280
+ picked' = landedInt(holds, picked, picked),
281
+ controlled' = controlled,
282
+ accepting' = accepting,
283
+ valueProp' = propAfter(follows, valueProp, picked),
284
+ requested' = if ((picked) != picked) picked else requested,
285
+ outcome' = if ((picked) != picked) verdict else outcome,
286
+ base' = if ((picked) != picked) picked else base,
287
+ requests' = if ((picked) != picked) requests + 1 else requests,
288
+ }
289
+
290
+ // closed parent: expand in place; open parent: first child; leaf: nothing
291
+ action arrow_right = all {
292
+ e0' = if (and(onNode, and(active == 0, not(e0)))) true else e0,
293
+ e3' = if (and(onNode, and(active == 3, not(e3)))) true else e3,
294
+ active' = rightTarget,
295
+ selected' = if (rightTarget != active) selectedFor(rightTarget) else selected,
296
+ lastEvent' = "arrow_right",
297
+ valueChanged' = false,
298
+ wasOpen' = open,
299
+ open' = open,
300
+ focused' = focused,
301
+ labelMode' = labelMode,
302
+ picked' = landedInt(holds, picked, picked),
303
+ controlled' = controlled,
304
+ accepting' = accepting,
305
+ valueProp' = propAfter(follows, valueProp, picked),
306
+ requested' = if ((picked) != picked) picked else requested,
307
+ outcome' = if ((picked) != picked) verdict else outcome,
308
+ base' = if ((picked) != picked) picked else base,
309
+ requests' = if ((picked) != picked) requests + 1 else requests,
310
+ }
311
+
312
+ // open parent: collapse; other child: its parent; root: nothing
313
+ action arrow_left = all {
314
+ e0' = if (and(onNode, and(active == 0, e0))) false else e0,
315
+ e3' = if (and(onNode, and(active == 3, e3))) false else e3,
316
+ active' = leftTarget,
317
+ selected' = if (leftTarget != active) selectedFor(leftTarget) else selected,
318
+ lastEvent' = "arrow_left",
319
+ valueChanged' = false,
320
+ wasOpen' = open,
321
+ open' = open,
322
+ focused' = focused,
323
+ labelMode' = labelMode,
324
+ picked' = landedInt(holds, picked, picked),
325
+ controlled' = controlled,
326
+ accepting' = accepting,
327
+ valueProp' = propAfter(follows, valueProp, picked),
328
+ requested' = if ((picked) != picked) picked else requested,
329
+ outcome' = if ((picked) != picked) verdict else outcome,
330
+ base' = if ((picked) != picked) picked else base,
331
+ requests' = if ((picked) != picked) requests + 1 else requests,
332
+ }
333
+
334
+ action home = all {
335
+ active' = if (nav) 0 else active,
336
+ selected' = if (nav) selectedFor(0) else selected,
337
+ lastEvent' = "home",
338
+ valueChanged' = false,
339
+ wasOpen' = open,
340
+ open' = open,
341
+ e0' = e0,
342
+ e3' = e3,
343
+ focused' = focused,
344
+ labelMode' = labelMode,
345
+ picked' = landedInt(holds, picked, picked),
346
+ controlled' = controlled,
347
+ accepting' = accepting,
348
+ valueProp' = propAfter(follows, valueProp, picked),
349
+ requested' = if ((picked) != picked) picked else requested,
350
+ outcome' = if ((picked) != picked) verdict else outcome,
351
+ base' = if ((picked) != picked) picked else base,
352
+ requests' = if ((picked) != picked) requests + 1 else requests,
353
+ }
354
+
355
+ action end = all {
356
+ active' = if (nav) NODES - 1 else active,
357
+ selected' = if (nav) selectedFor(NODES - 1) else selected,
358
+ lastEvent' = "end",
359
+ valueChanged' = false,
360
+ wasOpen' = open,
361
+ open' = open,
362
+ e0' = e0,
363
+ e3' = e3,
364
+ focused' = focused,
365
+ labelMode' = labelMode,
366
+ picked' = landedInt(holds, picked, picked),
367
+ controlled' = controlled,
368
+ accepting' = accepting,
369
+ valueProp' = propAfter(follows, valueProp, picked),
370
+ requested' = if ((picked) != picked) picked else requested,
371
+ outcome' = if ((picked) != picked) verdict else outcome,
372
+ base' = if ((picked) != picked) picked else base,
373
+ requests' = if ((picked) != picked) requests + 1 else requests,
374
+ }
375
+
376
+ // --- commit / dismiss ---------------------------------------------------
377
+ // Enter accepts the selected node; with nothing selected it does nothing
378
+ action enter = all {
379
+ open' = if (commitsSelected) false else open,
380
+ picked' = landedInt(holds, picked, if (commitsSelected) selected else picked),
381
+ controlled' = controlled,
382
+ accepting' = accepting,
383
+ valueProp' = propAfter(follows, valueProp, if (commitsSelected) selected else picked),
384
+ requested' = if ((if (commitsSelected) selected else picked) != picked) if (commitsSelected) selected else picked else requested,
385
+ outcome' = if ((if (commitsSelected) selected else picked) != picked) verdict else outcome,
386
+ base' = if ((if (commitsSelected) selected else picked) != picked) picked else base,
387
+ requests' = if ((if (commitsSelected) selected else picked) != picked) requests + 1 else requests,
388
+ active' = if (commitsSelected) NONE else active,
389
+ selected' = if (commitsSelected) NONE else selected,
390
+ lastEvent' = "enter",
391
+ valueChanged' = and(commitsSelected, picked != selected),
392
+ wasOpen' = open,
393
+ e0' = e0,
394
+ e3' = e3,
395
+ focused' = focused,
396
+ labelMode' = labelMode
397
+ }
398
+
399
+ // open: close, keep the value; closed: clear the textbox
400
+ action escape = all {
401
+ open' = if (focused) false else open,
402
+ active' = if (focused) NONE else active,
403
+ selected' = if (focused) NONE else selected,
404
+ picked' = landedInt(holds, picked, if (escapeClears) NONE else picked),
405
+ controlled' = controlled,
406
+ accepting' = accepting,
407
+ valueProp' = propAfter(follows, valueProp, if (escapeClears) NONE else picked),
408
+ requested' = if ((if (escapeClears) NONE else picked) != picked) if (escapeClears) NONE else picked else requested,
409
+ outcome' = if ((if (escapeClears) NONE else picked) != picked) verdict else outcome,
410
+ base' = if ((if (escapeClears) NONE else picked) != picked) picked else base,
411
+ requests' = if ((if (escapeClears) NONE else picked) != picked) requests + 1 else requests,
412
+ lastEvent' = "escape",
413
+ valueChanged' = escapeClears,
414
+ wasOpen' = open,
415
+ e0' = e0,
416
+ e3' = e3,
417
+ focused' = focused,
418
+ labelMode' = labelMode
419
+ }
420
+
421
+ // --- pointer -------------------------------------------------------------
422
+ action click_input = all {
423
+ focused' = true,
424
+ open' = not(open),
425
+ active' = NONE,
426
+ selected' = NONE,
427
+ lastEvent' = "click_input",
428
+ valueChanged' = false,
429
+ wasOpen' = open,
430
+ e0' = e0,
431
+ e3' = e3,
432
+ labelMode' = labelMode,
433
+ picked' = landedInt(holds, picked, picked),
434
+ controlled' = controlled,
435
+ accepting' = accepting,
436
+ valueProp' = propAfter(follows, valueProp, picked),
437
+ requested' = if ((picked) != picked) picked else requested,
438
+ outcome' = if ((picked) != picked) verdict else outcome,
439
+ base' = if ((picked) != picked) picked else base,
440
+ requests' = if ((picked) != picked) requests + 1 else requests,
441
+ }
442
+
443
+ // a selectable node is committed; a category label is toggled and focused
444
+ action click_node = {
445
+ nondet n = Set(0, 1, 2, 3, 4, 5, 6).oneOf()
446
+ all {
447
+ picked' = landedInt(holds, picked, if (and(and(open, vis(n)), selectable(labelMode, n))) n else picked),
448
+ controlled' = controlled,
449
+ accepting' = accepting,
450
+ valueProp' = propAfter(follows, valueProp, if (and(and(open, vis(n)), selectable(labelMode, n))) n else picked),
451
+ requested' = if ((if (and(and(open, vis(n)), selectable(labelMode, n))) n else picked) != picked) if (and(and(open, vis(n)), selectable(labelMode, n))) n else picked else requested,
452
+ outcome' = if ((if (and(and(open, vis(n)), selectable(labelMode, n))) n else picked) != picked) verdict else outcome,
453
+ base' = if ((if (and(and(open, vis(n)), selectable(labelMode, n))) n else picked) != picked) picked else base,
454
+ requests' = if ((if (and(and(open, vis(n)), selectable(labelMode, n))) n else picked) != picked) requests + 1 else requests,
455
+ open' = if (and(and(open, vis(n)), selectable(labelMode, n))) false else open,
456
+ active' = if (and(open, vis(n))) (if (selectable(labelMode, n)) NONE else n) else active,
457
+ selected' = if (and(open, vis(n))) (if (selectable(labelMode, n)) NONE else selectedFor(n)) else selected,
458
+ e0' = if (and(and(open, vis(n)), and(n == 0, not(selectable(labelMode, n))))) not(e0) else e0,
459
+ e3' = if (and(and(open, vis(n)), and(n == 3, not(selectable(labelMode, n))))) not(e3) else e3,
460
+ lastEvent' = "click_node",
461
+ valueChanged' = and(and(and(open, vis(n)), selectable(labelMode, n)), picked != n),
462
+ wasOpen' = open,
463
+ focused' = focused,
464
+ labelMode' = labelMode
465
+ }
466
+ }
467
+
468
+ // --- prop changes ---------------------------------------------------------
469
+ // the labelMode changes which nodes are values: popup closes, a no-longer
470
+ // selectable committed value is dropped
471
+ action set_label_mode = {
472
+ nondet v = Set(0, 1, 2).oneOf()
473
+ all {
474
+ labelMode' = v,
475
+ open' = false,
476
+ active' = NONE,
477
+ selected' = NONE,
478
+ picked' = if (selectable(v, picked)) picked else NONE,
479
+ lastEvent' = "set_label_mode",
480
+ valueChanged' = and(picked >= 0, not(selectable(v, picked))),
481
+ wasOpen' = open,
482
+ e0' = e0,
483
+ e3' = e3,
484
+ focused' = focused,
485
+ controlled' = controlled,
486
+ accepting' = accepting,
487
+ valueProp' = if (controlled) (if (selectable(v, picked)) picked else NONE) else valueProp,
488
+ requested' = requested,
489
+ outcome' = "none",
490
+ base' = base,
491
+ requests' = requests
492
+ }
493
+ }
494
+
495
+ // a non-selectable value is ignored
496
+ action set_value = {
497
+ nondet n = Set(0, 1, 2, 3, 4, 5, 6).oneOf()
498
+ all {
499
+ picked' = if (selectable(labelMode, n)) n else picked,
500
+ open' = false,
501
+ active' = NONE,
502
+ selected' = NONE,
503
+ lastEvent' = "set_value",
504
+ valueChanged' = and(selectable(labelMode, n), picked != n),
505
+ wasOpen' = open,
506
+ e0' = e0,
507
+ e3' = e3,
508
+ focused' = focused,
509
+ labelMode' = labelMode,
510
+ controlled' = controlled,
511
+ accepting' = accepting,
512
+ valueProp' = if (controlled) (if (selectable(labelMode, n)) n else picked) else valueProp,
513
+ requested' = requested,
514
+ outcome' = "none",
515
+ base' = base,
516
+ requests' = requests
517
+ }
518
+ }
519
+
520
+ // commit: the host flips its own copy of the picked node
521
+ action hostToggle = all {
522
+ open' = open,
523
+ active' = active,
524
+ selected' = selected,
525
+ e0' = e0,
526
+ e3' = e3,
527
+ focused' = focused,
528
+ labelMode' = labelMode,
529
+ picked' = if (controlled) (if (picked >= 0) NONE else 1) else picked,
530
+ lastEvent' = "hostToggle",
531
+ valueChanged' = false,
532
+ wasOpen' = open,
533
+ controlled' = controlled,
534
+ accepting' = accepting,
535
+ valueProp' = if (controlled) (if (picked >= 0) NONE else 1) else valueProp,
536
+ requested' = requested,
537
+ outcome' = if (controlled) "none" else outcome,
538
+ base' = base,
539
+ requests' = requests
540
+ }
541
+
542
+ // configuration: the host starts owning the picked node, adopting it
543
+ action control = all {
544
+ open' = open,
545
+ active' = active,
546
+ selected' = selected,
547
+ e0' = e0,
548
+ e3' = e3,
549
+ focused' = focused,
550
+ labelMode' = labelMode,
551
+ picked' = picked,
552
+ lastEvent' = lastEvent,
553
+ valueChanged' = valueChanged,
554
+ wasOpen' = open,
555
+ controlled' = true,
556
+ accepting' = accepting,
557
+ valueProp' = picked,
558
+ requested' = requested,
559
+ outcome' = outcome,
560
+ base' = base,
561
+ requests' = requests
562
+ }
563
+
564
+ // configuration: the controlled host answers every request with its intent
565
+ action acceptRequests = all {
566
+ open' = open,
567
+ active' = active,
568
+ selected' = selected,
569
+ e0' = e0,
570
+ e3' = e3,
571
+ focused' = focused,
572
+ labelMode' = labelMode,
573
+ picked' = picked,
574
+ lastEvent' = lastEvent,
575
+ valueChanged' = valueChanged,
576
+ wasOpen' = open,
577
+ controlled' = controlled,
578
+ accepting' = true,
579
+ valueProp' = valueProp,
580
+ requested' = requested,
581
+ outcome' = outcome,
582
+ base' = base,
583
+ requests' = requests
584
+ }
585
+
586
+ action step = any {
587
+ init, focus, blur, tab, type_char, arrow_down, arrow_up, arrow_right,
588
+ arrow_left, home, end, enter, escape, click_input, click_node, set_label_mode,
589
+ set_value, hostToggle, control, acceptRequests
590
+ }
591
+
592
+ // --- invariants (APG guarantees) ----------------------------------------
593
+ // aria-activedescendant refers to a visible node of the open popup
594
+ val ActiveNodeIsVisibleInOpenPopup: bool = or(active == NONE, and(open, vis(active)))
595
+ // DOM focus stays on the combobox while its popup is open
596
+ val DomFocusStaysOnCombobox: bool = or(not(open), focused)
597
+ // at most one aria-selected node, only in an open popup, always a selectable value
598
+ val SelectionIsSingleAndSelectable: bool = and(
599
+ selectedCount == (if (selected >= 0) 1 else 0),
600
+ or(selected == NONE, and(open, selectable(labelMode, selected)))
601
+ )
602
+ // selection follows focus on selectable nodes; on a label it is kept
603
+ // (labelMode 1) or cleared (labelMode 2); nothing is selected without focus
604
+ val SelectionFollowsFocus: bool = and(
605
+ or(active != NONE, selected == NONE),
606
+ or(active == NONE, not(selectable(labelMode, active)), selected == active),
607
+ or(active == NONE, selectable(labelMode, active), labelMode == 1, selected == NONE)
608
+ )
609
+ // the rendered attributes agree with the state; non-listbox popups declare
610
+ // their type through aria-haspopup
611
+ val AriaContractConsistent: bool = and(
612
+ ariaActivedescendant == active,
613
+ ariaHasPopup == "tree",
614
+ ariaExpanded == open
615
+ )
616
+ val EscapeOnOpenPopupKeepsValue: bool =
617
+ or(not(lastEvent == "escape"), not(wasOpen), not(valueChanged))
618
+ val NavigationKeepsValue: bool = or(not(isNavigation(lastEvent)), not(valueChanged))
619
+ val ValueChangesOnlyOnCommitOrEdit: bool = or(not(valueChanged), mayChangeValue(lastEvent))
620
+ // the committed value is always a selectable node
621
+ val PickedIsSelectable: bool =
622
+ and(picked >= NONE, picked < NODES, selectable(labelMode, picked))
623
+ val FocusLossClosesPopup: bool =
624
+ or(not(or(lastEvent == "blur", lastEvent == "tab")), and(not(open), not(focused)))
625
+ // callbacks only ever count up from zero
626
+ val RequestsNonNegative: bool = requests >= 0
627
+ // controlled: only the value prop sets the picked node, so no event commits
628
+ val ControlledFollowsProp: bool = or(not(controlled), picked == valueProp)
629
+ // a rejected request leaves the picked node as it found it
630
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), picked == base)
631
+ // an accepted request committed exactly its intent
632
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), picked == requested)
633
+ }