@cssints/apg-specs 0.0.0-stage → 0.5.2

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,412 @@
1
+ // WAI-ARIA APG pattern: Tabs — one tablist, four tab/tabpanel pairs.
2
+ // https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
3
+ // Examples: .../examples/tabs-automatic/ and .../examples/tabs-manual/
4
+ //
5
+ // Modelled (APG "Keyboard Interaction" + "WAI-ARIA Roles, States, and Properties"):
6
+ // - Tab: focus enters the tablist on the ACTIVE (selected) tab, the next Tab
7
+ // leaves the tablist for the tabpanel, then out. Shift+Tab walks it back.
8
+ // - Roving tabindex: tabindex=0 sits on the selected tab only, every other
9
+ // tab is -1 (as in both APG examples: arrows move DOM focus by script
10
+ // without touching tabindex; only selection moves the tab stop).
11
+ // - Left/Right Arrow (horizontal) and Up/Down Arrow (vertical): previous/next
12
+ // tab, wrapping last->first and first->last. Arrows of the other axis are
13
+ // ignored.
14
+ // - Home / End: first / last tab.
15
+ // - Automatic activation: moving focus selects (tabs-automatic example).
16
+ // Manual activation: moving focus only moves focus; Enter / Space selects
17
+ // the focused tab (tabs-manual example).
18
+ // - Delete (optional, `deletable` flavor): removes the focused tab and its
19
+ // panel; focus goes to the following tab, or the previous one when the
20
+ // deleted tab was last. A deleted selected tab hands selection to the tab
21
+ // that receives focus.
22
+ // - Disabled tabs: skipped by arrows/Home/End/Delete-focus, ignore clicks.
23
+ // - Pointer: clicking an enabled tab focuses and selects it in BOTH modes.
24
+ // - Panel shown = selected tab's panel (aria-selected / aria-controls /
25
+ // aria-labelledby are derived defs; tab k controls panel k by index).
26
+ //
27
+ // Variants (activation x orientation x deletable x disabled-tab mask) are one
28
+ // nondet pool on `configure`, which remounts the widget (the generator allows
29
+ // one nondet per action, so the variant is a packed int). `configure` also
30
+ // drops the host back to uncontrolled.
31
+ //
32
+ // Request vs commit (map ticket rt1p). Selection is the value. Every event a
33
+ // user sees as a selection — `click` and `press` — is a *request* carrying an
34
+ // intent (`requested`); it fires onValueChange (`requests` counts the calls)
35
+ // when the intent differs from the current `selected`, and then:
36
+ // uncontrolled the request is accepted: selection commits the intent
37
+ // controlled, holds the host ignores it ("rejected"): selection is untouched
38
+ // controlled, accepts the host answers with the same value (`accepting`): the
39
+ // selection commits through the controlled value, not the event
40
+ // `valueProp` is the only thing that changes selection while `controlled`;
41
+ // `setValue` is the host writing it. `control` / `acceptRequests` are
42
+ // configuration events (init is literal-only). DOM focus still moves with the
43
+ // request (roving focus), so `AutomaticSelectionFollowsFocus` is an
44
+ // uncontrolled guarantee. Invariants for the model: ControlledFollowsProp,
45
+ // RejectedRequestUntouched, AcceptedRequestCommits.
46
+ //
47
+ // Deliberate omissions:
48
+ // - RTL (Left/Right swap) and tablist `aria-label` / `aria-labelledby`.
49
+ // - Dynamic disable/enable of a tab, tab insertion, an empty tablist:
50
+ // deleting the last focusable tab is refused (Delete is a no-op).
51
+ // - Typeahead, Shift+F10 / context menus, focus inside the panel content.
52
+ // - Non-wrapping navigation (APG examples always wrap).
53
+ // - Focusing disabled tabs (APG is silent; we follow react-aria/Base UI and
54
+ // skip them).
55
+ // - Delete while controlled: a host-owned tablist never loses a tab through
56
+ // a user event.
57
+ //
58
+ // Generator gaps hit: one nondet per action (packed variant int), no
59
+ // collections (tabs are bit-masks over a fixed 4 slots), no `const`.
60
+ module apg_tabs {
61
+ import aria.* from "../../shared/aria"
62
+
63
+ var activation: str // "automatic" | "manual"
64
+ var orientation: str // "horizontal" | "vertical" (aria-orientation)
65
+ var deletable: bool // Delete removes the focused tab
66
+ var disabled: int // bit k set = tab k is aria-disabled
67
+ var present: int // bit k set = tab k (and panel k) is in the DOM
68
+ var selected: int // aria-selected tab (also the tabindex=0 tab)
69
+ var focused: int // tab that has / last had DOM focus
70
+ var zone: str // "outside" | "tablist" | "panel": where DOM focus is
71
+ var controlled: bool // the host owns the selection (`value` prop present)
72
+ var accepting: bool // controlled host that answers every request with its intent
73
+ var valueProp: int // the controlled value prop (meaningful while controlled)
74
+ var requested: int // intent of the last request
75
+ var requests: int // number of requests that fired onSelectionChange
76
+ var outcome: str // "none" | "accepted" | "rejected"
77
+ var base: int // selection when the last request was received
78
+
79
+ // ---- bit-mask helpers over the 4 tab slots ------------------------------
80
+ def pow2(k: int): int =
81
+ if (k == 0) 1 else if (k == 1) 2 else if (k == 2) 4 else 8
82
+ def hasBit(mask: int, k: int): bool =
83
+ and(k >= 0, k <= 3, (mask / pow2(k)) % 2 == 1)
84
+ def one(b: bool): int = if (b) 1 else 0
85
+
86
+ // a tab can take focus / selection: in the DOM and not disabled
87
+ def focusable(k: int, p: int, d: int): bool =
88
+ and(hasBit(p, k), not(hasBit(d, k)))
89
+ def focusableCount(p: int, d: int): int =
90
+ one(focusable(0, p, d)) + one(focusable(1, p, d)) +
91
+ one(focusable(2, p, d)) + one(focusable(3, p, d))
92
+
93
+ // ---- navigation targets -------------------------------------------------
94
+ def nextWrap(i: int, p: int, d: int): int =
95
+ if (focusable((i + 1) % 4, p, d)) (i + 1) % 4
96
+ else if (focusable((i + 2) % 4, p, d)) (i + 2) % 4
97
+ else if (focusable((i + 3) % 4, p, d)) (i + 3) % 4
98
+ else i
99
+ def prevWrap(i: int, p: int, d: int): int =
100
+ if (focusable((i + 3) % 4, p, d)) (i + 3) % 4
101
+ else if (focusable((i + 2) % 4, p, d)) (i + 2) % 4
102
+ else if (focusable((i + 1) % 4, p, d)) (i + 1) % 4
103
+ else i
104
+ def firstTab(p: int, d: int): int =
105
+ if (focusable(0, p, d)) 0
106
+ else if (focusable(1, p, d)) 1
107
+ else if (focusable(2, p, d)) 2
108
+ else 3
109
+ def lastTab(p: int, d: int): int =
110
+ if (focusable(3, p, d)) 3
111
+ else if (focusable(2, p, d)) 2
112
+ else if (focusable(1, p, d)) 1
113
+ else 0
114
+ // Delete: the following tab, else the previous one (no wrap)
115
+ def afterDelete(i: int, p: int, d: int): int =
116
+ if (focusable(i + 1, p, d)) i + 1
117
+ else if (focusable(i + 2, p, d)) i + 2
118
+ else if (focusable(i + 3, p, d)) i + 3
119
+ else if (focusable(i - 1, p, d)) i - 1
120
+ else if (focusable(i - 2, p, d)) i - 2
121
+ else i - 3
122
+
123
+ def isNavKey(key: str, o: str): bool =
124
+ or(ariaAxisForward(o, key), ariaAxisBackward(o, key), key == "Home", key == "End")
125
+ def navTarget(key: str, o: str, i: int, p: int, d: int): int =
126
+ if (ariaAxisForward(o, key)) nextWrap(i, p, d)
127
+ else if (ariaAxisBackward(o, key)) prevWrap(i, p, d)
128
+ else if (key == "Home") firstTab(p, d)
129
+ else if (key == "End") lastTab(p, d)
130
+ else i
131
+
132
+ // ---- key handling (only while DOM focus is on a tab) --------------------
133
+ // a host-owned tablist never loses a tab through a user event
134
+ def deleting(zn: str, key: str, del: bool, i: int, p: int, d: int): bool =
135
+ and(not(controlled), and(zn == "tablist", key == "Delete", del, focusableCount(p, d) > 1))
136
+ def pressFocused(zn: str, key: str, o: str, del: bool, i: int, p: int, d: int): int =
137
+ if (zn != "tablist") i
138
+ else if (deleting(zn, key, del, i, p, d)) afterDelete(i, p, d)
139
+ else navTarget(key, o, i, p, d)
140
+ def pressSelected(zn: str, key: str, act: str, o: str, del: bool, sel: int, i: int, p: int, d: int): int =
141
+ if (zn != "tablist") sel
142
+ else if (deleting(zn, key, del, i, p, d)) (if (sel == i) afterDelete(i, p, d) else sel)
143
+ else if (and(act == "automatic", isNavKey(key, o))) navTarget(key, o, i, p, d)
144
+ else if (or(key == "Enter", key == " ")) i
145
+ else sel
146
+
147
+ // ---- Tab / Shift+Tab: outside -> tablist -> panel -> outside ------------
148
+ def enteringTablist(zn: str, shift: bool): bool =
149
+ or(and(zn == "outside", not(shift)), and(zn == "panel", shift))
150
+ def nextZone(zn: str, shift: bool): str =
151
+ if (shift)
152
+ (if (zn == "outside") "panel" else if (zn == "panel") "tablist" else "outside")
153
+ else
154
+ (if (zn == "outside") "tablist" else if (zn == "tablist") "panel" else "outside")
155
+
156
+ // ---- request / commit (map ticket rt1p) ---------------------------------
157
+ // a change of intent fires onSelectionChange; an inert event (same intent) does not
158
+ def moved(before: int, after: int): int = if (before == after) 0 else 1
159
+ // the tab a pointer click would select (an unenabled target is inert)
160
+ def clickWant(tab: int, p: int, d: int, sel: int): int =
161
+ if (focusable(tab, p, d)) tab else sel
162
+ // controlled host that keeps the old value: the request is rejected
163
+ def holds: bool = and(controlled, not(accepting))
164
+ // controlled host that writes the intent back through the value prop
165
+ def follows: bool = and(controlled, accepting)
166
+ def verdict: str = if (holds) "rejected" else "accepted"
167
+ // the selection after a request for `want`
168
+ def landed(h: bool, current: int, want: int): int = if (h) current else want
169
+ // the value prop after a request for `want`
170
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
171
+
172
+ // ---- configure: packed variant (see header) -----------------------------
173
+ def variantActivation(v: int): str = if (v % 2 == 0) "automatic" else "manual"
174
+ def variantOrientation(v: int): str = if ((v / 2) % 2 == 0) "horizontal" else "vertical"
175
+ def variantDeletable(v: int): bool = (v / 4) % 2 == 1
176
+ def variantDisabled(v: int): int =
177
+ if ((v / 8) % 5 == 0) 0
178
+ else if ((v / 8) % 5 == 1) 1
179
+ else if ((v / 8) % 5 == 2) 2
180
+ else if ((v / 8) % 5 == 3) 5
181
+ else 14
182
+
183
+ // ---- ARIA contract, derived from state ----------------------------------
184
+ // tab k: role=tab, aria-selected, tabindex, aria-disabled
185
+ def ariaSelected(k: int, p: int, sel: int): bool = and(hasBit(p, k), k == sel)
186
+ def tabIndex(k: int, p: int, sel: int): int = if (ariaSelected(k, p, sel)) 0 else -1
187
+ def ariaDisabled(k: int, d: int): bool = hasBit(d, k)
188
+ // panel k: role=tabpanel, aria-labelledby=tab k; visible iff its tab is selected
189
+ def panelVisible(k: int, p: int, sel: int): bool = ariaSelected(k, p, sel)
190
+ def selectedCount(p: int, sel: int): int =
191
+ one(ariaSelected(0, p, sel)) + one(ariaSelected(1, p, sel)) +
192
+ one(ariaSelected(2, p, sel)) + one(ariaSelected(3, p, sel))
193
+ def tabStopCount(p: int, sel: int): int =
194
+ one(tabIndex(0, p, sel) == 0) + one(tabIndex(1, p, sel) == 0) +
195
+ one(tabIndex(2, p, sel) == 0) + one(tabIndex(3, p, sel) == 0)
196
+ def tabStopIndex(p: int, sel: int): int =
197
+ one(tabIndex(1, p, sel) == 0) + 2 * one(tabIndex(2, p, sel) == 0) +
198
+ 3 * one(tabIndex(3, p, sel) == 0)
199
+ def visiblePanelCount(p: int, sel: int): int =
200
+ one(panelVisible(0, p, sel)) + one(panelVisible(1, p, sel)) +
201
+ one(panelVisible(2, p, sel)) + one(panelVisible(3, p, sel))
202
+ def visiblePanelIndex(p: int, sel: int): int =
203
+ one(panelVisible(1, p, sel)) + 2 * one(panelVisible(2, p, sel)) +
204
+ 3 * one(panelVisible(3, p, sel))
205
+
206
+ action init = all {
207
+ activation' = "automatic",
208
+ orientation' = "horizontal",
209
+ deletable' = false,
210
+ disabled' = 0,
211
+ present' = 15,
212
+ selected' = 0,
213
+ focused' = 0,
214
+ zone' = "outside",
215
+ controlled' = false,
216
+ accepting' = false,
217
+ valueProp' = 0,
218
+ requested' = 0,
219
+ requests' = 0,
220
+ outcome' = "none",
221
+ base' = 0
222
+ }
223
+
224
+ // remount with a variant: all four tabs present, first enabled tab selected;
225
+ // the widget is uncontrolled again until the host asks to own it
226
+ action configure = {
227
+ nondet flavor = Set(
228
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
229
+ 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39
230
+ ).oneOf()
231
+ all {
232
+ activation' = variantActivation(flavor),
233
+ orientation' = variantOrientation(flavor),
234
+ deletable' = variantDeletable(flavor),
235
+ disabled' = variantDisabled(flavor),
236
+ present' = 15,
237
+ selected' = firstTab(15, variantDisabled(flavor)),
238
+ focused' = firstTab(15, variantDisabled(flavor)),
239
+ zone' = "outside",
240
+ controlled' = false,
241
+ accepting' = false,
242
+ valueProp' = firstTab(15, variantDisabled(flavor)),
243
+ requested' = firstTab(15, variantDisabled(flavor)),
244
+ requests' = 0,
245
+ outcome' = "none",
246
+ base' = firstTab(15, variantDisabled(flavor))
247
+ }
248
+ }
249
+
250
+ // Tab (shift = false) / Shift+Tab (shift = true)
251
+ action tab_key = {
252
+ nondet shift = Set(true, false).oneOf()
253
+ all {
254
+ activation' = activation,
255
+ orientation' = orientation,
256
+ deletable' = deletable,
257
+ disabled' = disabled,
258
+ present' = present,
259
+ selected' = selected,
260
+ focused' = if (enteringTablist(zone, shift)) selected else focused,
261
+ zone' = nextZone(zone, shift),
262
+ controlled' = controlled,
263
+ accepting' = accepting,
264
+ valueProp' = valueProp,
265
+ requested' = requested,
266
+ requests' = requests,
267
+ outcome' = outcome,
268
+ base' = base
269
+ }
270
+ }
271
+
272
+ // request: a keydown on the focused tab (a no-op unless DOM focus is in the
273
+ // tablist); automatic navigation and manual Enter/Space change selection
274
+ action press = {
275
+ nondet key = Set("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "Enter", " ", "Delete").oneOf()
276
+ all {
277
+ activation' = activation,
278
+ orientation' = orientation,
279
+ deletable' = deletable,
280
+ disabled' = disabled,
281
+ present' = if (deleting(zone, key, deletable, focused, present, disabled)) present - pow2(focused) else present,
282
+ selected' = landed(holds, selected, pressSelected(zone, key, activation, orientation, deletable, selected, focused, present, disabled)),
283
+ focused' = pressFocused(zone, key, orientation, deletable, focused, present, disabled),
284
+ zone' = zone,
285
+ controlled' = controlled,
286
+ accepting' = accepting,
287
+ valueProp' = propAfter(follows, valueProp, pressSelected(zone, key, activation, orientation, deletable, selected, focused, present, disabled)),
288
+ requested' = if (moved(selected, pressSelected(zone, key, activation, orientation, deletable, selected, focused, present, disabled)) == 1) pressSelected(zone, key, activation, orientation, deletable, selected, focused, present, disabled) else requested,
289
+ requests' = requests + moved(selected, pressSelected(zone, key, activation, orientation, deletable, selected, focused, present, disabled)),
290
+ outcome' = if (moved(selected, pressSelected(zone, key, activation, orientation, deletable, selected, focused, present, disabled)) == 1) verdict else outcome,
291
+ base' = if (moved(selected, pressSelected(zone, key, activation, orientation, deletable, selected, focused, present, disabled)) == 1) selected else base
292
+ }
293
+ }
294
+
295
+ // request: a pointer click on tab `tab` focuses and selects an enabled tab in
296
+ // both modes (an unenabled target is inert)
297
+ action click = {
298
+ nondet tab = Set(0, 1, 2, 3).oneOf()
299
+ all {
300
+ activation' = activation,
301
+ orientation' = orientation,
302
+ deletable' = deletable,
303
+ disabled' = disabled,
304
+ present' = present,
305
+ selected' = landed(holds, selected, clickWant(tab, present, disabled, selected)),
306
+ focused' = if (focusable(tab, present, disabled)) tab else focused,
307
+ zone' = if (focusable(tab, present, disabled)) "tablist" else zone,
308
+ controlled' = controlled,
309
+ accepting' = accepting,
310
+ valueProp' = propAfter(follows, valueProp, clickWant(tab, present, disabled, selected)),
311
+ requested' = if (moved(selected, clickWant(tab, present, disabled, selected)) == 1) clickWant(tab, present, disabled, selected) else requested,
312
+ requests' = requests + moved(selected, clickWant(tab, present, disabled, selected)),
313
+ outcome' = if (moved(selected, clickWant(tab, present, disabled, selected)) == 1) verdict else outcome,
314
+ base' = if (moved(selected, clickWant(tab, present, disabled, selected)) == 1) selected else base
315
+ }
316
+ }
317
+
318
+ // commit: the host writes the controlled selection (a no-op while uncontrolled)
319
+ action setValue = {
320
+ nondet v = Set(0, 1, 2, 3).oneOf()
321
+ all {
322
+ activation' = activation,
323
+ orientation' = orientation,
324
+ deletable' = deletable,
325
+ disabled' = disabled,
326
+ present' = present,
327
+ selected' = if (and(controlled, focusable(v, present, disabled))) v else selected,
328
+ focused' = focused,
329
+ zone' = zone,
330
+ controlled' = controlled,
331
+ accepting' = accepting,
332
+ valueProp' = if (and(controlled, focusable(v, present, disabled))) v else valueProp,
333
+ requested' = requested,
334
+ requests' = requests,
335
+ outcome' = if (controlled) "none" else outcome,
336
+ base' = base
337
+ }
338
+ }
339
+
340
+ // configuration: the host starts owning the selection, adopting the current one
341
+ action control = all {
342
+ activation' = activation,
343
+ orientation' = orientation,
344
+ deletable' = deletable,
345
+ disabled' = disabled,
346
+ present' = present,
347
+ selected' = selected,
348
+ focused' = focused,
349
+ zone' = zone,
350
+ controlled' = true,
351
+ accepting' = accepting,
352
+ valueProp' = selected,
353
+ requested' = requested,
354
+ requests' = requests,
355
+ outcome' = outcome,
356
+ base' = base
357
+ }
358
+
359
+ // configuration: the controlled host answers every request with its intent
360
+ action acceptRequests = all {
361
+ activation' = activation,
362
+ orientation' = orientation,
363
+ deletable' = deletable,
364
+ disabled' = disabled,
365
+ present' = present,
366
+ selected' = selected,
367
+ focused' = focused,
368
+ zone' = zone,
369
+ controlled' = controlled,
370
+ accepting' = true,
371
+ valueProp' = valueProp,
372
+ requested' = requested,
373
+ requests' = requests,
374
+ outcome' = outcome,
375
+ base' = base
376
+ }
377
+
378
+ action step = any { init, configure, tab_key, press, click, setValue, control, acceptRequests }
379
+
380
+ val ConfigValid: bool = and(
381
+ or(activation == "automatic", activation == "manual"),
382
+ ariaOrientationKnown(orientation)
383
+ )
384
+ val ZoneValid: bool = or(zone == "outside", zone == "tablist", zone == "panel")
385
+ val PresentInRange: bool = and(present >= 1, present <= 15)
386
+ val AtLeastOneFocusableTab: bool = focusableCount(present, disabled) >= 1
387
+ // selection and the DOM-focus cursor always rest on a live, enabled tab
388
+ val SelectedTabIsFocusable: bool = focusable(selected, present, disabled)
389
+ val FocusedTabIsFocusable: bool = focusable(focused, present, disabled)
390
+ // exactly one tab is aria-selected
391
+ val ExactlyOneSelectedTab: bool = selectedCount(present, selected) == 1
392
+ // roving tabindex: exactly one tab stop (tabindex=0) and it is the selected tab
393
+ val ExactlyOneTabStopOnSelected: bool =
394
+ and(tabStopCount(present, selected) == 1, tabStopIndex(present, selected) == selected)
395
+ // exactly one panel visible and it belongs to the selected tab
396
+ val VisiblePanelIsSelectedTabPanel: bool =
397
+ and(visiblePanelCount(present, selected) == 1, visiblePanelIndex(present, selected) == selected)
398
+ // automatic activation: selection follows focus while focus is on a tab; a
399
+ // controlled host that rejects a request leaves focus on the requested tab
400
+ val AutomaticSelectionFollowsFocus: bool =
401
+ or(activation != "automatic", zone != "tablist", controlled, selected == focused)
402
+ // only deletable tablists ever lose a tab
403
+ val OnlyDeletableLosesTabs: bool = or(deletable, present == 15)
404
+ // callbacks only ever count up from zero
405
+ val RequestsNonNegative: bool = requests >= 0
406
+ // controlled: only the value prop sets the state, so no event commits
407
+ val ControlledFollowsProp: bool = or(not(controlled), selected == valueProp)
408
+ // a rejected request leaves the state as it found it
409
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), selected == base)
410
+ // an accepted request committed exactly its intent
411
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), selected == requested)
412
+ }