@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,494 @@
1
+ // WAI-ARIA APG — Spinbutton pattern.
2
+ // https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/
3
+ // example: Quantity Spin Button (the APG ships no date-picker spinbutton
4
+ // example; the closest, "Quantity", is the one the pattern page links).
5
+ //
6
+ // Modelled (APG sections):
7
+ // - About This Pattern: text field + increase/decrease buttons; the text
8
+ // field is the only focusable part, focus stays on it during operation.
9
+ // - Keyboard Interaction: Up/Down (step), Page Up/Down (optional larger
10
+ // step), Home -> min, End -> max, printable characters edit the text.
11
+ // - WAI-ARIA Roles, States, Properties: role=spinbutton, aria-valuenow /
12
+ // aria-valuemin / aria-valuemax, aria-invalid, and the tab-order rule that
13
+ // the buttons are NOT focusable by Tab (tabindex -1).
14
+ //
15
+ // The committed `value` is separate from the typed `draft` text. The draft is a
16
+ // validity pool: the supported subset has no string parsing, so the spec
17
+ // carries a fixed set of sample texts ("" / "5" / "12" / "13" / "0" / "abc")
18
+ // and explicit defs say which parse to an in-range integer. Enter commits a
19
+ // valid draft, an invalid one reverts; blur and Escape follow the same rule
20
+ // (blur commits-or-reverts, Escape always reverts). Spin keys and the buttons
21
+ // resolve a pending draft first, then step from the result.
22
+ //
23
+ // Variant: `wrap` selects clamp-at-bounds (false) or wrap-around (true). It is
24
+ // a switchable var so one spec covers both; `setWrap` resets `result`.
25
+ //
26
+ // Request vs commit (map ticket rt1p). Every value-changing event (spin keys,
27
+ // Home/End, the stepper buttons, Enter commit and blur) is a *request* carrying
28
+ // an intent (`requested`); it fires onValueChange (`requests` counts the calls)
29
+ // when the intent differs from the current value, and then:
30
+ // uncontrolled the request is accepted: the value commits the intent
31
+ // controlled, holds the host ignores it ("rejected"): the value is untouched
32
+ // controlled, accepts the host answers with the same value (`accepting`): the
33
+ // value commits through the controlled prop, not the event
34
+ // `valueProp` is the only thing that changes the value while `controlled`;
35
+ // `setValue` / `hostToggle` are the host writing it. `control` / `acceptRequests`
36
+ // are configuration events (init is literal-only). Typing, Escape and `setWrap`
37
+ // change no committed value, so they are not requests. Invariants for the
38
+ // model: ControlledFollowsProp (no event commits while controlled),
39
+ // RejectedRequestUntouched, AcceptedRequestCommits.
40
+ //
41
+ // Range constants are small on purpose: min 1, max 12 (a month), step 1, page 3.
42
+ //
43
+ // Deliberately omitted:
44
+ // - aria-valuetext / labels (aria-label, aria-labelledby): strings with no
45
+ // state; the Solid layer supplies them.
46
+ // - spinbuttons without a min/max (aria-valuemin/max are always present),
47
+ // and non-integer ("decimal") values.
48
+ // - per-character text editing, caret/selection, and key filtering: `draft`
49
+ // is replaced wholesale by a sample text.
50
+ // - the commit/revert policy (Enter/Escape/blur) is a widely used
51
+ // convention, not APG text; so is the buttons' aria-disabled-at-bound.
52
+ // - press-and-hold auto-repeat on the buttons, mouse wheel.
53
+ //
54
+ // Generator notes: no action preconditions (guards live in the assignment
55
+ // RHS); every action assigns every var.
56
+ module apg_spinbutton {
57
+ var value: int // the committed value (aria-valuenow), always in [min,max]
58
+ var focused: bool // the text field has focus
59
+ var editing: bool // the text field holds an uncommitted draft
60
+ var draft: str // the typed text while editing, "" otherwise
61
+ var wrap: bool // variant: wrap-around instead of clamping at the bounds
62
+ var result: str // what the last action did: "none" | "focused" | "blurred" | "stepped" | "clamped" | "wrapped" | "jumped" | "typed" | "committed" | "reverted" | "mode"
63
+ var controlled: bool // the host owns the value (`value` prop present)
64
+ var accepting: bool // controlled host that answers every request with its intent
65
+ var valueProp: int // the controlled value prop (meaningful while controlled)
66
+ var requested: int // intent of the last request
67
+ var outcome: str // "none" | "accepted" | "rejected": fate of the last request
68
+ var base: int // value when the last request was received
69
+ var requests: int // number of requests that fired onValueChange
70
+
71
+ // --- range constants ---------------------------------------------------
72
+ def minValue: int = 1
73
+ def maxValue: int = 12
74
+ def smallStep: int = 1
75
+ def largeStep: int = 3
76
+ def span: int = maxValue - minValue + 1
77
+
78
+ // --- draft validity pool -----------------------------------------------
79
+ def draftIsNumber(t: str): bool =
80
+ or(or(t == "5", t == "12"), or(t == "13", t == "0"))
81
+ def draftNumber(t: str): int =
82
+ if (t == "5") 5 else if (t == "12") 12 else if (t == "13") 13 else 0
83
+ def draftInRange(t: str): bool =
84
+ and(draftIsNumber(t), and(draftNumber(t) >= minValue, draftNumber(t) <= maxValue))
85
+
86
+ // --- value arithmetic: clamp vs wrap -----------------------------------
87
+ def up(v: int, d: int, w: bool): int =
88
+ if (v + d <= maxValue) v + d else if (w) v + d - span else maxValue
89
+ def down(v: int, d: int, w: bool): int =
90
+ if (v - d >= minValue) v - d else if (w) v - d + span else minValue
91
+ def upResult(v: int, d: int, w: bool): str =
92
+ if (v + d <= maxValue) "stepped" else if (w) "wrapped" else "clamped"
93
+ def downResult(v: int, d: int, w: bool): str =
94
+ if (v - d >= minValue) "stepped" else if (w) "wrapped" else "clamped"
95
+
96
+ // the value a spin key / button steps from: a pending valid draft wins,
97
+ // an invalid one is discarded
98
+ def resolveBase(ed: bool, dr: str, v: int): int =
99
+ if (and(ed, draftInRange(dr))) draftNumber(dr) else v
100
+ def resolveResult(ed: bool, dr: str): str =
101
+ if (ed) (if (draftInRange(dr)) "committed" else "reverted") else "none"
102
+
103
+ // --- request / commit (map ticket rt1p) ----------------------------------
104
+ def moved(before: int, after: int): int = if (before == after) 0 else 1
105
+ def wants(f: bool, current: int, want: int): bool = and(f, want != current)
106
+ // controlled host that keeps the old value: the request is rejected
107
+ def holds: bool = and(controlled, not(accepting))
108
+ // controlled host that writes the intent back through the value prop
109
+ def follows: bool = and(controlled, accepting)
110
+ def verdict: str = if (holds) "rejected" else "accepted"
111
+ def landed(h: bool, current: int, want: int): int = if (h) current else want
112
+ def propAfter(f: bool, p: int, want: int): int = if (f) want else p
113
+
114
+ // --- ARIA contract, derived from the state -----------------------------
115
+ def role: str = "spinbutton"
116
+ def ariaValueNow(v: int): int = v
117
+ def ariaValueMin: int = minValue
118
+ def ariaValueMax: int = maxValue
119
+ // aria-invalid: the pending draft is not an in-range number
120
+ def ariaInvalid(ed: bool, dr: str): bool = and(ed, not(draftInRange(dr)))
121
+ def inputTabindex: int = 0
122
+ def incrementTabindex: int = -1
123
+ def decrementTabindex: int = -1
124
+ // buttons are inert at a bound only when clamping
125
+ def incrementDisabled(v: int, w: bool): bool = and(not(w), v == maxValue)
126
+ def decrementDisabled(v: int, w: bool): bool = and(not(w), v == minValue)
127
+
128
+ action init = all {
129
+ value' = 5,
130
+ focused' = false,
131
+ editing' = false,
132
+ draft' = "",
133
+ wrap' = false,
134
+ result' = "none",
135
+ controlled' = false,
136
+ accepting' = false,
137
+ valueProp' = 5,
138
+ requested' = 5,
139
+ outcome' = "none",
140
+ base' = 5,
141
+ requests' = 0
142
+ }
143
+
144
+ action focus = all {
145
+ value' = value,
146
+ focused' = true,
147
+ editing' = editing,
148
+ draft' = draft,
149
+ wrap' = wrap,
150
+ result' = "focused",
151
+ controlled' = controlled,
152
+ accepting' = accepting,
153
+ valueProp' = valueProp,
154
+ requested' = requested,
155
+ outcome' = outcome,
156
+ base' = base,
157
+ requests' = requests
158
+ }
159
+
160
+ // request: blur ends editing and commits a valid draft / reverts an invalid one
161
+ action blur = all {
162
+ value' = landed(holds, value, resolveBase(editing, draft, value)),
163
+ focused' = false,
164
+ editing' = false,
165
+ draft' = "",
166
+ wrap' = wrap,
167
+ result' = if (editing) resolveResult(editing, draft) else "blurred",
168
+ controlled' = controlled,
169
+ accepting' = accepting,
170
+ valueProp' = if (wants(true, value, resolveBase(editing, draft, value))) propAfter(follows, valueProp, resolveBase(editing, draft, value)) else valueProp,
171
+ requested' = if (wants(true, value, resolveBase(editing, draft, value))) resolveBase(editing, draft, value) else requested,
172
+ outcome' = if (wants(true, value, resolveBase(editing, draft, value))) verdict else outcome,
173
+ base' = if (wants(true, value, resolveBase(editing, draft, value))) value else base,
174
+ requests' = if (wants(true, value, resolveBase(editing, draft, value))) requests + 1 else requests
175
+ }
176
+
177
+ // request: keyboard, only acts while the text field has focus
178
+ action arrowUp = all {
179
+ value' = if (focused) landed(holds, value, up(resolveBase(editing, draft, value), smallStep, wrap)) else value,
180
+ focused' = focused,
181
+ editing' = false,
182
+ draft' = "",
183
+ wrap' = wrap,
184
+ result' = if (focused) upResult(resolveBase(editing, draft, value), smallStep, wrap) else result,
185
+ controlled' = controlled,
186
+ accepting' = accepting,
187
+ valueProp' = if (wants(focused, value, up(resolveBase(editing, draft, value), smallStep, wrap))) propAfter(follows, valueProp, up(resolveBase(editing, draft, value), smallStep, wrap)) else valueProp,
188
+ requested' = if (wants(focused, value, up(resolveBase(editing, draft, value), smallStep, wrap))) up(resolveBase(editing, draft, value), smallStep, wrap) else requested,
189
+ outcome' = if (wants(focused, value, up(resolveBase(editing, draft, value), smallStep, wrap))) verdict else outcome,
190
+ base' = if (wants(focused, value, up(resolveBase(editing, draft, value), smallStep, wrap))) value else base,
191
+ requests' = if (wants(focused, value, up(resolveBase(editing, draft, value), smallStep, wrap))) requests + 1 else requests
192
+ }
193
+
194
+ action arrowDown = all {
195
+ value' = if (focused) landed(holds, value, down(resolveBase(editing, draft, value), smallStep, wrap)) else value,
196
+ focused' = focused,
197
+ editing' = false,
198
+ draft' = "",
199
+ wrap' = wrap,
200
+ result' = if (focused) downResult(resolveBase(editing, draft, value), smallStep, wrap) else result,
201
+ controlled' = controlled,
202
+ accepting' = accepting,
203
+ valueProp' = if (wants(focused, value, down(resolveBase(editing, draft, value), smallStep, wrap))) propAfter(follows, valueProp, down(resolveBase(editing, draft, value), smallStep, wrap)) else valueProp,
204
+ requested' = if (wants(focused, value, down(resolveBase(editing, draft, value), smallStep, wrap))) down(resolveBase(editing, draft, value), smallStep, wrap) else requested,
205
+ outcome' = if (wants(focused, value, down(resolveBase(editing, draft, value), smallStep, wrap))) verdict else outcome,
206
+ base' = if (wants(focused, value, down(resolveBase(editing, draft, value), smallStep, wrap))) value else base,
207
+ requests' = if (wants(focused, value, down(resolveBase(editing, draft, value), smallStep, wrap))) requests + 1 else requests
208
+ }
209
+
210
+ action pageUp = all {
211
+ value' = if (focused) landed(holds, value, up(resolveBase(editing, draft, value), largeStep, wrap)) else value,
212
+ focused' = focused,
213
+ editing' = false,
214
+ draft' = "",
215
+ wrap' = wrap,
216
+ result' = if (focused) upResult(resolveBase(editing, draft, value), largeStep, wrap) else result,
217
+ controlled' = controlled,
218
+ accepting' = accepting,
219
+ valueProp' = if (wants(focused, value, up(resolveBase(editing, draft, value), largeStep, wrap))) propAfter(follows, valueProp, up(resolveBase(editing, draft, value), largeStep, wrap)) else valueProp,
220
+ requested' = if (wants(focused, value, up(resolveBase(editing, draft, value), largeStep, wrap))) up(resolveBase(editing, draft, value), largeStep, wrap) else requested,
221
+ outcome' = if (wants(focused, value, up(resolveBase(editing, draft, value), largeStep, wrap))) verdict else outcome,
222
+ base' = if (wants(focused, value, up(resolveBase(editing, draft, value), largeStep, wrap))) value else base,
223
+ requests' = if (wants(focused, value, up(resolveBase(editing, draft, value), largeStep, wrap))) requests + 1 else requests
224
+ }
225
+
226
+ action pageDown = all {
227
+ value' = if (focused) landed(holds, value, down(resolveBase(editing, draft, value), largeStep, wrap)) else value,
228
+ focused' = focused,
229
+ editing' = false,
230
+ draft' = "",
231
+ wrap' = wrap,
232
+ result' = if (focused) downResult(resolveBase(editing, draft, value), largeStep, wrap) else result,
233
+ controlled' = controlled,
234
+ accepting' = accepting,
235
+ valueProp' = if (wants(focused, value, down(resolveBase(editing, draft, value), largeStep, wrap))) propAfter(follows, valueProp, down(resolveBase(editing, draft, value), largeStep, wrap)) else valueProp,
236
+ requested' = if (wants(focused, value, down(resolveBase(editing, draft, value), largeStep, wrap))) down(resolveBase(editing, draft, value), largeStep, wrap) else requested,
237
+ outcome' = if (wants(focused, value, down(resolveBase(editing, draft, value), largeStep, wrap))) verdict else outcome,
238
+ base' = if (wants(focused, value, down(resolveBase(editing, draft, value), largeStep, wrap))) value else base,
239
+ requests' = if (wants(focused, value, down(resolveBase(editing, draft, value), largeStep, wrap))) requests + 1 else requests
240
+ }
241
+
242
+ action home = all {
243
+ value' = if (focused) landed(holds, value, minValue) else value,
244
+ focused' = focused,
245
+ editing' = false,
246
+ draft' = "",
247
+ wrap' = wrap,
248
+ result' = if (focused) "jumped" else result,
249
+ controlled' = controlled,
250
+ accepting' = accepting,
251
+ valueProp' = if (wants(focused, value, minValue)) propAfter(follows, valueProp, minValue) else valueProp,
252
+ requested' = if (wants(focused, value, minValue)) minValue else requested,
253
+ outcome' = if (wants(focused, value, minValue)) verdict else outcome,
254
+ base' = if (wants(focused, value, minValue)) value else base,
255
+ requests' = if (wants(focused, value, minValue)) requests + 1 else requests
256
+ }
257
+
258
+ action end = all {
259
+ value' = if (focused) landed(holds, value, maxValue) else value,
260
+ focused' = focused,
261
+ editing' = false,
262
+ draft' = "",
263
+ wrap' = wrap,
264
+ result' = if (focused) "jumped" else result,
265
+ controlled' = controlled,
266
+ accepting' = accepting,
267
+ valueProp' = if (wants(focused, value, maxValue)) propAfter(follows, valueProp, maxValue) else valueProp,
268
+ requested' = if (wants(focused, value, maxValue)) maxValue else requested,
269
+ outcome' = if (wants(focused, value, maxValue)) verdict else outcome,
270
+ base' = if (wants(focused, value, maxValue)) value else base,
271
+ requests' = if (wants(focused, value, maxValue)) requests + 1 else requests
272
+ }
273
+
274
+ // request: pointer, the buttons are out of the tab order, work with or
275
+ // without focus, and leave focus on the text field
276
+ action clickIncrement = all {
277
+ value' = landed(holds, value, up(resolveBase(editing, draft, value), smallStep, wrap)),
278
+ focused' = true,
279
+ editing' = false,
280
+ draft' = "",
281
+ wrap' = wrap,
282
+ result' = upResult(resolveBase(editing, draft, value), smallStep, wrap),
283
+ controlled' = controlled,
284
+ accepting' = accepting,
285
+ valueProp' = if (wants(true, value, up(resolveBase(editing, draft, value), smallStep, wrap))) propAfter(follows, valueProp, up(resolveBase(editing, draft, value), smallStep, wrap)) else valueProp,
286
+ requested' = if (wants(true, value, up(resolveBase(editing, draft, value), smallStep, wrap))) up(resolveBase(editing, draft, value), smallStep, wrap) else requested,
287
+ outcome' = if (wants(true, value, up(resolveBase(editing, draft, value), smallStep, wrap))) verdict else outcome,
288
+ base' = if (wants(true, value, up(resolveBase(editing, draft, value), smallStep, wrap))) value else base,
289
+ requests' = if (wants(true, value, up(resolveBase(editing, draft, value), smallStep, wrap))) requests + 1 else requests
290
+ }
291
+
292
+ action clickDecrement = all {
293
+ value' = landed(holds, value, down(resolveBase(editing, draft, value), smallStep, wrap)),
294
+ focused' = true,
295
+ editing' = false,
296
+ draft' = "",
297
+ wrap' = wrap,
298
+ result' = downResult(resolveBase(editing, draft, value), smallStep, wrap),
299
+ controlled' = controlled,
300
+ accepting' = accepting,
301
+ valueProp' = if (wants(true, value, down(resolveBase(editing, draft, value), smallStep, wrap))) propAfter(follows, valueProp, down(resolveBase(editing, draft, value), smallStep, wrap)) else valueProp,
302
+ requested' = if (wants(true, value, down(resolveBase(editing, draft, value), smallStep, wrap))) down(resolveBase(editing, draft, value), smallStep, wrap) else requested,
303
+ outcome' = if (wants(true, value, down(resolveBase(editing, draft, value), smallStep, wrap))) verdict else outcome,
304
+ base' = if (wants(true, value, down(resolveBase(editing, draft, value), smallStep, wrap))) value else base,
305
+ requests' = if (wants(true, value, down(resolveBase(editing, draft, value), smallStep, wrap))) requests + 1 else requests
306
+ }
307
+
308
+ // text editing: the draft is replaced by a sample text; value is untouched
309
+ action typeText = {
310
+ nondet text = Set("", "5", "12", "13", "0", "abc").oneOf()
311
+ all {
312
+ value' = value,
313
+ focused' = focused,
314
+ editing' = if (focused) true else editing,
315
+ draft' = if (focused) text else draft,
316
+ wrap' = wrap,
317
+ result' = if (focused) "typed" else result,
318
+ controlled' = controlled,
319
+ accepting' = accepting,
320
+ valueProp' = valueProp,
321
+ requested' = requested,
322
+ outcome' = outcome,
323
+ base' = base,
324
+ requests' = requests
325
+ }
326
+ }
327
+
328
+ // request: Enter commits a valid draft, reverts an invalid one; stay focused
329
+ action commit = all {
330
+ value' = if (focused) landed(holds, value, resolveBase(editing, draft, value)) else value,
331
+ focused' = focused,
332
+ editing' = if (focused) false else editing,
333
+ draft' = if (focused) "" else draft,
334
+ wrap' = wrap,
335
+ result' = if (focused) resolveResult(editing, draft) else result,
336
+ controlled' = controlled,
337
+ accepting' = accepting,
338
+ valueProp' = if (wants(focused, value, resolveBase(editing, draft, value))) propAfter(follows, valueProp, resolveBase(editing, draft, value)) else valueProp,
339
+ requested' = if (wants(focused, value, resolveBase(editing, draft, value))) resolveBase(editing, draft, value) else requested,
340
+ outcome' = if (wants(focused, value, resolveBase(editing, draft, value))) verdict else outcome,
341
+ base' = if (wants(focused, value, resolveBase(editing, draft, value))) value else base,
342
+ requests' = if (wants(focused, value, resolveBase(editing, draft, value))) requests + 1 else requests
343
+ }
344
+
345
+ // Escape: drop the draft, keep the committed value (not a request)
346
+ action revert = all {
347
+ value' = value,
348
+ focused' = focused,
349
+ editing' = false,
350
+ draft' = "",
351
+ wrap' = wrap,
352
+ result' = if (editing) "reverted" else "none",
353
+ controlled' = controlled,
354
+ accepting' = accepting,
355
+ valueProp' = valueProp,
356
+ requested' = requested,
357
+ outcome' = outcome,
358
+ base' = base,
359
+ requests' = requests
360
+ }
361
+
362
+ action setWrap = {
363
+ nondet mode = Set(true, false).oneOf()
364
+ all {
365
+ value' = value,
366
+ focused' = focused,
367
+ editing' = editing,
368
+ draft' = draft,
369
+ wrap' = mode,
370
+ result' = "mode",
371
+ controlled' = controlled,
372
+ accepting' = accepting,
373
+ valueProp' = valueProp,
374
+ requested' = requested,
375
+ outcome' = outcome,
376
+ base' = base,
377
+ requests' = requests
378
+ }
379
+ }
380
+
381
+ // commit: the host writes the controlled value (a no-op while uncontrolled)
382
+ action setValue = {
383
+ nondet v = Set(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12).oneOf()
384
+ all {
385
+ value' = if (controlled) v else value,
386
+ focused' = focused,
387
+ editing' = editing,
388
+ draft' = draft,
389
+ wrap' = wrap,
390
+ result' = result,
391
+ controlled' = controlled,
392
+ accepting' = accepting,
393
+ valueProp' = if (controlled) v else valueProp,
394
+ requested' = requested,
395
+ outcome' = if (controlled) "none" else outcome,
396
+ base' = base,
397
+ requests' = requests
398
+ }
399
+ }
400
+
401
+ // commit: the host flips its own copy of the value between the range ends
402
+ // (an outside two-state control)
403
+ action hostToggle = all {
404
+ value' = if (controlled) (if (valueProp == maxValue) minValue else maxValue) else value,
405
+ focused' = focused,
406
+ editing' = editing,
407
+ draft' = draft,
408
+ wrap' = wrap,
409
+ result' = result,
410
+ controlled' = controlled,
411
+ accepting' = accepting,
412
+ valueProp' = if (controlled) (if (valueProp == maxValue) minValue else maxValue) else valueProp,
413
+ requested' = requested,
414
+ outcome' = if (controlled) "none" else outcome,
415
+ base' = base,
416
+ requests' = requests
417
+ }
418
+
419
+ // configuration: the host starts owning the value, adopting the current one
420
+ action control = all {
421
+ value' = value,
422
+ focused' = focused,
423
+ editing' = editing,
424
+ draft' = draft,
425
+ wrap' = wrap,
426
+ result' = result,
427
+ controlled' = true,
428
+ accepting' = accepting,
429
+ valueProp' = value,
430
+ requested' = requested,
431
+ outcome' = outcome,
432
+ base' = base,
433
+ requests' = requests
434
+ }
435
+
436
+ // configuration: the controlled host answers every request with its intent
437
+ action acceptRequests = all {
438
+ value' = value,
439
+ focused' = focused,
440
+ editing' = editing,
441
+ draft' = draft,
442
+ wrap' = wrap,
443
+ result' = result,
444
+ controlled' = controlled,
445
+ accepting' = true,
446
+ valueProp' = valueProp,
447
+ requested' = requested,
448
+ outcome' = outcome,
449
+ base' = base,
450
+ requests' = requests
451
+ }
452
+
453
+ action step = any {
454
+ init, focus, blur, arrowUp, arrowDown, pageUp, pageDown, home, end,
455
+ clickIncrement, clickDecrement, typeText, commit, revert, setWrap,
456
+ setValue, hostToggle, control, acceptRequests
457
+ }
458
+
459
+ // --- invariants --------------------------------------------------------
460
+ // the committed value is always inside [min, max]; invalid text never lands in it
461
+ val ValueInRange: bool = and(value >= minValue, value <= maxValue)
462
+ // aria-valuenow lies within aria-valuemin..aria-valuemax
463
+ val AriaNowWithinBounds: bool =
464
+ and(ariaValueMin <= ariaValueNow(value), ariaValueNow(value) <= ariaValueMax)
465
+ // the derived ARIA bounds are the range constants
466
+ val AriaBoundsMatchRange: bool =
467
+ and(ariaValueMin == minValue, ariaValueMax == maxValue)
468
+ // the text field is the one Tab stop; the buttons never are
469
+ val ButtonsOutOfTabOrder: bool =
470
+ and(inputTabindex == 0, and(incrementTabindex == -1, decrementTabindex == -1))
471
+ // a draft exists exactly while editing
472
+ val DraftOnlyWhileEditing: bool = or(editing, draft == "")
473
+ // typing needs focus, and blur/Escape/Enter end it
474
+ val EditingImpliesFocus: bool = or(not(editing), focused)
475
+ // aria-invalid is only ever a property of the pending draft
476
+ val InvalidImpliesEditing: bool = or(not(ariaInvalid(editing, draft)), editing)
477
+ // a bound hit in clamp mode never reports a wrap, and vice versa
478
+ val WrapResultMatchesMode: bool =
479
+ and(or(not(result == "wrapped"), wrap), or(not(result == "clamped"), not(wrap)))
480
+ // disabled buttons are only offered when clamping at that exact bound
481
+ val DisabledOnlyAtBound: bool =
482
+ and(
483
+ or(not(incrementDisabled(value, wrap)), and(not(wrap), value == maxValue)),
484
+ or(not(decrementDisabled(value, wrap)), and(not(wrap), value == minValue))
485
+ )
486
+ // callbacks only ever count up from zero
487
+ val RequestsNonNegative: bool = requests >= 0
488
+ // controlled: only the value prop sets the state, so no event commits
489
+ val ControlledFollowsProp: bool = or(not(controlled), value == valueProp)
490
+ // a rejected request leaves the state as it found it
491
+ val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), value == base)
492
+ // an accepted request committed exactly its intent
493
+ val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), value == requested)
494
+ }