@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,364 @@
1
+ // APG Toolbar — https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/
2
+ //
3
+ // A toolbar of five items behind ONE tab stop. Items are addressed by index
4
+ // 0..4; their kinds are fixed by the spec:
5
+ // 0 menu-button (a button that opens a 3-item menu) 1 button
6
+ // 2 text (native <input type=text>) 3 spin (custom role=spinbutton with its
7
+ // own key handler) 4 button
8
+ //
9
+ // The roving-tabindex part is the platform's `focusgroup="toolbar"` contract,
10
+ // imported from shared/focusgroup.qnt and NOT restated here: single tab stop,
11
+ // arrows along the axis, wrap, Home / End, last-focused memory, and Tab /
12
+ // Shift+Tab stepping to the neighbouring item out of a native key-conflict
13
+ // control. This spec models only the delta:
14
+ // - the axis comes from aria-orientation (the focusgroup axis does not set it)
15
+ // and the wrap flag is the author's choice
16
+ // - disabled-but-focusable: a disabled item stays reachable by arrows, click
17
+ // and Tab (aria-disabled; a native `disabled` button would be SKIPPED by the
18
+ // platform, which is the gap the shared module records); it just cannot be
19
+ // activated
20
+ // - embedded controls consume keys: `text` swallows every key the group would
21
+ // act on (Left/Right/Home/End horizontally, Up/Down/Home/End vertically),
22
+ // `spin` swallows Up/Down/Home/End (its handler prevents default). A
23
+ // swallowed key never moves focus; Tab / Shift+Tab is the way on: `text`
24
+ // is a native key-conflict control, so Tab steps to the neighbouring item
25
+ // (focus leaves the toolbar only at either end), `spin` is not, so Tab
26
+ // leaves
27
+ // - activation of an enabled button
28
+ // - the menu-button kind below
29
+ //
30
+ // Menu button (item 0). The APG Toolbar pattern does NOT define a menu button's
31
+ // open key. It only says the arrows perpendicular to the toolbar axis "can be
32
+ // reserved for operating controls" ("Keyboard Interaction",
33
+ // https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/), and that a vertical
34
+ // toolbar navigates with Up/Down. So the open key is settled as:
35
+ // - Enter / Space open the menu on the FIRST item;
36
+ // - the key PERPENDICULAR to the toolbar axis also opens, and the end it opens
37
+ // on is the sign of that key: the forward perpendicular key opens on the
38
+ // first item, the backward one on the last. Horizontal toolbar: Down opens
39
+ // on the first, Up on the last. Vertical toolbar: Right opens on the first,
40
+ // Left on the last -- Down/Up stay the toolbar's own navigation keys, which
41
+ // is exactly why the vertical case needs its own rule.
42
+ // Once open, the menu owns the keyboard (APG menubar, the behaviour
43
+ // menu-button.qnt cites): Up/Down wrap within the menu, Home/End jump,
44
+ // Enter/Space activate and close returning focus to the trigger, Escape closes
45
+ // and returns focus to the trigger, Tab closes and leaves the toolbar. The
46
+ // toolbar's roving focus is suspended: the trigger keeps the tab stop, and the
47
+ // menu CAPTURES every navigation key through the same `captures` / `swallowed`
48
+ // mechanism the embedded controls use, so the keys never move the toolbar.
49
+ //
50
+ // Why item 0 is the menu-button: the ported base-ui scenario
51
+ // `toolbar.base-ui.menu-trigger-consumes-arrows` renders its single menu trigger
52
+ // as the toolbar's item 0, so making the spec's item 0 the menu-button lets the
53
+ // scenario's `item:0` / `menuitem:N` mapping replay honestly. The menu is the
54
+ // scenario's three items (menuitem 0..2).
55
+ //
56
+ // Events (user level): tabIn, tab(back), leave, click(item), key(Left|Right|Up|
57
+ // Down|Home|End), activate (Enter/Space), escape, toggleDisabled(item),
58
+ // flipOrientation, flipWrap. `tabIn` / `leave` are the plain enter / focus-lost
59
+ // events; `tab` is the real Tab / Shift+Tab key from wherever focus is.
60
+ // `activations` counts activations of enabled buttons; `consumed` counts keys
61
+ // swallowed by an embedded control or by the open menu. The rendered tabindex of
62
+ // item i is `fgTabIndex(tabStop, i)`.
63
+ //
64
+ // Conformance: seeded random walks of this model (40 walks x 40 events, every
65
+ // event kind) were replayed against a real DOM toolbar in Chromium
66
+ // 153.0.8010.12, once with native `focusgroup` and once with
67
+ // @microsoft/focusgroup-polyfill 1.6.0 forced: 1600 events each, focus matched
68
+ // the spec after every one (script kept outside the repo; see
69
+ // shared/focusgroup.qnt for the provider divergences).
70
+ //
71
+ // ponytail: item count and kinds are fixed, not configurable; grouping
72
+ // separators, RTL (Left/Right swap), the menu-button's own menu activation and
73
+ // the live value of the embedded controls are not modelled. Widen the pools if a
74
+ // consumer needs them.
75
+ module apg_toolbar {
76
+ import aria.* from "../../shared/aria"
77
+ import focusgroup.* from "../../shared/focusgroup"
78
+
79
+ var orientation: str // "horizontal" | "vertical" (aria-orientation)
80
+ var wrap: bool // arrows wrap around the ends
81
+ var focus: int // focused item, -1 = focus is outside the toolbar
82
+ var tabStop: int // the item that owns tabindex=0 (focus memory)
83
+ var disabledMask: int // bit i set = item i is aria-disabled
84
+ var activations: int // activations of enabled buttons
85
+ var lastActivated: int // item of the last activation, -1 = none
86
+ var consumed: int // keys swallowed by an embedded control or the menu
87
+ var lastKeyConsumed: bool // the previous event was a key an embedded control swallowed
88
+ var menuOpen: bool // the menu-button's menu is displayed (aria-expanded)
89
+ var menuIndex: int // focused menu item while open, 0 otherwise
90
+
91
+ def itemCount: int = 5
92
+ def menuButton: int = 0
93
+ def menuCount: int = 3
94
+ def bit(i: int): int =
95
+ if (i == 0) 1 else if (i == 1) 2 else if (i == 2) 4 else if (i == 3) 8 else if (i == 4) 16 else 0
96
+ def isDisabledIn(mask: int, i: int): bool = (mask / bit(i)) % 2 == 1
97
+ def kindOf(i: int): str =
98
+ if (i == menuButton) "menu-button" else if (i == 2) "text" else if (i == 3) "spin" else "button"
99
+ def isMenuButton(i: int): bool = i == menuButton
100
+ def axisOf(o: str): str = if (o == "horizontal") "inline" else "block"
101
+
102
+ // the axis perpendicular to the toolbar: the axis the open keys live on
103
+ def perpAxis(o: str): str = if (o == "horizontal") "block" else "inline"
104
+ def perpDelta(o: str, key: str): int = fgDelta(perpAxis(o), key)
105
+ def perpKey(o: str, key: str): bool = perpDelta(o, key) != 0
106
+ def opensFirst(o: str, key: str): bool = perpDelta(o, key) == 1
107
+ def opensLast(o: str, key: str): bool = perpDelta(o, key) == -1
108
+
109
+ // the menu is a flat block list: Up/Down wrap, Home/End jump
110
+ def menuNavKey(key: str): bool = fgIsNavKey("block", key)
111
+ def menuLanding(key: str, idx: int): int = fgLanding(menuCount, "block", true, idx, key)
112
+
113
+ // does the embedded control -- or the open menu -- capture the key? A native
114
+ // text field consumes every key the group would act on; the spinbutton's own
115
+ // handler prevents default for Up/Down/Home/End; while the menu is open the
116
+ // menu owns every navigation key.
117
+ def captures(kind: str, o: str, key: str, open: bool): bool =
118
+ or(
119
+ and(kind == "text", fgIsNavKey(axisOf(o), key)),
120
+ and(kind == "spin", or(or(key == "Up", key == "Down"), fgIsEdgeKey(key))),
121
+ and(kind == "menu-button", open)
122
+ )
123
+ def swallowed(o: str, f: int, key: str, open: bool): bool =
124
+ and(f >= 0, captures(kindOf(f), o, key, open))
125
+
126
+ // where focus lands after a key: a captured key pins it, otherwise the
127
+ // platform contract decides
128
+ def landing(o: str, w: bool, f: int, key: str, open: bool): int =
129
+ if (swallowed(o, f, key, open)) f else fgLanding(itemCount, axisOf(o), w, f, key)
130
+
131
+ // where the menu focus goes after a key while the menu is open; also the item
132
+ // a perpendicular key opens on when the menu-button is focused and closed
133
+ def menuKeyTarget(o: str, k: str): int =
134
+ if (menuOpen) (if (menuNavKey(k)) menuLanding(k, menuIndex) else menuIndex)
135
+ else if (and(isMenuButton(focus), perpKey(o, k)))
136
+ (if (opensLast(o, k)) menuCount - 1 else 0)
137
+ else 0
138
+
139
+ // where Tab (back = false) / Shift+Tab (back = true) puts focus: an open menu
140
+ // closes and leaves; outside it enters on the remembered item; a native text
141
+ // field steps to its neighbour; any other item leaves the toolbar
142
+ def tabLanding(f: int, stop: int, back: bool, open: bool): int =
143
+ if (open) -1
144
+ else if (f < 0) fgEntry(true, stop, 0)
145
+ else if (kindOf(f) == "text") fgEscape(itemCount, f, back)
146
+ else -1
147
+
148
+ def activates(mask: int, i: int): bool =
149
+ and(and(i >= 0, kindOf(i) == "button"), not(isDisabledIn(mask, i)))
150
+ def flip(mask: int, i: int): int =
151
+ if (isDisabledIn(mask, i)) mask - bit(i) else mask + bit(i)
152
+
153
+ // ARIA contract: aria-expanded on the menu-button item
154
+ def ariaExpandedMenuButton(open: bool): bool = open
155
+
156
+ action init = all {
157
+ orientation' = "horizontal",
158
+ wrap' = false,
159
+ focus' = -1,
160
+ tabStop' = 0,
161
+ disabledMask' = 0,
162
+ activations' = 0,
163
+ lastActivated' = -1,
164
+ consumed' = 0,
165
+ lastKeyConsumed' = false,
166
+ menuOpen' = false,
167
+ menuIndex' = 0
168
+ }
169
+
170
+ // Tab / Shift+Tab into the toolbar lands on the remembered item
171
+ action tabIn = all {
172
+ orientation' = orientation,
173
+ wrap' = wrap,
174
+ focus' = if (focus < 0) fgEntry(true, tabStop, 0) else focus,
175
+ tabStop' = tabStop,
176
+ disabledMask' = disabledMask,
177
+ activations' = activations,
178
+ lastActivated' = lastActivated,
179
+ consumed' = consumed,
180
+ lastKeyConsumed' = false,
181
+ menuOpen' = menuOpen,
182
+ menuIndex' = menuIndex
183
+ }
184
+
185
+ // focus lost elsewhere (pointer, script): the memory stays, an open menu closes
186
+ action leave = all {
187
+ orientation' = orientation,
188
+ wrap' = wrap,
189
+ focus' = -1,
190
+ tabStop' = fgStopOnLeave(true, tabStop, 0),
191
+ disabledMask' = disabledMask,
192
+ activations' = activations,
193
+ lastActivated' = lastActivated,
194
+ consumed' = consumed,
195
+ lastKeyConsumed' = false,
196
+ menuOpen' = false,
197
+ menuIndex' = 0
198
+ }
199
+
200
+ // the Tab key itself: an open menu closes and leaves; otherwise enter, step out
201
+ // of a native text field to the neighbouring item, or leave
202
+ action tab = {
203
+ nondet back = Set(false, true).oneOf()
204
+ all {
205
+ orientation' = orientation,
206
+ wrap' = wrap,
207
+ focus' = tabLanding(focus, tabStop, back, menuOpen),
208
+ tabStop' = if (tabLanding(focus, tabStop, back, menuOpen) >= 0) tabLanding(focus, tabStop, back, menuOpen) else tabStop,
209
+ disabledMask' = disabledMask,
210
+ activations' = activations,
211
+ lastActivated' = lastActivated,
212
+ consumed' = consumed,
213
+ lastKeyConsumed' = false,
214
+ menuOpen' = false,
215
+ menuIndex' = 0
216
+ }
217
+ }
218
+
219
+ // pointer: while the menu is open a toolbar click closes it (light dismiss);
220
+ // otherwise it focuses the item (even a disabled one) and makes it the tab
221
+ // stop, opens the menu-button's menu, and activates an enabled button
222
+ action click = {
223
+ nondet idx = Set(0, 1, 2, 3, 4).oneOf()
224
+ all {
225
+ orientation' = orientation,
226
+ wrap' = wrap,
227
+ focus' = if (menuOpen) focus else idx,
228
+ tabStop' = if (menuOpen) tabStop else idx,
229
+ disabledMask' = disabledMask,
230
+ activations' = if (and(not(menuOpen), activates(disabledMask, idx))) activations + 1 else activations,
231
+ lastActivated' = if (and(not(menuOpen), activates(disabledMask, idx))) idx else lastActivated,
232
+ consumed' = consumed,
233
+ lastKeyConsumed' = false,
234
+ menuOpen' = if (menuOpen) false else isMenuButton(idx),
235
+ menuIndex' = 0
236
+ }
237
+ }
238
+
239
+ action key = {
240
+ nondet k = Set("Left", "Right", "Up", "Down", "Home", "End").oneOf()
241
+ all {
242
+ orientation' = orientation,
243
+ wrap' = wrap,
244
+ focus' = landing(orientation, wrap, focus, k, menuOpen),
245
+ tabStop' = if (focus < 0) tabStop else landing(orientation, wrap, focus, k, menuOpen),
246
+ disabledMask' = disabledMask,
247
+ activations' = activations,
248
+ lastActivated' = lastActivated,
249
+ consumed' = if (swallowed(orientation, focus, k, menuOpen)) consumed + 1 else consumed,
250
+ lastKeyConsumed' = swallowed(orientation, focus, k, menuOpen),
251
+ menuOpen' = if (menuOpen) true else and(isMenuButton(focus), perpKey(orientation, k)),
252
+ menuIndex' = menuKeyTarget(orientation, k)
253
+ }
254
+ }
255
+
256
+ // Enter / Space: on the closed menu-button it opens the menu on the first item;
257
+ // while the menu is open it closes the menu and returns focus to the trigger;
258
+ // otherwise it activates the focused enabled button
259
+ action activate = all {
260
+ orientation' = orientation,
261
+ wrap' = wrap,
262
+ focus' = if (menuOpen) menuButton else focus,
263
+ tabStop' = tabStop,
264
+ disabledMask' = disabledMask,
265
+ activations' = if (and(not(menuOpen), activates(disabledMask, focus))) activations + 1 else activations,
266
+ lastActivated' = if (and(not(menuOpen), activates(disabledMask, focus))) focus else lastActivated,
267
+ consumed' = consumed,
268
+ lastKeyConsumed' = false,
269
+ menuOpen' = if (menuOpen) false else isMenuButton(focus),
270
+ menuIndex' = 0
271
+ }
272
+
273
+ // Escape: while the menu is open it closes and returns focus to the trigger
274
+ action escape = all {
275
+ orientation' = orientation,
276
+ wrap' = wrap,
277
+ focus' = if (menuOpen) menuButton else focus,
278
+ tabStop' = tabStop,
279
+ disabledMask' = disabledMask,
280
+ activations' = activations,
281
+ lastActivated' = lastActivated,
282
+ consumed' = consumed,
283
+ lastKeyConsumed' = false,
284
+ menuOpen' = false,
285
+ menuIndex' = 0
286
+ }
287
+
288
+ // disabling never moves focus or the tab stop: the idx stays focusable
289
+ action toggleDisabled = {
290
+ nondet idx = Set(0, 1, 2, 3, 4).oneOf()
291
+ all {
292
+ orientation' = orientation,
293
+ wrap' = wrap,
294
+ focus' = focus,
295
+ tabStop' = tabStop,
296
+ disabledMask' = flip(disabledMask, idx),
297
+ activations' = activations,
298
+ lastActivated' = lastActivated,
299
+ consumed' = consumed,
300
+ lastKeyConsumed' = false,
301
+ menuOpen' = menuOpen,
302
+ menuIndex' = menuIndex
303
+ }
304
+ }
305
+
306
+ action flipOrientation = all {
307
+ orientation' = if (orientation == "horizontal") "vertical" else "horizontal",
308
+ wrap' = wrap,
309
+ focus' = focus,
310
+ tabStop' = tabStop,
311
+ disabledMask' = disabledMask,
312
+ activations' = activations,
313
+ lastActivated' = lastActivated,
314
+ consumed' = consumed,
315
+ lastKeyConsumed' = false,
316
+ menuOpen' = menuOpen,
317
+ menuIndex' = menuIndex
318
+ }
319
+
320
+ action flipWrap = all {
321
+ orientation' = orientation,
322
+ wrap' = not(wrap),
323
+ focus' = focus,
324
+ tabStop' = tabStop,
325
+ disabledMask' = disabledMask,
326
+ activations' = activations,
327
+ lastActivated' = lastActivated,
328
+ consumed' = consumed,
329
+ lastKeyConsumed' = false,
330
+ menuOpen' = menuOpen,
331
+ menuIndex' = menuIndex
332
+ }
333
+
334
+ action step = any {
335
+ init, tabIn, tab, leave, click, key, activate, escape, toggleDisabled, flipOrientation, flipWrap
336
+ }
337
+
338
+ def tabbable(i: int): int = if (fgTabIndex(tabStop, i) == 0) 1 else 0
339
+
340
+ // APG: exactly one item is in the tab sequence, and it is the remembered one
341
+ val ExactlyOneTabbable: bool =
342
+ tabbable(0) + tabbable(1) + tabbable(2) + tabbable(3) + tabbable(4) == 1
343
+ // focus memory always points at an existing item
344
+ val TabStopInRange: bool = fgStopInRange(itemCount, tabStop)
345
+ // focus is outside the toolbar or on an item that is in range
346
+ val FocusInRange: bool = fgFocusInRange(itemCount, focus)
347
+ // roving tabindex: whatever holds focus inside the toolbar is the tab stop
348
+ val FocusIsTabStop: bool = fgFocusIsStop(focus, tabStop)
349
+ val DisabledMaskValid: bool = and(disabledMask >= 0, disabledMask <= 31)
350
+ val OrientationKnown: bool = ariaOrientationKnown(orientation)
351
+ // only an enabled-at-the-time button is ever activated
352
+ val OnlyButtonsActivate: bool = or(lastActivated < 0, kindOf(lastActivated) == "button")
353
+ val ActivationLogValid: bool = and(lastActivated >= -1, lastActivated <= 4)
354
+ // a swallowed key leaves focus on the embedded control (or the menu-button)
355
+ val ConsumedStaysEmbedded: bool = or(not(lastKeyConsumed), and(focus >= 0, kindOf(focus) != "button"))
356
+ val CountersNonNegative: bool = and(activations >= 0, consumed >= 0)
357
+ // while the menu is open the roving focus is suspended on the menu-button item
358
+ val MenuOpenImpliesMenuButtonFocused: bool = or(not(menuOpen), focus == menuButton)
359
+ // the menu index is a real menu item while open, and 0 while closed
360
+ val MenuIndexInRange: bool =
361
+ if (menuOpen) and(menuIndex >= 0, menuIndex <= menuCount - 1) else menuIndex == 0
362
+ // aria-expanded on the menu-button item is true exactly while the menu is open
363
+ val MenuButtonExpandedIffOpen: bool = ariaExpandedMenuButton(menuOpen) == menuOpen
364
+ }