@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,360 @@
1
+ // WAI-ARIA APG — grid (data grid + layout grid): roving focus, navigation, wrap, edit mode.
2
+ //
3
+ // Modelled from https://www.w3.org/WAI/ARIA/apg/patterns/grid/ :
4
+ // - "Keyboard Interaction For Data Grids" (no wrapping, arrows, Home/End,
5
+ // Ctrl+Home/End, PageUp/PageDown)
6
+ // - "Keyboard Interaction For Layout Grids" (optional wrapping across row /
7
+ // column boundaries)
8
+ // - "Whether to Focus on a Cell Or an Element Inside It"
9
+ // - "Editing and Navigating Inside a Cell" (Enter / F2 / Escape / alphanumeric)
10
+ // - "WAI-ARIA Roles, States, and Properties" (aria-rowindex/colindex/rowcount/
11
+ // colcount/readonly, roving tabindex)
12
+ // and the examples https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/data-grids/
13
+ // and https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/layout-grids/
14
+ // (data-wrap-cols / data-wrap-rows, PageDown/PageUp by a fixed row count, tabindex
15
+ // -1/0 on the cell or on the widget inside it).
16
+ //
17
+ // The grid is a bounded ROWS x COLS body (header row: see grid-sort.qnt;
18
+ // selection: see grid-selection.qnt). Focus is the pair (row, col); a cell is
19
+ // addressed by the linear index row * COLS + col, which makes every wrap rule
20
+ // "+1 / -1 / +COLS" arithmetic.
21
+ //
22
+ // Events: `key` (the keydown alphabet below), `click` (pointer on a cell),
23
+ // `focus_in` / `focus_out` (focus enters / leaves the grid), and prop changes
24
+ // `set_kind`, `set_wrap_cols`, `set_wrap_rows`, `set_focus_target`,
25
+ // `set_readonly`. The ARIA contract is the derived defs (tabindex, aria-*), not
26
+ // state.
27
+ //
28
+ // Deliberate decisions where APG says "optional" / "may":
29
+ // - a data grid never wraps (APG: wrapping "would be disorienting" in a data
30
+ // grid), so switching to a data grid clears both wrap flags;
31
+ // - Enter or F2 in edit mode commits and restores grid navigation; Escape
32
+ // cancels (discards the draft) and restores navigation;
33
+ // - leaving the grid (blur, click elsewhere in the grid) commits an edit;
34
+ // - a read-only cell (last column) and a read-only grid never enter edit mode;
35
+ // turning the grid read-only mid-edit cancels the edit;
36
+ // - while editing, every other key belongs to the input (focus never moves).
37
+ //
38
+ // Deliberate omissions (no state / no events for them):
39
+ // - header rows/cells, sort and aria-sort (grid-sort.qnt);
40
+ // - cell/row/column selection and aria-selected (grid-selection.qnt);
41
+ // - aria-rowindex/aria-colindex gaps from virtualization or hidden columns
42
+ // (here the indices are always the dense 1-based position);
43
+ // - cells with several widgets (Tab / Arrow between widgets inside a cell) and
44
+ // Tab / Shift+Tab inside edit mode (modelled as a no-op);
45
+ // - layout-grid optional rule "Home/End go to the first/last cell of the grid
46
+ // when a row has fewer than three cells";
47
+ // - Enter moving the edit to the neighbouring cell, cut/copy/paste, spans,
48
+ // grid labelling (aria-labelledby/describedby), dynamic rows/columns,
49
+ // author-configurable page size (PAGE is a constant);
50
+ // - DOM `focus()` calls: focus is the abstract bit `hasFocus`.
51
+ //
52
+ // Controlled state (map ticket rt1p): `grid` is NAVIGATION-ONLY and stays
53
+ // uncontrolled. Focus is roving internal navigation state (`row`, `col`,
54
+ // `hasFocus`, `mode`), not a host-owned value the consumer can pass down; the
55
+ // user-changeable values live in the sibling machines — the selected rectangle
56
+ // (grid-selection.qnt) and the sort (grid-sort.qnt). Those two carry the
57
+ // `controlled`/`accepting`/`valueProp` request/commit shape; this machine has no
58
+ // `value` prop to express, so it deliberately omits it.
59
+ //
60
+ // Generator gaps hit: no `const` declarations (grid sizes are zero-argument
61
+ // `val`s of type int); one nondet per action (cell index / key name are single
62
+ // pools); no sets/lists/maps (cell state is derived from scalar vars; the
63
+ // "exactly one tab stop" invariant enumerates the ROWS x COLS cells).
64
+ module apg_grid {
65
+ var row: int // focused row, 0-based
66
+ var col: int // focused column, 0-based
67
+ var hasFocus: bool // DOM focus is inside the grid
68
+ var mode: str // "navigate" | "edit"
69
+ var dirty: bool // the edit draft differs from the committed value
70
+ var lastEdit: str // "none" | "committed" | "cancelled"
71
+ var gridKind: str // "data" | "layout"
72
+ var wrapCols: bool // Left/Right wrap across row boundaries (data-wrap-cols)
73
+ var wrapRows: bool // Up/Down wrap across column boundaries (data-wrap-rows)
74
+ var focusTarget: str // "cell" | "widget": what carries tabindex and DOM focus
75
+ var gridReadonly: bool // aria-readonly on the grid element
76
+
77
+ // bounded grid: change here, then keep CELL_POOL (click) and the
78
+ // tabStops enumeration in step
79
+ val ROWS: int = 4
80
+ val COLS: int = 3
81
+ val PAGE: int = 2
82
+ val LAST_ROW: int = ROWS - 1
83
+ val LAST_COL: int = COLS - 1
84
+ val CELLS: int = ROWS * COLS
85
+
86
+ // the last column is computed data (e.g. a balance): never editable
87
+ def colReadonly(c: int): bool = c == LAST_COL
88
+
89
+ def cur: int = row * COLS + col
90
+ def navigating: bool = and(hasFocus, mode == "navigate")
91
+ def editing: bool = mode == "edit"
92
+ def canEdit: bool = and(not(gridReadonly), not(colReadonly(col)))
93
+
94
+ // --- navigation targets (linear cell index); a blocked move returns `cur` ---
95
+ def rightIdx: int =
96
+ if (col < LAST_COL) cur + 1 else if (and(wrapCols, row < LAST_ROW)) cur + 1 else cur
97
+ def leftIdx: int =
98
+ if (col > 0) cur - 1 else if (and(wrapCols, row > 0)) cur - 1 else cur
99
+ def downIdx: int =
100
+ if (row < LAST_ROW) cur + COLS else if (and(wrapRows, col < LAST_COL)) col + 1 else cur
101
+ def upIdx: int =
102
+ if (row > 0) cur - COLS else if (and(wrapRows, col > 0)) LAST_ROW * COLS + col - 1 else cur
103
+ def pageDownIdx: int =
104
+ if (row + PAGE > LAST_ROW) LAST_ROW * COLS + col else cur + PAGE * COLS
105
+ def pageUpIdx: int =
106
+ if (row < PAGE) col else cur - PAGE * COLS
107
+
108
+ def keyTarget(k: str): int =
109
+ if (k == "ArrowRight") rightIdx
110
+ else if (k == "ArrowLeft") leftIdx
111
+ else if (k == "ArrowDown") downIdx
112
+ else if (k == "ArrowUp") upIdx
113
+ else if (k == "PageDown") pageDownIdx
114
+ else if (k == "PageUp") pageUpIdx
115
+ else if (k == "Home") row * COLS
116
+ else if (k == "End") row * COLS + LAST_COL
117
+ else if (k == "Control+Home") 0
118
+ else if (k == "Control+End") CELLS - 1
119
+ else cur
120
+
121
+ // --- edit mode ---
122
+ // Enter / F2 / a printable key start editing a focused editable cell
123
+ def startsEdit(k: str): bool =
124
+ and(navigating, canEdit, or(k == "Enter", k == "F2", k == "a"))
125
+ def commitsEdit(k: str): bool = and(editing, or(k == "Enter", k == "F2"))
126
+ def cancelsEdit(k: str): bool = and(editing, k == "Escape")
127
+ def endsEdit(k: str): bool = or(commitsEdit(k), cancelsEdit(k))
128
+ def leavesGrid(k: str): bool = and(navigating, or(k == "Tab", k == "Shift+Tab"))
129
+
130
+ // --- ARIA contract (derived) ---
131
+ val ARIA_ROWCOUNT: int = ROWS
132
+ val ARIA_COLCOUNT: int = COLS
133
+ def ariaRowindex(r: int): int = r + 1
134
+ def ariaColindex(c: int): int = c + 1
135
+ def cellReadonly(r: int, c: int): bool = or(gridReadonly, colReadonly(c))
136
+ // roving tabindex: exactly the focused cell (or its widget) is tabbable
137
+ def cellTabindex(r: int, c: int): int =
138
+ if (and(r == row, c == col, focusTarget == "cell")) 0 else -1
139
+ def widgetTabindex(r: int, c: int): int =
140
+ if (and(r == row, c == col, focusTarget == "widget")) 0 else -1
141
+ def cellDomFocused(r: int, c: int): bool =
142
+ and(navigating, focusTarget == "cell", r == row, c == col)
143
+ def widgetDomFocused(r: int, c: int): bool =
144
+ and(navigating, focusTarget == "widget", r == row, c == col)
145
+ def inputDomFocused(r: int, c: int): bool = and(editing, r == row, c == col)
146
+
147
+ def tabStops(r: int, c: int): int =
148
+ (if (cellTabindex(r, c) == 0) 1 else 0) + (if (widgetTabindex(r, c) == 0) 1 else 0)
149
+ def rowTabStops(r: int): int =
150
+ tabStops(r, 0) + tabStops(r, 1) + tabStops(r, 2)
151
+
152
+ action init = all {
153
+ row' = 0,
154
+ col' = 0,
155
+ hasFocus' = false,
156
+ mode' = "navigate",
157
+ dirty' = false,
158
+ lastEdit' = "none",
159
+ gridKind' = "data",
160
+ wrapCols' = false,
161
+ wrapRows' = false,
162
+ focusTarget' = "cell",
163
+ gridReadonly' = false
164
+ }
165
+
166
+ // keydown while the grid has focus; ignored otherwise
167
+ action key = {
168
+ nondet key = Set(
169
+ "ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp",
170
+ "Home", "End", "Control+Home", "Control+End",
171
+ "PageUp", "PageDown",
172
+ "Enter", "F2", "Escape", "a",
173
+ "Tab", "Shift+Tab"
174
+ ).oneOf()
175
+ all {
176
+ row' = if (navigating) keyTarget(key) / COLS else row,
177
+ col' = if (navigating) keyTarget(key) % COLS else col,
178
+ hasFocus' = if (leavesGrid(key)) false else hasFocus,
179
+ mode' = if (startsEdit(key)) "edit" else if (endsEdit(key)) "navigate" else mode,
180
+ dirty' =
181
+ if (startsEdit(key)) (key == "a")
182
+ else if (and(editing, key == "a")) true
183
+ else if (endsEdit(key)) false
184
+ else dirty,
185
+ lastEdit' =
186
+ if (commitsEdit(key)) "committed"
187
+ else if (cancelsEdit(key)) "cancelled"
188
+ else lastEdit,
189
+ gridKind' = gridKind,
190
+ wrapCols' = wrapCols,
191
+ wrapRows' = wrapRows,
192
+ focusTarget' = focusTarget,
193
+ gridReadonly' = gridReadonly
194
+ }
195
+ }
196
+
197
+ // pointer on a cell: focuses it; clicking another cell commits an edit
198
+ action click = {
199
+ nondet cell = Set(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11).oneOf()
200
+ all {
201
+ row' = cell / COLS,
202
+ col' = cell % COLS,
203
+ hasFocus' = true,
204
+ mode' = if (and(editing, cell == cur)) "edit" else "navigate",
205
+ dirty' = if (and(editing, cell == cur)) dirty else false,
206
+ lastEdit' = if (and(editing, cell != cur)) "committed" else lastEdit,
207
+ gridKind' = gridKind,
208
+ wrapCols' = wrapCols,
209
+ wrapRows' = wrapRows,
210
+ focusTarget' = focusTarget,
211
+ gridReadonly' = gridReadonly
212
+ }
213
+ }
214
+
215
+ // focus enters the grid (Tab): lands on the roving tab stop, position kept
216
+ action focus_in = all {
217
+ row' = row,
218
+ col' = col,
219
+ hasFocus' = true,
220
+ mode' = mode,
221
+ dirty' = dirty,
222
+ lastEdit' = lastEdit,
223
+ gridKind' = gridKind,
224
+ wrapCols' = wrapCols,
225
+ wrapRows' = wrapRows,
226
+ focusTarget' = focusTarget,
227
+ gridReadonly' = gridReadonly
228
+ }
229
+
230
+ // focus leaves the grid: an open edit is committed, the roving tab stop stays
231
+ action focus_out = all {
232
+ row' = row,
233
+ col' = col,
234
+ hasFocus' = false,
235
+ mode' = "navigate",
236
+ dirty' = false,
237
+ lastEdit' = if (editing) "committed" else lastEdit,
238
+ gridKind' = gridKind,
239
+ wrapCols' = wrapCols,
240
+ wrapRows' = wrapRows,
241
+ focusTarget' = focusTarget,
242
+ gridReadonly' = gridReadonly
243
+ }
244
+
245
+ // data grids never wrap
246
+ action set_kind = {
247
+ nondet choice = Set("data", "layout").oneOf()
248
+ all {
249
+ row' = row,
250
+ col' = col,
251
+ hasFocus' = hasFocus,
252
+ mode' = mode,
253
+ dirty' = dirty,
254
+ lastEdit' = lastEdit,
255
+ gridKind' = choice,
256
+ wrapCols' = if (choice == "data") false else wrapCols,
257
+ wrapRows' = if (choice == "data") false else wrapRows,
258
+ focusTarget' = focusTarget,
259
+ gridReadonly' = gridReadonly
260
+ }
261
+ }
262
+
263
+ // wrapping is only available on layout grids
264
+ action set_wrap_cols = {
265
+ nondet flag = Set(true, false).oneOf()
266
+ all {
267
+ row' = row,
268
+ col' = col,
269
+ hasFocus' = hasFocus,
270
+ mode' = mode,
271
+ dirty' = dirty,
272
+ lastEdit' = lastEdit,
273
+ gridKind' = gridKind,
274
+ wrapCols' = if (gridKind == "layout") flag else false,
275
+ wrapRows' = wrapRows,
276
+ focusTarget' = focusTarget,
277
+ gridReadonly' = gridReadonly
278
+ }
279
+ }
280
+
281
+ action set_wrap_rows = {
282
+ nondet flag = Set(true, false).oneOf()
283
+ all {
284
+ row' = row,
285
+ col' = col,
286
+ hasFocus' = hasFocus,
287
+ mode' = mode,
288
+ dirty' = dirty,
289
+ lastEdit' = lastEdit,
290
+ gridKind' = gridKind,
291
+ wrapCols' = wrapCols,
292
+ wrapRows' = if (gridKind == "layout") flag else false,
293
+ focusTarget' = focusTarget,
294
+ gridReadonly' = gridReadonly
295
+ }
296
+ }
297
+
298
+ action set_focus_target = {
299
+ nondet choice = Set("cell", "widget").oneOf()
300
+ all {
301
+ row' = row,
302
+ col' = col,
303
+ hasFocus' = hasFocus,
304
+ mode' = mode,
305
+ dirty' = dirty,
306
+ lastEdit' = lastEdit,
307
+ gridKind' = gridKind,
308
+ wrapCols' = wrapCols,
309
+ wrapRows' = wrapRows,
310
+ focusTarget' = choice,
311
+ gridReadonly' = gridReadonly
312
+ }
313
+ }
314
+
315
+ // read-only grid: a running edit is cancelled
316
+ action set_readonly = {
317
+ nondet flag = Set(true, false).oneOf()
318
+ all {
319
+ row' = row,
320
+ col' = col,
321
+ hasFocus' = hasFocus,
322
+ mode' = if (and(flag, editing)) "navigate" else mode,
323
+ dirty' = if (and(flag, editing)) false else dirty,
324
+ lastEdit' = if (and(flag, editing)) "cancelled" else lastEdit,
325
+ gridKind' = gridKind,
326
+ wrapCols' = wrapCols,
327
+ wrapRows' = wrapRows,
328
+ focusTarget' = focusTarget,
329
+ gridReadonly' = flag
330
+ }
331
+ }
332
+
333
+ action step = any {
334
+ init, key, click, focus_in, focus_out,
335
+ set_kind, set_wrap_cols, set_wrap_rows, set_focus_target, set_readonly
336
+ }
337
+
338
+ // focus coordinates stay inside the bounded grid
339
+ val FocusInBounds: bool = and(row >= 0, row < ROWS, col >= 0, col < COLS)
340
+ // roving tabindex: exactly one element (cell or widget) in the whole grid has tabindex=0
341
+ val ExactlyOneTabStop: bool =
342
+ rowTabStops(0) + rowTabStops(1) + rowTabStops(2) + rowTabStops(3) == 1
343
+ // aria-rowindex / aria-colindex of the focused cell are 1-based and within the counts
344
+ val AriaIndicesInRange: bool = and(
345
+ ariaRowindex(row) >= 1, ariaRowindex(row) <= ARIA_ROWCOUNT,
346
+ ariaColindex(col) >= 1, ariaColindex(col) <= ARIA_COLCOUNT
347
+ )
348
+ val ModeValid: bool = or(mode == "navigate", mode == "edit")
349
+ val KindValid: bool = or(gridKind == "data", gridKind == "layout")
350
+ val FocusTargetValid: bool = or(focusTarget == "cell", focusTarget == "widget")
351
+ val LastEditValid: bool = or(lastEdit == "none", lastEdit == "committed", lastEdit == "cancelled")
352
+ // an input can only be focused while DOM focus is inside the grid
353
+ val EditImpliesFocus: bool = or(not(editing), hasFocus)
354
+ // only an editable cell of an editable grid is ever in edit mode
355
+ val EditOnEditableCell: bool = or(not(editing), canEdit)
356
+ // a draft exists only inside edit mode
357
+ val DirtyImpliesEdit: bool = or(not(dirty), editing)
358
+ // wrapping is a layout-grid-only behaviour
359
+ val DataGridNeverWraps: bool = or(gridKind == "layout", and(not(wrapCols), not(wrapRows)))
360
+ }
@@ -0,0 +1,119 @@
1
+ // APG Link — https://www.w3.org/WAI/ARIA/apg/patterns/link/
2
+ //
3
+ // A link is an interactive reference to a resource. A native <a href> brings
4
+ // focusability and activation for free; a non-native element with role=link
5
+ // must supply both itself (tabindex=0 and an Enter handler), because "applying
6
+ // the link role will not cause browsers to enhance the element".
7
+ //
8
+ // Keyboard (APG): Enter executes the link and moves focus to the link target.
9
+ // Space does NOT activate a link.
10
+ //
11
+ // Events:
12
+ // focusLink / blur the link gains / loses focus (a link that is not
13
+ // focusable ignores focusLink)
14
+ // press Enter on the focused link
15
+ // pressSpace Space on the focused link (no effect)
16
+ // click pointer activation (focuses, then executes)
17
+ // back the user navigates back to the origin
18
+ // setNative / setCustom the element is a native <a href> / a role=link span
19
+ //
20
+ // location: 0 = origin, 1 = link target. focus: "none" | "link" | "target".
21
+ //
22
+ // ponytail: the optional Shift+F10 context menu is not modelled; add a
23
+ // `menuOpen` var when the widget implements it.
24
+ module apg_link {
25
+ var native: bool // native <a href> (no role needed)
26
+ var tabindex: bool // tabindex=0 present (custom role=link only)
27
+ var focus: str // "none" | "link" | "target"
28
+ var location: int // 0 = origin, 1 = target
29
+ var activations: int // times the link executed
30
+
31
+ def focusable: bool = or(native, tabindex)
32
+
33
+ action init = all {
34
+ native' = true,
35
+ tabindex' = false,
36
+ focus' = "none",
37
+ location' = 0,
38
+ activations' = 0
39
+ }
40
+
41
+ action focusLink = all {
42
+ native' = native,
43
+ tabindex' = tabindex,
44
+ focus' = if (focusable) "link" else focus,
45
+ location' = location,
46
+ activations' = activations
47
+ }
48
+
49
+ action blur = all {
50
+ native' = native,
51
+ tabindex' = tabindex,
52
+ focus' = "none",
53
+ location' = location,
54
+ activations' = activations
55
+ }
56
+
57
+ action press = all {
58
+ native' = native,
59
+ tabindex' = tabindex,
60
+ focus' = if (focus == "link") "target" else focus,
61
+ location' = if (focus == "link") 1 else location,
62
+ activations' = if (focus == "link") activations + 1 else activations
63
+ }
64
+
65
+ action pressSpace = all {
66
+ native' = native,
67
+ tabindex' = tabindex,
68
+ focus' = focus,
69
+ location' = location,
70
+ activations' = activations
71
+ }
72
+
73
+ action click = all {
74
+ native' = native,
75
+ tabindex' = tabindex,
76
+ focus' = "target",
77
+ location' = 1,
78
+ activations' = activations + 1
79
+ }
80
+
81
+ action back = all {
82
+ native' = native,
83
+ tabindex' = tabindex,
84
+ focus' = "none",
85
+ location' = 0,
86
+ activations' = activations
87
+ }
88
+
89
+ action setNative = all {
90
+ native' = true,
91
+ tabindex' = false,
92
+ focus' = focus,
93
+ location' = location,
94
+ activations' = activations
95
+ }
96
+
97
+ action setCustom = all {
98
+ native' = false,
99
+ tabindex' = true,
100
+ focus' = focus,
101
+ location' = location,
102
+ activations' = activations
103
+ }
104
+
105
+ action step = any { init, focusLink, blur, press, pressSpace, click, back, setNative, setCustom }
106
+
107
+ // APG: a non-native link role must be keyboard reachable (tabindex=0)
108
+ val KeyboardReachable: bool = focusable
109
+ // only a focusable element can hold focus on the link
110
+ val FocusOnLinkImpliesFocusable: bool = or(focus != "link", focusable)
111
+ // focus lands on the target only after arriving there
112
+ val TargetFocusImpliesArrived: bool = or(focus != "target", location == 1)
113
+ // being at the target means the link ran at least once
114
+ val ArrivedImpliesActivated: bool = or(location == 0, activations >= 1)
115
+ // only the origin and the target exist
116
+ val LocationKnown: bool = or(location == 0, location == 1)
117
+ // the activation count only grows from zero
118
+ val ActivationsNonNegative: bool = activations >= 0
119
+ }