@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,363 @@
1
+ // WAI-ARIA APG — Menu Button: a button that opens a (flat) menu.
2
+ //
3
+ // Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
4
+ // - "Keyboard Interaction": with focus on the button Enter / Space open the menu
5
+ // on the first item, (optional) Down Arrow opens on the first item, (optional)
6
+ // Up Arrow opens on the last item.
7
+ // - "WAI-ARIA Roles, States, and Properties": the button has aria-haspopup and
8
+ // aria-expanded true/false.
9
+ // Once open, the menu behaves as in https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
10
+ // (Keyboard Interaction): Down/Up wrap, Home/End, typeahead, Enter/Space activate
11
+ // and close, Escape closes and returns focus to the button, Tab closes and leaves,
12
+ // Left/Right Arrow do nothing without a menubar (modelled by absence of events).
13
+ // Examples: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-actions/
14
+ // https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-actions-active-descendant/
15
+ // https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-links/
16
+ //
17
+ // Request vs commit (map ticket rt1p). Every event a user sees as an open/close —
18
+ // tab_out, click_button, activate, arrow_down, arrow_up, escape — is a *request*
19
+ // carrying the intended open state (`requested`); it fires onOpenChange
20
+ // (`requests` counts the calls) when the intent differs from the current state,
21
+ // and then:
22
+ // uncontrolled the request is accepted: the whole transition commits
23
+ // controlled, holds the host ignores it ("rejected"): nothing moves
24
+ // controlled, accepts the host answers with the same value (`accepting`): the
25
+ // transition commits through the controlled value
26
+ // `valueProp` is the only thing that changes `menuOpen` while `controlled`;
27
+ // `setValue` / `hostToggle` are the host writing it. `control` / `acceptRequests`
28
+ // are configuration events (init is literal-only). A rejected request reverts
29
+ // the *whole* transition (focus included), so the ARIA invariants still hold.
30
+ //
31
+ // Fixture: 4 items, item 3 is aria-disabled ("focusable but cannot be activated",
32
+ // APG note 1). Typeahead label codes: item1=1, item2=2, item3=1, item4=2.
33
+ //
34
+ // Focus is `(focusLevel, focusIdx)`: 0 outside, 1 on the button, 2 on a menu item.
35
+ //
36
+ // ARIA contract (derived defs): ariaExpanded(open), ariaHasPopup,
37
+ // ariaActiveDescendant(level, idx) (the item id an activedescendant implementation
38
+ // would expose; the element.focus() variant uses focusIdx directly), ariaDisabled(i).
39
+ //
40
+ // Deliberate omissions: Shift+Tab is `tab_out` as Tab; no hover/pointer-leave; the
41
+ // menu container is not itself focusable; no menu-button-links navigation (an
42
+ // activation is a recorded item index); no aria-controls id (a static relation);
43
+ // activating while a disabled item is focused is a no-op (menu stays open);
44
+ // typeahead is a no-op while the menu is closed; letters are one-letter codes 1..3.
45
+ module apg_menu_button {
46
+ import aria.* from "../../shared/aria"
47
+
48
+ var focusLevel: int // 0 outside | 1 button | 2 menu item
49
+ var focusIdx: int // focused menu item (0 unless focusLevel == 2)
50
+ var menuOpen: bool // the menu is displayed (button aria-expanded)
51
+ var lastActivated: int // last activated item (0 none)
52
+ var controlled: bool // the host owns `menuOpen` (`value` prop present)
53
+ var accepting: bool // controlled host that answers every request with its intent
54
+ var valueProp: bool // the controlled open value (meaningful while controlled)
55
+ var requested: bool // intent of the last request
56
+ var outcome: str // "none" | "accepted" | "rejected"
57
+ var base: bool // open state when the last request was received
58
+ var requests: int // number of requests that fired onOpenChange
59
+
60
+ def isDisabled(i: int): bool = i == 3
61
+ def letterOf(i: int): int = if (i == 1) 1 else if (i == 2) 2 else if (i == 3) 1 else 2
62
+ def c1(i: int): int = ariaWrapNext1(i, 4)
63
+ def typeTarget(i: int, ch: int): int =
64
+ if (letterOf(c1(i)) == ch) c1(i)
65
+ else if (letterOf(c1(c1(i))) == ch) c1(c1(i))
66
+ else if (letterOf(c1(c1(c1(i)))) == ch) c1(c1(c1(i)))
67
+ else i
68
+
69
+ // ARIA contract
70
+ def ariaExpanded(open: bool): bool = open
71
+ def ariaHasPopup(open: bool): bool = true
72
+ def ariaDisabled(i: int): bool = isDisabled(i)
73
+ def ariaActiveDescendant(l: int, i: int): int = if (l == 2) i else 0
74
+
75
+ // request / commit (map ticket rt1p)
76
+ def holds: bool = and(controlled, not(accepting))
77
+ def follows: bool = and(controlled, accepting)
78
+ def verdict: str = if (holds) "rejected" else "accepted"
79
+ def landed(h: bool, cur: bool, want: bool): bool = if (h) cur else want
80
+ def propAfter(f: bool, p: bool, want: bool): bool = if (f) want else p
81
+ // the intended open state of each request
82
+ def activateWant: bool =
83
+ if (focusLevel == 1) true
84
+ else if (and(focusLevel == 2, not(isDisabled(focusIdx)))) false
85
+ else menuOpen
86
+ def downWant: bool = if (focusLevel == 1) true else menuOpen
87
+ def escapeWant: bool = if (focusLevel == 2) false else menuOpen
88
+ // a request is real only when its intent differs from the current state
89
+ def moves(want: bool): bool = want != menuOpen
90
+ // a held request with a moving intent is rejected: the whole transition reverts
91
+ def rejects(want: bool): bool = and(holds, moves(want))
92
+
93
+ action init = all {
94
+ focusLevel' = 0,
95
+ focusIdx' = 0,
96
+ menuOpen' = false,
97
+ lastActivated' = 0,
98
+ controlled' = false,
99
+ accepting' = false,
100
+ valueProp' = false,
101
+ requested' = false,
102
+ outcome' = "none",
103
+ base' = false,
104
+ requests' = 0
105
+ }
106
+
107
+ // Tab onto the button
108
+ action focus_in = all {
109
+ focusLevel' = if (focusLevel == 0) 1 else focusLevel,
110
+ focusIdx' = focusIdx,
111
+ menuOpen' = menuOpen,
112
+ lastActivated' = lastActivated,
113
+ controlled' = controlled,
114
+ accepting' = accepting,
115
+ valueProp' = valueProp,
116
+ requested' = requested,
117
+ outcome' = outcome,
118
+ base' = base,
119
+ requests' = requests
120
+ }
121
+
122
+ // request: Tab / Shift+Tab / blur closes and leaves
123
+ action tab_out = all {
124
+ focusLevel' = if (rejects(false)) focusLevel else 0,
125
+ focusIdx' = if (rejects(false)) focusIdx else 0,
126
+ menuOpen' = landed(holds, menuOpen, false),
127
+ lastActivated' = lastActivated,
128
+ controlled' = controlled,
129
+ accepting' = accepting,
130
+ valueProp' = propAfter(follows, valueProp, false),
131
+ requested' = if (moves(false)) false else requested,
132
+ outcome' = if (moves(false)) verdict else outcome,
133
+ base' = if (moves(false)) menuOpen else base,
134
+ requests' = if (moves(false)) requests + 1 else requests
135
+ }
136
+
137
+ // request: pointer click on the button toggles the menu; opening focuses the
138
+ // first item, closing returns focus to the button
139
+ action click_button = all {
140
+ focusLevel' = if (rejects(not(menuOpen))) focusLevel else if (menuOpen) 1 else 2,
141
+ focusIdx' = if (rejects(not(menuOpen))) focusIdx else if (menuOpen) 0 else 1,
142
+ menuOpen' = landed(holds, menuOpen, not(menuOpen)),
143
+ lastActivated' = lastActivated,
144
+ controlled' = controlled,
145
+ accepting' = accepting,
146
+ valueProp' = propAfter(follows, valueProp, not(menuOpen)),
147
+ requested' = if (moves(not(menuOpen))) not(menuOpen) else requested,
148
+ outcome' = if (moves(not(menuOpen))) verdict else outcome,
149
+ base' = if (moves(not(menuOpen))) menuOpen else base,
150
+ requests' = if (moves(not(menuOpen))) requests + 1 else requests
151
+ }
152
+
153
+ // request: Enter / Space on the button opens on the first item; on an enabled
154
+ // item activates it, closes the menu and returns focus to the button
155
+ action activate = {
156
+ nondet key = Set("Enter", "Space").oneOf()
157
+ all {
158
+ focusLevel' = if (rejects(activateWant)) focusLevel
159
+ else if (focusLevel == 1) 2
160
+ else if (and(focusLevel == 2, not(isDisabled(focusIdx)))) 1
161
+ else focusLevel,
162
+ focusIdx' = if (rejects(activateWant)) focusIdx
163
+ else if (focusLevel == 1) 1
164
+ else if (and(focusLevel == 2, not(isDisabled(focusIdx)))) 0
165
+ else focusIdx,
166
+ menuOpen' = landed(holds, menuOpen, activateWant),
167
+ lastActivated' = if (and(focusLevel == 2, not(isDisabled(focusIdx)))) focusIdx else lastActivated,
168
+ controlled' = controlled,
169
+ accepting' = accepting,
170
+ valueProp' = propAfter(follows, valueProp, activateWant),
171
+ requested' = if (moves(activateWant)) activateWant else requested,
172
+ outcome' = if (moves(activateWant)) verdict else outcome,
173
+ base' = if (moves(activateWant)) menuOpen else base,
174
+ requests' = if (moves(activateWant)) requests + 1 else requests
175
+ }
176
+ }
177
+
178
+ // request: (optional) Down Arrow: button opens on the first item; menu moves next
179
+ action arrow_down = all {
180
+ focusLevel' = if (rejects(downWant)) focusLevel else if (focusLevel == 1) 2 else focusLevel,
181
+ focusIdx' = if (rejects(downWant)) focusIdx
182
+ else if (focusLevel == 1) 1
183
+ else if (focusLevel == 2) ariaWrapNext1(focusIdx, 4)
184
+ else focusIdx,
185
+ menuOpen' = landed(holds, menuOpen, downWant),
186
+ lastActivated' = lastActivated,
187
+ controlled' = controlled,
188
+ accepting' = accepting,
189
+ valueProp' = propAfter(follows, valueProp, downWant),
190
+ requested' = if (moves(downWant)) downWant else requested,
191
+ outcome' = if (moves(downWant)) verdict else outcome,
192
+ base' = if (moves(downWant)) menuOpen else base,
193
+ requests' = if (moves(downWant)) requests + 1 else requests
194
+ }
195
+
196
+ // request: (optional) Up Arrow: button opens on the last item; menu moves previous
197
+ action arrow_up = all {
198
+ focusLevel' = if (rejects(downWant)) focusLevel else if (focusLevel == 1) 2 else focusLevel,
199
+ focusIdx' = if (rejects(downWant)) focusIdx
200
+ else if (focusLevel == 1) 4
201
+ else if (focusLevel == 2) ariaWrapPrev1(focusIdx, 4)
202
+ else focusIdx,
203
+ menuOpen' = landed(holds, menuOpen, downWant),
204
+ lastActivated' = lastActivated,
205
+ controlled' = controlled,
206
+ accepting' = accepting,
207
+ valueProp' = propAfter(follows, valueProp, downWant),
208
+ requested' = if (moves(downWant)) downWant else requested,
209
+ outcome' = if (moves(downWant)) verdict else outcome,
210
+ base' = if (moves(downWant)) menuOpen else base,
211
+ requests' = if (moves(downWant)) requests + 1 else requests
212
+ }
213
+
214
+ action key_home = all {
215
+ focusLevel' = focusLevel,
216
+ focusIdx' = if (focusLevel == 2) 1 else focusIdx,
217
+ menuOpen' = menuOpen,
218
+ lastActivated' = lastActivated,
219
+ controlled' = controlled,
220
+ accepting' = accepting,
221
+ valueProp' = valueProp,
222
+ requested' = requested,
223
+ outcome' = outcome,
224
+ base' = base,
225
+ requests' = requests
226
+ }
227
+
228
+ action key_end = all {
229
+ focusLevel' = focusLevel,
230
+ focusIdx' = if (focusLevel == 2) 4 else focusIdx,
231
+ menuOpen' = menuOpen,
232
+ lastActivated' = lastActivated,
233
+ controlled' = controlled,
234
+ accepting' = accepting,
235
+ valueProp' = valueProp,
236
+ requested' = requested,
237
+ outcome' = outcome,
238
+ base' = base,
239
+ requests' = requests
240
+ }
241
+
242
+ // request: Escape closes and returns focus to the button
243
+ action escape = all {
244
+ focusLevel' = if (rejects(escapeWant)) focusLevel else if (focusLevel == 2) 1 else focusLevel,
245
+ focusIdx' = if (rejects(escapeWant)) focusIdx else if (focusLevel == 2) 0 else focusIdx,
246
+ menuOpen' = landed(holds, menuOpen, escapeWant),
247
+ lastActivated' = lastActivated,
248
+ controlled' = controlled,
249
+ accepting' = accepting,
250
+ valueProp' = propAfter(follows, valueProp, escapeWant),
251
+ requested' = if (moves(escapeWant)) escapeWant else requested,
252
+ outcome' = if (moves(escapeWant)) verdict else outcome,
253
+ base' = if (moves(escapeWant)) menuOpen else base,
254
+ requests' = if (moves(escapeWant)) requests + 1 else requests
255
+ }
256
+
257
+ // printable character: next item (after the focused one, wrapping) with that label
258
+ action typeahead = {
259
+ nondet ch = Set(1, 2, 3).oneOf()
260
+ all {
261
+ focusLevel' = focusLevel,
262
+ focusIdx' = if (focusLevel == 2) typeTarget(focusIdx, ch) else focusIdx,
263
+ menuOpen' = menuOpen,
264
+ lastActivated' = lastActivated,
265
+ controlled' = controlled,
266
+ accepting' = accepting,
267
+ valueProp' = valueProp,
268
+ requested' = requested,
269
+ outcome' = outcome,
270
+ base' = base,
271
+ requests' = requests
272
+ }
273
+ }
274
+
275
+ // commit: the host writes the controlled open state (a no-op while uncontrolled)
276
+ action setValue = {
277
+ nondet v = Set(true, false).oneOf()
278
+ all {
279
+ focusLevel' = if (controlled) (if (v) 2 else 1) else focusLevel,
280
+ focusIdx' = if (controlled) (if (v) 1 else 0) else focusIdx,
281
+ menuOpen' = if (controlled) v else menuOpen,
282
+ lastActivated' = lastActivated,
283
+ controlled' = controlled,
284
+ accepting' = accepting,
285
+ valueProp' = if (controlled) v else valueProp,
286
+ requested' = requested,
287
+ outcome' = if (controlled) "none" else outcome,
288
+ base' = base,
289
+ requests' = requests
290
+ }
291
+ }
292
+
293
+ // commit: the host flips its own copy of the open state
294
+ action hostToggle = all {
295
+ focusLevel' = if (controlled) (if (valueProp) 1 else 2) else focusLevel,
296
+ focusIdx' = if (controlled) (if (valueProp) 0 else 1) else focusIdx,
297
+ menuOpen' = if (controlled) not(valueProp) else menuOpen,
298
+ lastActivated' = lastActivated,
299
+ controlled' = controlled,
300
+ accepting' = accepting,
301
+ valueProp' = if (controlled) not(valueProp) else valueProp,
302
+ requested' = requested,
303
+ outcome' = if (controlled) "none" else outcome,
304
+ base' = base,
305
+ requests' = requests
306
+ }
307
+
308
+ // configuration: the host starts owning the open state, adopting the current one
309
+ action control = all {
310
+ focusLevel' = focusLevel,
311
+ focusIdx' = focusIdx,
312
+ menuOpen' = menuOpen,
313
+ lastActivated' = lastActivated,
314
+ controlled' = true,
315
+ accepting' = accepting,
316
+ valueProp' = menuOpen,
317
+ requested' = requested,
318
+ outcome' = outcome,
319
+ base' = base,
320
+ requests' = requests
321
+ }
322
+
323
+ // configuration: the controlled host answers every request with its intent
324
+ action acceptRequests = all {
325
+ focusLevel' = focusLevel,
326
+ focusIdx' = focusIdx,
327
+ menuOpen' = menuOpen,
328
+ lastActivated' = lastActivated,
329
+ controlled' = controlled,
330
+ accepting' = true,
331
+ valueProp' = valueProp,
332
+ requested' = requested,
333
+ outcome' = outcome,
334
+ base' = base,
335
+ requests' = requests
336
+ }
337
+
338
+ action step = any {
339
+ init, focus_in, tab_out, click_button, activate, arrow_down, arrow_up,
340
+ key_home, key_end, escape, typeahead, setValue, hostToggle, control, acceptRequests
341
+ }
342
+
343
+ val LevelValid: bool = and(focusLevel >= 0, focusLevel <= 2)
344
+ // aria-expanded is true exactly while focus is managed inside the menu
345
+ val ExpandedIffFocusInMenu: bool = ariaExpanded(menuOpen) == (focusLevel == 2)
346
+ // focus is on a real item while open, and on no item otherwise
347
+ val FocusIdxInRange: bool =
348
+ if (focusLevel == 2) and(focusIdx >= 1, focusIdx <= 4) else focusIdx == 0
349
+ // a disabled item can never have been activated
350
+ val DisabledNeverActivated: bool =
351
+ and(lastActivated >= 0, lastActivated <= 4, not(and(lastActivated > 0, ariaDisabled(lastActivated))))
352
+ // the active descendant is the focused item while open, none otherwise
353
+ val ActiveDescendantMatchesFocus: bool =
354
+ ariaActiveDescendant(focusLevel, focusIdx) == (if (menuOpen) focusIdx else 0)
355
+ // callbacks only ever count up from zero
356
+ val RequestsNonNegative: bool = requests >= 0
357
+ // controlled: only the value prop sets the state, so no event commits
358
+ val ControlledFollowsProp: bool = or(not(controlled), menuOpen == valueProp)
359
+ // a rejected request leaves the state as it found it
360
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), menuOpen == base)
361
+ // an accepted request committed exactly its intent
362
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), menuOpen == requested)
363
+ }
@@ -0,0 +1,304 @@
1
+ // WAI-ARIA APG — Menu: menuitemcheckbox / menuitemradio state.
2
+ //
3
+ // Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
4
+ // - "Keyboard Interaction" > Enter: "Otherwise, activates the item and closes the
5
+ // menu."
6
+ // - "Keyboard Interaction" > Space: (Optional) on a menuitemcheckbox "changes the
7
+ // state without closing the menu"; (Optional) on an unchecked menuitemradio
8
+ // "checks the focused menuitemradio and unchecks any other checked
9
+ // menuitemradio element in the same group" without closing; on a plain item
10
+ // activates it and closes.
11
+ // - "WAI-ARIA Roles, States, and Properties": aria-checked=true on a checked
12
+ // menuitemcheckbox / menuitemradio; radio items grouped (separator-delimited).
13
+ // Example: https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-editor/
14
+ // (Font > menuitemcheckbox Bold/Italic..., menuitemradio sizes/faces)
15
+ //
16
+ // Request vs commit (map ticket rt1p). Enter and Space on an item carry the
17
+ // intended checked state (`requested`, the checkbox bit and the radio choice
18
+ // packed with `checked`). A real request fires onCheckedChange / onValueChange
19
+ // (`requests` counts the calls) when the intent differs from the current state,
20
+ // and then:
21
+ // uncontrolled the request is accepted: the items commit the intent
22
+ // controlled, holds the host ignores it ("rejected"): the checked items stay
23
+ // controlled, accepts the host answers with the same value (`accepting`): the
24
+ // checked items commit through the controlled value
25
+ // `valueProp` is the only thing that changes the checked items while
26
+ // `controlled`; `setValue` / `hostToggle` are the host writing it. `control` /
27
+ // `acceptRequests` are configuration events (init is literal-only). The open
28
+ // state is not part of the controlled value, so a rejected check still closes
29
+ // the menu as the key's open-state half dictates.
30
+ //
31
+ // Fixture (one menu opened from a trigger; 1-based, 0 = focus on the trigger):
32
+ // 1 menuitemcheckbox "Word wrap" (state `wrap`)
33
+ // 2 menuitemradio "Small" \
34
+ // 3 menuitemradio "Medium" } one radio group (state `choice`, exactly one checked)
35
+ // 4 menuitemradio "Large" /
36
+ // 5 menuitem "Reset view" (plain)
37
+ //
38
+ // ARIA contract (derived defs): ariaChecked(i, wrap, choice) is the aria-checked
39
+ // value of item i (true/false for checkable roles; a plain item has none, reported
40
+ // false), itemRole(i) is the role code (0 menuitem | 1 menuitemcheckbox |
41
+ // 2 menuitemradio), isChecked(i, ...) is only meaningful for checkable roles.
42
+ //
43
+ // Deliberate omissions: navigation keys beyond Up/Down (+ wrap) and Home/End are
44
+ // covered by menu-menubar.qnt / menu-button.qnt; typeahead; disabled items; the
45
+ // activation of the plain item has no side effect besides closing; Space on the
46
+ // plain item closes (the "Optional" APG variant); pointer events.
47
+ module apg_menu_checkable {
48
+ import aria.* from "../../shared/aria"
49
+
50
+ var focusIdx: int // 0 = on the trigger (menu closed), 1..5 = focused item
51
+ var menuOpen: bool // menu displayed
52
+ var wrap: bool // menuitemcheckbox 1 checked
53
+ var choice: int // checked radio of the group (2..4)
54
+ var lastActivated: int // last activated item (0 none)
55
+ var controlled: bool // the host owns the checked items (`value` prop present)
56
+ var accepting: bool // controlled host that answers every request with its intent
57
+ var valueProp: int // the controlled checked state, packed with `checked`
58
+ var requested: int // intent of the last request, packed
59
+ var outcome: str // "none" | "accepted" | "rejected"
60
+ var base: int // checked state when the last request was received, packed
61
+ var requests: int // number of requests that fired a change callback
62
+
63
+ def itemRole(i: int): int =
64
+ if (i == 1) 1 else if (and(i >= 2, i <= 4)) 2 else 0
65
+ def ariaChecked(i: int, w: bool, c: int): bool =
66
+ if (i == 1) w else if (and(i >= 2, i <= 4)) c == i else false
67
+ def b2i(b: bool): int = if (b) 1 else 0
68
+ def isRadio(i: int): bool = itemRole(i) == 2
69
+
70
+ // request / commit (map ticket rt1p)
71
+ // injective: checkbox bit * 5 + radio choice (2..4)
72
+ def checked(w: bool, c: int): int = b2i(w) * 5 + c
73
+ def nowPacked: int = checked(wrap, choice)
74
+ def wrapFrom(v: int): bool = v >= 5
75
+ def choiceFrom(v: int): int = v % 5
76
+ def holds: bool = and(controlled, not(accepting))
77
+ def follows: bool = and(controlled, accepting)
78
+ def verdict: str = if (holds) "rejected" else "accepted"
79
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
80
+ // the intended checked state of an activation (Enter / Space on an item)
81
+ def intentWrap: bool = if (and(menuOpen, focusIdx == 1)) not(wrap) else wrap
82
+ def intentChoice: int = if (and(menuOpen, isRadio(focusIdx))) focusIdx else choice
83
+ def intentPacked: int = checked(intentWrap, intentChoice)
84
+ def checkedMoves: bool = intentPacked != nowPacked
85
+ // a held request with a moving check is rejected: the checked items stay put
86
+ def rejects: bool = and(holds, checkedMoves)
87
+
88
+ action init = all {
89
+ focusIdx' = 0,
90
+ menuOpen' = false,
91
+ wrap' = false,
92
+ choice' = 2,
93
+ lastActivated' = 0,
94
+ controlled' = false,
95
+ accepting' = false,
96
+ valueProp' = 2,
97
+ requested' = 2,
98
+ outcome' = "none",
99
+ base' = 2,
100
+ requests' = 0
101
+ }
102
+
103
+ action arrow_down = all {
104
+ focusIdx' = if (menuOpen) ariaWrapNext1(focusIdx, 5) else 1,
105
+ menuOpen' = true,
106
+ wrap' = wrap,
107
+ choice' = choice,
108
+ lastActivated' = lastActivated,
109
+ controlled' = controlled,
110
+ accepting' = accepting,
111
+ valueProp' = valueProp,
112
+ requested' = requested,
113
+ outcome' = outcome,
114
+ base' = base,
115
+ requests' = requests
116
+ }
117
+
118
+ action arrow_up = all {
119
+ focusIdx' = if (menuOpen) ariaWrapPrev1(focusIdx, 5) else 5,
120
+ menuOpen' = true,
121
+ wrap' = wrap,
122
+ choice' = choice,
123
+ lastActivated' = lastActivated,
124
+ controlled' = controlled,
125
+ accepting' = accepting,
126
+ valueProp' = valueProp,
127
+ requested' = requested,
128
+ outcome' = outcome,
129
+ base' = base,
130
+ requests' = requests
131
+ }
132
+
133
+ action key_home = all {
134
+ focusIdx' = if (menuOpen) 1 else focusIdx,
135
+ menuOpen' = menuOpen,
136
+ wrap' = wrap,
137
+ choice' = choice,
138
+ lastActivated' = lastActivated,
139
+ controlled' = controlled,
140
+ accepting' = accepting,
141
+ valueProp' = valueProp,
142
+ requested' = requested,
143
+ outcome' = outcome,
144
+ base' = base,
145
+ requests' = requests
146
+ }
147
+
148
+ action key_end = all {
149
+ focusIdx' = if (menuOpen) 5 else focusIdx,
150
+ menuOpen' = menuOpen,
151
+ wrap' = wrap,
152
+ choice' = choice,
153
+ lastActivated' = lastActivated,
154
+ controlled' = controlled,
155
+ accepting' = accepting,
156
+ valueProp' = valueProp,
157
+ requested' = requested,
158
+ outcome' = outcome,
159
+ base' = base,
160
+ requests' = requests
161
+ }
162
+
163
+ // request: Enter on the trigger opens on the first item; on an item changes its
164
+ // state (toggle a checkbox, check a radio) AND closes the menu
165
+ action enter = all {
166
+ focusIdx' = if (menuOpen) 0 else 1,
167
+ menuOpen' = not(menuOpen),
168
+ wrap' = if (rejects) wrap else intentWrap,
169
+ choice' = if (rejects) choice else intentChoice,
170
+ lastActivated' = if (menuOpen) focusIdx else lastActivated,
171
+ controlled' = controlled,
172
+ accepting' = accepting,
173
+ valueProp' = propAfter(follows, valueProp, intentPacked),
174
+ requested' = if (checkedMoves) intentPacked else requested,
175
+ outcome' = if (checkedMoves) verdict else outcome,
176
+ base' = if (checkedMoves) nowPacked else base,
177
+ requests' = if (checkedMoves) requests + 1 else requests
178
+ }
179
+
180
+ // request: Space on the trigger opens; on a checkbox / radio changes the state
181
+ // WITHOUT closing; on the plain item activates and closes
182
+ action space = all {
183
+ focusIdx' = if (not(menuOpen)) 1 else if (focusIdx == 5) 0 else focusIdx,
184
+ menuOpen' = if (not(menuOpen)) true else if (focusIdx == 5) false else true,
185
+ wrap' = if (rejects) wrap else intentWrap,
186
+ choice' = if (rejects) choice else intentChoice,
187
+ lastActivated' = if (menuOpen) focusIdx else lastActivated,
188
+ controlled' = controlled,
189
+ accepting' = accepting,
190
+ valueProp' = propAfter(follows, valueProp, intentPacked),
191
+ requested' = if (checkedMoves) intentPacked else requested,
192
+ outcome' = if (checkedMoves) verdict else outcome,
193
+ base' = if (checkedMoves) nowPacked else base,
194
+ requests' = if (checkedMoves) requests + 1 else requests
195
+ }
196
+
197
+ action escape = all {
198
+ focusIdx' = if (menuOpen) 0 else focusIdx,
199
+ menuOpen' = false,
200
+ wrap' = wrap,
201
+ choice' = choice,
202
+ lastActivated' = lastActivated,
203
+ controlled' = controlled,
204
+ accepting' = accepting,
205
+ valueProp' = valueProp,
206
+ requested' = requested,
207
+ outcome' = outcome,
208
+ base' = base,
209
+ requests' = requests
210
+ }
211
+
212
+ // commit: the host writes the controlled checked state (a no-op while uncontrolled)
213
+ action setValue = {
214
+ nondet v = Set(2, 3, 4, 7, 8, 9).oneOf()
215
+ all {
216
+ focusIdx' = focusIdx,
217
+ menuOpen' = menuOpen,
218
+ wrap' = if (controlled) wrapFrom(v) else wrap,
219
+ choice' = if (controlled) choiceFrom(v) else choice,
220
+ lastActivated' = lastActivated,
221
+ controlled' = controlled,
222
+ accepting' = accepting,
223
+ valueProp' = if (controlled) v else valueProp,
224
+ requested' = requested,
225
+ outcome' = if (controlled) "none" else outcome,
226
+ base' = base,
227
+ requests' = requests
228
+ }
229
+ }
230
+
231
+ // commit: the host flips the checkbox item of its own copy of the state
232
+ action hostToggle = all {
233
+ focusIdx' = focusIdx,
234
+ menuOpen' = menuOpen,
235
+ wrap' = if (controlled) not(wrap) else wrap,
236
+ choice' = choice,
237
+ lastActivated' = lastActivated,
238
+ controlled' = controlled,
239
+ accepting' = accepting,
240
+ valueProp' = if (controlled) checked(not(wrap), choice) else valueProp,
241
+ requested' = requested,
242
+ outcome' = if (controlled) "none" else outcome,
243
+ base' = base,
244
+ requests' = requests
245
+ }
246
+
247
+ // configuration: the host starts owning the checked items, adopting the current ones
248
+ action control = all {
249
+ focusIdx' = focusIdx,
250
+ menuOpen' = menuOpen,
251
+ wrap' = wrap,
252
+ choice' = choice,
253
+ lastActivated' = lastActivated,
254
+ controlled' = true,
255
+ accepting' = accepting,
256
+ valueProp' = nowPacked,
257
+ requested' = requested,
258
+ outcome' = outcome,
259
+ base' = base,
260
+ requests' = requests
261
+ }
262
+
263
+ // configuration: the controlled host answers every request with its intent
264
+ action acceptRequests = all {
265
+ focusIdx' = focusIdx,
266
+ menuOpen' = menuOpen,
267
+ wrap' = wrap,
268
+ choice' = choice,
269
+ lastActivated' = lastActivated,
270
+ controlled' = controlled,
271
+ accepting' = true,
272
+ valueProp' = valueProp,
273
+ requested' = requested,
274
+ outcome' = outcome,
275
+ base' = base,
276
+ requests' = requests
277
+ }
278
+
279
+ action step = any {
280
+ init, arrow_down, arrow_up, key_home, key_end, enter, space, escape,
281
+ setValue, hostToggle, control, acceptRequests
282
+ }
283
+
284
+ val FocusIdxInRange: bool = and(focusIdx >= 0, focusIdx <= 5)
285
+ // the menu is open exactly while focus is on one of its items
286
+ val OpenIffFocusInMenu: bool = menuOpen == (focusIdx > 0)
287
+ val RadioChoiceInGroup: bool = and(choice >= 2, choice <= 4)
288
+ // a radio group always has exactly one aria-checked=true member (cannot be unchecked)
289
+ val ExactlyOneRadioChecked: bool =
290
+ b2i(ariaChecked(2, wrap, choice)) + b2i(ariaChecked(3, wrap, choice)) + b2i(ariaChecked(4, wrap, choice)) == 1
291
+ // the checkbox's aria-checked is its own state, independent of the radio group
292
+ val CheckboxCheckedIsWrap: bool = ariaChecked(1, wrap, choice) == wrap
293
+ // the plain item never carries aria-checked=true
294
+ val PlainItemNeverChecked: bool = not(ariaChecked(5, wrap, choice))
295
+ val LastActivatedValid: bool = and(lastActivated >= 0, lastActivated <= 5)
296
+ // callbacks only ever count up from zero
297
+ val RequestsNonNegative: bool = requests >= 0
298
+ // controlled: only the value prop sets the checked items, so no event commits
299
+ val ControlledFollowsProp: bool = or(not(controlled), nowPacked == valueProp)
300
+ // a rejected request leaves the checked items as it found them
301
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), nowPacked == base)
302
+ // an accepted request committed exactly its intent
303
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), nowPacked == requested)
304
+ }