@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.
- package/LICENSE +21 -0
- package/README.md +65 -2
- package/dist/apg/accordion.bridge.d.mts +35 -0
- package/dist/apg/accordion.bridge.mjs +326 -0
- package/dist/apg/accordion.model.json +8397 -0
- package/dist/apg/alert.bridge.d.mts +22 -0
- package/dist/apg/alert.bridge.mjs +86 -0
- package/dist/apg/alert.model.json +871 -0
- package/dist/apg/alertdialog.bridge.d.mts +21 -0
- package/dist/apg/alertdialog.bridge.mjs +221 -0
- package/dist/apg/alertdialog.model.json +5232 -0
- package/dist/apg/breadcrumb.bridge.d.mts +26 -0
- package/dist/apg/breadcrumb.bridge.mjs +120 -0
- package/dist/apg/breadcrumb.model.json +574 -0
- package/dist/apg/button.bridge.d.mts +35 -0
- package/dist/apg/button.bridge.mjs +208 -0
- package/dist/apg/button.model.json +5814 -0
- package/dist/apg/carousel.bridge.d.mts +44 -0
- package/dist/apg/carousel.bridge.mjs +262 -0
- package/dist/apg/carousel.model.json +8963 -0
- package/dist/apg/checkbox-group.bridge.d.mts +25 -0
- package/dist/apg/checkbox-group.bridge.mjs +276 -0
- package/dist/apg/checkbox-group.model.json +3863 -0
- package/dist/apg/checkbox.bridge.d.mts +34 -0
- package/dist/apg/checkbox.bridge.mjs +253 -0
- package/dist/apg/checkbox.model.json +3158 -0
- package/dist/apg/combobox-autocomplete.bridge.d.mts +14 -0
- package/dist/apg/combobox-autocomplete.bridge.mjs +326 -0
- package/dist/apg/combobox-autocomplete.model.json +11586 -0
- package/dist/apg/combobox-dialog.bridge.d.mts +17 -0
- package/dist/apg/combobox-dialog.bridge.mjs +250 -0
- package/dist/apg/combobox-dialog.model.json +8981 -0
- package/dist/apg/combobox-grid.bridge.d.mts +17 -0
- package/dist/apg/combobox-grid.bridge.mjs +227 -0
- package/dist/apg/combobox-grid.model.json +8554 -0
- package/dist/apg/combobox-select-only.bridge.d.mts +24 -0
- package/dist/apg/combobox-select-only.bridge.mjs +304 -0
- package/dist/apg/combobox-select-only.model.json +7846 -0
- package/dist/apg/combobox-tree.bridge.d.mts +17 -0
- package/dist/apg/combobox-tree.bridge.mjs +211 -0
- package/dist/apg/combobox-tree.model.json +9345 -0
- package/dist/apg/dialog-modal.bridge.d.mts +35 -0
- package/dist/apg/dialog-modal.bridge.mjs +186 -0
- package/dist/apg/dialog-modal.model.json +7580 -0
- package/dist/apg/disclosure.bridge.d.mts +25 -0
- package/dist/apg/disclosure.bridge.mjs +202 -0
- package/dist/apg/disclosure.model.json +4703 -0
- package/dist/apg/feed.bridge.d.mts +31 -0
- package/dist/apg/feed.bridge.mjs +246 -0
- package/dist/apg/feed.model.json +4523 -0
- package/dist/apg/grid-selection.bridge.d.mts +13 -0
- package/dist/apg/grid-selection.bridge.mjs +160 -0
- package/dist/apg/grid-selection.model.json +6974 -0
- package/dist/apg/grid-sort.bridge.d.mts +12 -0
- package/dist/apg/grid-sort.bridge.mjs +138 -0
- package/dist/apg/grid-sort.model.json +3799 -0
- package/dist/apg/grid.bridge.d.mts +54 -0
- package/dist/apg/grid.bridge.mjs +441 -0
- package/dist/apg/grid.model.json +3802 -0
- package/dist/apg/link.bridge.d.mts +24 -0
- package/dist/apg/link.bridge.mjs +126 -0
- package/dist/apg/link.model.json +694 -0
- package/dist/apg/listbox-multi.bridge.d.mts +10 -0
- package/dist/apg/listbox-multi.bridge.mjs +261 -0
- package/dist/apg/listbox-multi.model.json +10893 -0
- package/dist/apg/listbox-rearrangeable.bridge.d.mts +20 -0
- package/dist/apg/listbox-rearrangeable.bridge.mjs +237 -0
- package/dist/apg/listbox-rearrangeable.model.json +17592 -0
- package/dist/apg/listbox-shared.d.mts +58 -0
- package/dist/apg/listbox-shared.mjs +171 -0
- package/dist/apg/listbox-single.bridge.d.mts +10 -0
- package/dist/apg/listbox-single.bridge.mjs +196 -0
- package/dist/apg/listbox-single.model.json +8024 -0
- package/dist/apg/listbox-typeahead.bridge.d.mts +10 -0
- package/dist/apg/listbox-typeahead.bridge.mjs +153 -0
- package/dist/apg/listbox-typeahead.model.json +5615 -0
- package/dist/apg/menu-button.bridge.d.mts +29 -0
- package/dist/apg/menu-button.bridge.mjs +259 -0
- package/dist/apg/menu-button.model.json +5706 -0
- package/dist/apg/menu-checkable.bridge.d.mts +25 -0
- package/dist/apg/menu-checkable.bridge.mjs +226 -0
- package/dist/apg/menu-checkable.model.json +4277 -0
- package/dist/apg/menu-menubar.bridge.d.mts +27 -0
- package/dist/apg/menu-menubar.bridge.mjs +273 -0
- package/dist/apg/menu-menubar.model.json +9540 -0
- package/dist/apg/meter.bridge.d.mts +28 -0
- package/dist/apg/meter.bridge.mjs +159 -0
- package/dist/apg/meter.model.json +1185 -0
- package/dist/apg/player.bridge.d.mts +24 -0
- package/dist/apg/player.bridge.mjs +342 -0
- package/dist/apg/player.model.json +11013 -0
- package/dist/apg/radio.bridge.d.mts +35 -0
- package/dist/apg/radio.bridge.mjs +371 -0
- package/dist/apg/radio.model.json +5011 -0
- package/dist/apg/slider-multithumb.bridge.d.mts +34 -0
- package/dist/apg/slider-multithumb.bridge.mjs +365 -0
- package/dist/apg/slider-multithumb.model.json +5774 -0
- package/dist/apg/slider.bridge.d.mts +54 -0
- package/dist/apg/slider.bridge.mjs +359 -0
- package/dist/apg/slider.model.json +4881 -0
- package/dist/apg/spinbutton.bridge.d.mts +41 -0
- package/dist/apg/spinbutton.bridge.mjs +327 -0
- package/dist/apg/spinbutton.model.json +7571 -0
- package/dist/apg/switch.bridge.d.mts +30 -0
- package/dist/apg/switch.bridge.mjs +243 -0
- package/dist/apg/switch.model.json +3159 -0
- package/dist/apg/tabs.bridge.d.mts +41 -0
- package/dist/apg/tabs.bridge.mjs +294 -0
- package/dist/apg/tabs.model.json +7659 -0
- package/dist/apg/timeline.bridge.d.mts +36 -0
- package/dist/apg/timeline.bridge.mjs +265 -0
- package/dist/apg/timeline.model.json +6189 -0
- package/dist/apg/toolbar.bridge.d.mts +25 -0
- package/dist/apg/toolbar.bridge.mjs +307 -0
- package/dist/apg/toolbar.model.json +6616 -0
- package/dist/apg/tooltip.bridge.d.mts +36 -0
- package/dist/apg/tooltip.bridge.mjs +208 -0
- package/dist/apg/tooltip.model.json +8295 -0
- package/dist/apg/treegrid.bridge.d.mts +28 -0
- package/dist/apg/treegrid.bridge.mjs +311 -0
- package/dist/apg/treegrid.model.json +17358 -0
- package/dist/apg/treeview.bridge.d.mts +26 -0
- package/dist/apg/treeview.bridge.mjs +396 -0
- package/dist/apg/treeview.model.json +13745 -0
- package/dist/apg/windowsplitter.bridge.d.mts +36 -0
- package/dist/apg/windowsplitter.bridge.mjs +242 -0
- package/dist/apg/windowsplitter.model.json +6253 -0
- package/dist/canvas/camera.model.json +6568 -0
- package/dist/canvas/canvas.bridge.d.mts +190 -0
- package/dist/canvas/canvas.bridge.mjs +729 -0
- package/dist/canvas/pointer.model.json +17095 -0
- package/dist/scenario.d.mts +233 -0
- package/dist/scenario.mjs +313 -0
- package/package.json +57 -4
- package/scenarios/apg/README.md +221 -0
- package/scenarios/apg/accordion.base-ui.json +1005 -0
- package/scenarios/apg/accordion.react-aria.json +929 -0
- package/scenarios/apg/alert.base-ui.json +81 -0
- package/scenarios/apg/alert.react-aria.json +98 -0
- package/scenarios/apg/alertdialog.base-ui.json +331 -0
- package/scenarios/apg/alertdialog.react-aria.json +225 -0
- package/scenarios/apg/breadcrumb.react-aria.json +312 -0
- package/scenarios/apg/button.ariakit.json +136 -0
- package/scenarios/apg/button.base-ui.json +747 -0
- package/scenarios/apg/button.react-aria.json +443 -0
- package/scenarios/apg/carousel.apg.json +909 -0
- package/scenarios/apg/checkbox.ariakit.json +299 -0
- package/scenarios/apg/checkbox.base-ui.json +1126 -0
- package/scenarios/apg/checkbox.react-aria.json +518 -0
- package/scenarios/apg/combobox.ariakit.json +945 -0
- package/scenarios/apg/combobox.base-ui.json +2444 -0
- package/scenarios/apg/combobox.react-aria.json +845 -0
- package/scenarios/apg/dialog-modal.ariakit.json +1102 -0
- package/scenarios/apg/dialog-modal.base-ui.json +775 -0
- package/scenarios/apg/dialog-modal.react-aria.json +638 -0
- package/scenarios/apg/disclosure.apg.json +349 -0
- package/scenarios/apg/disclosure.ariakit.json +163 -0
- package/scenarios/apg/disclosure.base-ui.json +451 -0
- package/scenarios/apg/disclosure.react-aria.json +643 -0
- package/scenarios/apg/feed.apg.json +442 -0
- package/scenarios/apg/grid.ariakit.json +197 -0
- package/scenarios/apg/grid.base-ui.json +720 -0
- package/scenarios/apg/grid.react-aria.json +969 -0
- package/scenarios/apg/landmarks.ariakit.json +93 -0
- package/scenarios/apg/landmarks.react-aria.json +330 -0
- package/scenarios/apg/link.ariakit.json +83 -0
- package/scenarios/apg/link.base-ui.json +88 -0
- package/scenarios/apg/link.react-aria.json +186 -0
- package/scenarios/apg/listbox.ariakit.json +78 -0
- package/scenarios/apg/listbox.base-ui.json +163 -0
- package/scenarios/apg/listbox.react-aria.json +1118 -0
- package/scenarios/apg/menu-button.ariakit.json +924 -0
- package/scenarios/apg/menu-button.base-ui.json +1155 -0
- package/scenarios/apg/menu-button.react-aria.json +1080 -0
- package/scenarios/apg/menubar.ariakit.json +758 -0
- package/scenarios/apg/menubar.base-ui.json +673 -0
- package/scenarios/apg/meter.base-ui.json +243 -0
- package/scenarios/apg/meter.react-aria.json +201 -0
- package/scenarios/apg/radio.ariakit.json +459 -0
- package/scenarios/apg/radio.base-ui.json +581 -0
- package/scenarios/apg/radio.react-aria.json +576 -0
- package/scenarios/apg/slider-multithumb.base-ui.json +316 -0
- package/scenarios/apg/slider-multithumb.react-aria.json +363 -0
- package/scenarios/apg/slider.base-ui.json +544 -0
- package/scenarios/apg/slider.react-aria.json +456 -0
- package/scenarios/apg/spinbutton.base-ui.json +1688 -0
- package/scenarios/apg/spinbutton.react-aria.json +426 -0
- package/scenarios/apg/switch.base-ui.json +456 -0
- package/scenarios/apg/switch.react-aria.json +303 -0
- package/scenarios/apg/table.ariakit.json +65 -0
- package/scenarios/apg/table.react-aria.json +221 -0
- package/scenarios/apg/tabs.ariakit.json +329 -0
- package/scenarios/apg/tabs.base-ui.json +875 -0
- package/scenarios/apg/tabs.react-aria.json +644 -0
- package/scenarios/apg/toolbar.ariakit.json +595 -0
- package/scenarios/apg/toolbar.base-ui.json +1189 -0
- package/scenarios/apg/toolbar.react-aria.json +383 -0
- package/scenarios/apg/tooltip.ariakit.json +441 -0
- package/scenarios/apg/tooltip.base-ui.json +411 -0
- package/scenarios/apg/tooltip.react-aria.json +454 -0
- package/scenarios/apg/treegrid.apg.json +2558 -0
- package/scenarios/apg/treegrid.react-aria.json +5094 -0
- package/scenarios/apg/treeview.apg.json +928 -0
- package/scenarios/apg/windowsplitter.apg.json +557 -0
- package/scenarios/canvas/README.md +69 -0
- package/scenarios/canvas/camera.contract.json +237 -0
- package/scenarios/canvas/camera.excalidraw.json +57 -0
- package/scenarios/canvas/marquee.excalidraw.json +122 -0
- package/scenarios/canvas/pan.excalidraw.json +46 -0
- package/scenarios/canvas/pointer.contract.json +528 -0
- package/scenarios/parts.json +291 -0
- package/shared/aria.qnt +186 -0
- package/shared/details-group.qnt +50 -0
- package/shared/focusgroup.qnt +152 -0
- package/shared/invokers.qnt +81 -0
- package/shared/popover.qnt +71 -0
- package/specs/apg/STATIC.md +103 -0
- package/specs/apg/accordion.qnt +578 -0
- package/specs/apg/alert.qnt +131 -0
- package/specs/apg/alertdialog.qnt +455 -0
- package/specs/apg/breadcrumb.qnt +88 -0
- package/specs/apg/button.qnt +445 -0
- package/specs/apg/carousel.qnt +732 -0
- package/specs/apg/checkbox-group.qnt +388 -0
- package/specs/apg/checkbox.qnt +394 -0
- package/specs/apg/combobox-autocomplete.qnt +764 -0
- package/specs/apg/combobox-dialog.qnt +627 -0
- package/specs/apg/combobox-grid.qnt +632 -0
- package/specs/apg/combobox-select-only.qnt +626 -0
- package/specs/apg/combobox-tree.qnt +633 -0
- package/specs/apg/dialog-modal.qnt +499 -0
- package/specs/apg/disclosure.qnt +309 -0
- package/specs/apg/feed.qnt +485 -0
- package/specs/apg/grid-selection.qnt +512 -0
- package/specs/apg/grid-sort.qnt +330 -0
- package/specs/apg/grid.qnt +360 -0
- package/specs/apg/link.qnt +119 -0
- package/specs/apg/listbox-multi.qnt +799 -0
- package/specs/apg/listbox-rearrangeable.qnt +801 -0
- package/specs/apg/listbox-single.qnt +511 -0
- package/specs/apg/listbox-typeahead.qnt +396 -0
- package/specs/apg/menu-button.qnt +363 -0
- package/specs/apg/menu-checkable.qnt +304 -0
- package/specs/apg/menu-menubar.qnt +531 -0
- package/specs/apg/meter.qnt +129 -0
- package/specs/apg/player.qnt +1081 -0
- package/specs/apg/radio.qnt +394 -0
- package/specs/apg/slider-multithumb.qnt +441 -0
- package/specs/apg/slider.qnt +396 -0
- package/specs/apg/spinbutton.qnt +494 -0
- package/specs/apg/switch.qnt +389 -0
- package/specs/apg/tabs.qnt +412 -0
- package/specs/apg/timeline.qnt +612 -0
- package/specs/apg/toolbar.qnt +364 -0
- package/specs/apg/tooltip.qnt +553 -0
- package/specs/apg/treegrid.qnt +983 -0
- package/specs/apg/treeview.qnt +1004 -0
- package/specs/apg/windowsplitter.qnt +487 -0
- package/specs/canvas/camera.qnt +407 -0
- package/specs/canvas/pointer.qnt +841 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// WAI-ARIA APG — Radio Group pattern (radiogroup + 4 radio items).
|
|
2
|
+
//
|
|
3
|
+
// Modelled (https://www.w3.org/WAI/ARIA/apg/patterns/radio/):
|
|
4
|
+
// - "Keyboard Interaction / Radio Groups Not Contained in a Toolbar":
|
|
5
|
+
// Tab / Shift+Tab focus enters the group on the checked radio, or on the
|
|
6
|
+
// first radio when none is checked (entering never checks)
|
|
7
|
+
// Space checks the focused radio if not already checked
|
|
8
|
+
// Right / Down focus AND check the next radio, last wraps to first
|
|
9
|
+
// Left / Up focus AND check the previous radio, first wraps to last
|
|
10
|
+
// - "WAI-ARIA Roles, States, and Properties": role radiogroup / radio,
|
|
11
|
+
// aria-checked true|false on every radio.
|
|
12
|
+
// Examples (variants of the SAME machine, chosen once at init via `flavor`):
|
|
13
|
+
// - roving https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio/
|
|
14
|
+
// DOM focus sits on the radio; tabindex 0 on the tab stop radio,
|
|
15
|
+
// -1 on the others; the radiogroup has no tabindex.
|
|
16
|
+
// - activedescendant
|
|
17
|
+
// https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-activedescendant/
|
|
18
|
+
// DOM focus sits on the radiogroup (tabindex 0); radios carry no
|
|
19
|
+
// tabindex; aria-activedescendant on the group names the
|
|
20
|
+
// visually-focused radio while focus is inside the group.
|
|
21
|
+
// Disabled radios (task requirement, not in the APG examples): they are never
|
|
22
|
+
// focused, checked or the tab stop; arrows skip them (wrapping across them).
|
|
23
|
+
//
|
|
24
|
+
// Request vs commit (map ticket rt1p). The checked radio is the value. Every
|
|
25
|
+
// event a user sees as a change — `arrow_next`, `arrow_prev`, `space`, `click`
|
|
26
|
+
// — is a *request* carrying an intent (`requested`); it fires onValueChange
|
|
27
|
+
// (`requests` counts the calls) when the intent differs from the current
|
|
28
|
+
// `checked`, and then:
|
|
29
|
+
// uncontrolled the request is accepted: the check commits the intent
|
|
30
|
+
// controlled, holds the host ignores it ("rejected"): the check is untouched
|
|
31
|
+
// controlled, accepts the host answers with the same value (`accepting`): the
|
|
32
|
+
// check commits through the controlled value, not the event
|
|
33
|
+
// `valueProp` is the only thing that changes the check while `controlled`;
|
|
34
|
+
// `setValue` is the host writing it. `control` / `acceptRequests` are
|
|
35
|
+
// configuration events (init is literal-only). DOM focus still moves with the
|
|
36
|
+
// request, so `FocusFollowsChecked` is an uncontrolled guarantee. Invariants
|
|
37
|
+
// for the model: ControlledFollowsProp, RejectedRequestUntouched,
|
|
38
|
+
// AcceptedRequestCommits.
|
|
39
|
+
//
|
|
40
|
+
// Pools / constants: 4 radios (indexes 0..3); `mask` is a bitmask of disabled
|
|
41
|
+
// radios (bit i = radio i), drawn from a pool that always leaves one enabled;
|
|
42
|
+
// `pick` is the preselected radio (-1 = none). `init` is the literal default
|
|
43
|
+
// (roving, none disabled, none checked); set_flavor / set_disabled / preselect
|
|
44
|
+
// re-mount the widget with other props (focus leaves the group) and follow the
|
|
45
|
+
// controlled value.
|
|
46
|
+
//
|
|
47
|
+
// Sentinels: focus/checked -1 = none. domFocus: 0..3 = a radio, 4 = the
|
|
48
|
+
// radiogroup element, -1 = outside the group. tabindex: -2 = attribute absent.
|
|
49
|
+
//
|
|
50
|
+
// Deliberate omissions: the toolbar flavor, the radio-rating example, Enter,
|
|
51
|
+
// aria-labelledby / aria-describedby / aria-label wiring, mouse hover, disabling
|
|
52
|
+
// without a re-mount, Shift+Tab "last radio" native-browser difference,
|
|
53
|
+
// dynamic add/remove of radios. Click is modelled as focus+check of an enabled radio.
|
|
54
|
+
//
|
|
55
|
+
// ponytail: radio count is fixed at 4; the generator has no sets/lists, so the
|
|
56
|
+
// group is unrolled into index arithmetic. Widen the modulus and weight table
|
|
57
|
+
// to grow it.
|
|
58
|
+
module apg_radio {
|
|
59
|
+
var flavor: str // "roving" | "activedescendant"
|
|
60
|
+
var mask: int // disabled bitmask (bit i set = radio i disabled)
|
|
61
|
+
var checked: int // checked radio, -1 = none
|
|
62
|
+
var focus: int // radio holding (visual) focus, -1 = focus outside the group
|
|
63
|
+
var controlled: bool // the host owns the check (`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 requests: int // number of requests that fired onValueChange
|
|
68
|
+
var outcome: str // "none" | "accepted" | "rejected"
|
|
69
|
+
var base: int // check when the last request was received
|
|
70
|
+
|
|
71
|
+
def weight(i: int): int =
|
|
72
|
+
if (i == 0) 1 else if (i == 1) 2 else if (i == 2) 4 else 8
|
|
73
|
+
def disabled(m: int, i: int): bool = imod(idiv(m, weight(i)), 2) == 1
|
|
74
|
+
def isEnabled(m: int, i: int): bool = not(disabled(m, i))
|
|
75
|
+
def b2i(b: bool): int = if (b) 1 else 0
|
|
76
|
+
|
|
77
|
+
// first enabled radio (the pool guarantees at least one)
|
|
78
|
+
def firstEnabled(m: int): int =
|
|
79
|
+
if (isEnabled(m, 0)) 0 else if (isEnabled(m, 1)) 1 else if (isEnabled(m, 2)) 2 else 3
|
|
80
|
+
|
|
81
|
+
// arrow targets: skip disabled radios, wrap around; stay put if alone
|
|
82
|
+
def nextEnabled(m: int, i: int): int =
|
|
83
|
+
if (isEnabled(m, imod(i + 1, 4))) imod(i + 1, 4)
|
|
84
|
+
else if (isEnabled(m, imod(i + 2, 4))) imod(i + 2, 4)
|
|
85
|
+
else if (isEnabled(m, imod(i + 3, 4))) imod(i + 3, 4)
|
|
86
|
+
else i
|
|
87
|
+
def prevEnabled(m: int, i: int): int =
|
|
88
|
+
if (isEnabled(m, imod(i + 3, 4))) imod(i + 3, 4)
|
|
89
|
+
else if (isEnabled(m, imod(i + 2, 4))) imod(i + 2, 4)
|
|
90
|
+
else if (isEnabled(m, imod(i + 1, 4))) imod(i + 1, 4)
|
|
91
|
+
else i
|
|
92
|
+
|
|
93
|
+
// ---- request / commit (map ticket rt1p) ----------------------------------
|
|
94
|
+
def moved(before: int, after: int): int = if (before == after) 0 else 1
|
|
95
|
+
// the intent of each request (an enabled target, or the current check)
|
|
96
|
+
def nextWant(m: int, c: int, f: int): int = if (f >= 0) nextEnabled(m, f) else c
|
|
97
|
+
def prevWant(m: int, c: int, f: int): int = if (f >= 0) prevEnabled(m, f) else c
|
|
98
|
+
def spaceWant(c: int, f: int): int = if (f >= 0) f else c
|
|
99
|
+
def clickWant(t: int, m: int, c: int): int = if (isEnabled(m, t)) t else c
|
|
100
|
+
// a value the host may write: -1 (none) or an enabled radio
|
|
101
|
+
def validPick(v: int, m: int): bool = or(v < 0, isEnabled(m, v))
|
|
102
|
+
// controlled host that keeps the old value: the request is rejected
|
|
103
|
+
def holds: bool = and(controlled, not(accepting))
|
|
104
|
+
// controlled host that writes the intent back through the value prop
|
|
105
|
+
def follows: bool = and(controlled, accepting)
|
|
106
|
+
def verdict: str = if (holds) "rejected" else "accepted"
|
|
107
|
+
// the check after a request for `want`
|
|
108
|
+
def landed(h: bool, current: int, want: int): int = if (h) current else want
|
|
109
|
+
// the value prop after a request for `want`
|
|
110
|
+
def propAfter(f: bool, p: int, want: int): int = if (f) want else p
|
|
111
|
+
|
|
112
|
+
// ---- ARIA contract, derived from state ----
|
|
113
|
+
|
|
114
|
+
// the radio Tab lands on: the checked one, else the first enabled one
|
|
115
|
+
def tabStop(m: int, c: int): int = if (c >= 0) c else firstEnabled(m)
|
|
116
|
+
|
|
117
|
+
def ariaChecked(c: int, i: int): bool = c == i
|
|
118
|
+
def ariaDisabled(m: int, i: int): bool = disabled(m, i)
|
|
119
|
+
|
|
120
|
+
// roving: tabindex 0 on the tab stop, -1 elsewhere; activedescendant: absent
|
|
121
|
+
def radioTabindex(v: str, m: int, c: int, i: int): int =
|
|
122
|
+
if (v == "roving") (if (i == tabStop(m, c)) 0 else -1) else -2
|
|
123
|
+
// activedescendant: the radiogroup itself is the tab stop; roving: absent
|
|
124
|
+
def groupTabindex(v: str): int = if (v == "activedescendant") 0 else -2
|
|
125
|
+
// aria-activedescendant on the radiogroup: the focused radio, -1 = absent
|
|
126
|
+
def activeDescendant(v: str, f: int): int =
|
|
127
|
+
if (v == "activedescendant") f else -1
|
|
128
|
+
// which element owns DOM focus
|
|
129
|
+
def domFocus(v: str, f: int): int =
|
|
130
|
+
if (f < 0) -1 else if (v == "roving") f else 4
|
|
131
|
+
|
|
132
|
+
def checkedCount(c: int): int =
|
|
133
|
+
b2i(ariaChecked(c, 0)) + b2i(ariaChecked(c, 1)) + b2i(ariaChecked(c, 2)) + b2i(ariaChecked(c, 3))
|
|
134
|
+
// tab stops contributed by one radio / the group, summed over the widget
|
|
135
|
+
def tabStopCount(v: str, m: int, c: int): int =
|
|
136
|
+
b2i(radioTabindex(v, m, c, 0) == 0) + b2i(radioTabindex(v, m, c, 1) == 0)
|
|
137
|
+
+ b2i(radioTabindex(v, m, c, 2) == 0) + b2i(radioTabindex(v, m, c, 3) == 0)
|
|
138
|
+
+ b2i(groupTabindex(v) == 0)
|
|
139
|
+
|
|
140
|
+
action init = all {
|
|
141
|
+
flavor' = "roving",
|
|
142
|
+
mask' = 0,
|
|
143
|
+
checked' = -1,
|
|
144
|
+
focus' = -1,
|
|
145
|
+
controlled' = false,
|
|
146
|
+
accepting' = false,
|
|
147
|
+
valueProp' = -1,
|
|
148
|
+
requested' = -1,
|
|
149
|
+
requests' = 0,
|
|
150
|
+
outcome' = "none",
|
|
151
|
+
base' = -1
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The three configuration actions model (re)mounting the widget with other
|
|
155
|
+
// props: they drop focus out of the group, so every later state is reached
|
|
156
|
+
// through the keyboard/pointer actions below. While controlled they keep the
|
|
157
|
+
// value prop in step with the check.
|
|
158
|
+
action set_flavor = {
|
|
159
|
+
nondet v = Set("roving", "activedescendant").oneOf()
|
|
160
|
+
all {
|
|
161
|
+
flavor' = v,
|
|
162
|
+
mask' = mask,
|
|
163
|
+
checked' = checked,
|
|
164
|
+
focus' = -1,
|
|
165
|
+
controlled' = controlled,
|
|
166
|
+
accepting' = accepting,
|
|
167
|
+
valueProp' = valueProp,
|
|
168
|
+
requested' = requested,
|
|
169
|
+
requests' = requests,
|
|
170
|
+
outcome' = outcome,
|
|
171
|
+
base' = base
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// disabled radios; the pool always leaves one enabled, and a checked radio
|
|
176
|
+
// that becomes disabled is unchecked
|
|
177
|
+
action set_disabled = {
|
|
178
|
+
nondet m = Set(0, 1, 2, 6, 9, 14).oneOf()
|
|
179
|
+
all {
|
|
180
|
+
flavor' = flavor,
|
|
181
|
+
mask' = m,
|
|
182
|
+
checked' = if (and(checked >= 0, isEnabled(m, checked))) checked else -1,
|
|
183
|
+
focus' = -1,
|
|
184
|
+
controlled' = controlled,
|
|
185
|
+
accepting' = accepting,
|
|
186
|
+
valueProp' = if (controlled) (if (and(checked >= 0, isEnabled(m, checked))) checked else -1) else valueProp,
|
|
187
|
+
requested' = requested,
|
|
188
|
+
requests' = requests,
|
|
189
|
+
outcome' = "none",
|
|
190
|
+
base' = base
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// initially checked radio (-1 = none); a disabled pick leaves none checked
|
|
195
|
+
action preselect = {
|
|
196
|
+
nondet pick = Set(-1, 0, 1, 2, 3).oneOf()
|
|
197
|
+
all {
|
|
198
|
+
flavor' = flavor,
|
|
199
|
+
mask' = mask,
|
|
200
|
+
checked' = if (and(pick >= 0, isEnabled(mask, pick))) pick else -1,
|
|
201
|
+
focus' = -1,
|
|
202
|
+
controlled' = controlled,
|
|
203
|
+
accepting' = accepting,
|
|
204
|
+
valueProp' = if (controlled) (if (and(pick >= 0, isEnabled(mask, pick))) pick else -1) else valueProp,
|
|
205
|
+
requested' = requested,
|
|
206
|
+
requests' = requests,
|
|
207
|
+
outcome' = "none",
|
|
208
|
+
base' = base
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Tab / Shift+Tab into the group: focus the tab stop, never check it.
|
|
213
|
+
// Already inside: Tab leaves the group, which is `leave`.
|
|
214
|
+
action enter = all {
|
|
215
|
+
flavor' = flavor,
|
|
216
|
+
mask' = mask,
|
|
217
|
+
checked' = checked,
|
|
218
|
+
focus' = if (focus < 0) tabStop(mask, checked) else focus,
|
|
219
|
+
controlled' = controlled,
|
|
220
|
+
accepting' = accepting,
|
|
221
|
+
valueProp' = valueProp,
|
|
222
|
+
requested' = requested,
|
|
223
|
+
requests' = requests,
|
|
224
|
+
outcome' = outcome,
|
|
225
|
+
base' = base
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
action leave = all {
|
|
229
|
+
flavor' = flavor,
|
|
230
|
+
mask' = mask,
|
|
231
|
+
checked' = checked,
|
|
232
|
+
focus' = -1,
|
|
233
|
+
controlled' = controlled,
|
|
234
|
+
accepting' = accepting,
|
|
235
|
+
valueProp' = valueProp,
|
|
236
|
+
requested' = requested,
|
|
237
|
+
requests' = requests,
|
|
238
|
+
outcome' = outcome,
|
|
239
|
+
base' = base
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// request: Right / Down arrow moves focus and checks, wrapping, skipping disabled
|
|
243
|
+
action arrow_next = all {
|
|
244
|
+
flavor' = flavor,
|
|
245
|
+
mask' = mask,
|
|
246
|
+
checked' = landed(holds, checked, nextWant(mask, checked, focus)),
|
|
247
|
+
focus' = if (focus >= 0) nextEnabled(mask, focus) else focus,
|
|
248
|
+
controlled' = controlled,
|
|
249
|
+
accepting' = accepting,
|
|
250
|
+
valueProp' = propAfter(follows, valueProp, nextWant(mask, checked, focus)),
|
|
251
|
+
requested' = if (moved(checked, nextWant(mask, checked, focus)) == 1) nextWant(mask, checked, focus) else requested,
|
|
252
|
+
requests' = requests + moved(checked, nextWant(mask, checked, focus)),
|
|
253
|
+
outcome' = if (moved(checked, nextWant(mask, checked, focus)) == 1) verdict else outcome,
|
|
254
|
+
base' = if (moved(checked, nextWant(mask, checked, focus)) == 1) checked else base
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// request: Left / Up arrow
|
|
258
|
+
action arrow_prev = all {
|
|
259
|
+
flavor' = flavor,
|
|
260
|
+
mask' = mask,
|
|
261
|
+
checked' = landed(holds, checked, prevWant(mask, checked, focus)),
|
|
262
|
+
focus' = if (focus >= 0) prevEnabled(mask, focus) else focus,
|
|
263
|
+
controlled' = controlled,
|
|
264
|
+
accepting' = accepting,
|
|
265
|
+
valueProp' = propAfter(follows, valueProp, prevWant(mask, checked, focus)),
|
|
266
|
+
requested' = if (moved(checked, prevWant(mask, checked, focus)) == 1) prevWant(mask, checked, focus) else requested,
|
|
267
|
+
requests' = requests + moved(checked, prevWant(mask, checked, focus)),
|
|
268
|
+
outcome' = if (moved(checked, prevWant(mask, checked, focus)) == 1) verdict else outcome,
|
|
269
|
+
base' = if (moved(checked, prevWant(mask, checked, focus)) == 1) checked else base
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// request: Space checks the focused radio (no-op when already checked)
|
|
273
|
+
action space = all {
|
|
274
|
+
flavor' = flavor,
|
|
275
|
+
mask' = mask,
|
|
276
|
+
checked' = landed(holds, checked, spaceWant(checked, focus)),
|
|
277
|
+
focus' = focus,
|
|
278
|
+
controlled' = controlled,
|
|
279
|
+
accepting' = accepting,
|
|
280
|
+
valueProp' = propAfter(follows, valueProp, spaceWant(checked, focus)),
|
|
281
|
+
requested' = if (moved(checked, spaceWant(checked, focus)) == 1) spaceWant(checked, focus) else requested,
|
|
282
|
+
requests' = requests + moved(checked, spaceWant(checked, focus)),
|
|
283
|
+
outcome' = if (moved(checked, spaceWant(checked, focus)) == 1) verdict else outcome,
|
|
284
|
+
base' = if (moved(checked, spaceWant(checked, focus)) == 1) checked else base
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// request: pointer on an enabled radio takes focus and becomes checked
|
|
288
|
+
action click = {
|
|
289
|
+
nondet target = Set(0, 1, 2, 3).oneOf()
|
|
290
|
+
all {
|
|
291
|
+
flavor' = flavor,
|
|
292
|
+
mask' = mask,
|
|
293
|
+
checked' = landed(holds, checked, clickWant(target, mask, checked)),
|
|
294
|
+
focus' = if (isEnabled(mask, target)) target else focus,
|
|
295
|
+
controlled' = controlled,
|
|
296
|
+
accepting' = accepting,
|
|
297
|
+
valueProp' = propAfter(follows, valueProp, clickWant(target, mask, checked)),
|
|
298
|
+
requested' = if (moved(checked, clickWant(target, mask, checked)) == 1) clickWant(target, mask, checked) else requested,
|
|
299
|
+
requests' = requests + moved(checked, clickWant(target, mask, checked)),
|
|
300
|
+
outcome' = if (moved(checked, clickWant(target, mask, checked)) == 1) verdict else outcome,
|
|
301
|
+
base' = if (moved(checked, clickWant(target, mask, checked)) == 1) checked else base
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// commit: the host writes the controlled check (a no-op while uncontrolled)
|
|
306
|
+
action setValue = {
|
|
307
|
+
nondet v = Set(-1, 0, 1, 2, 3).oneOf()
|
|
308
|
+
all {
|
|
309
|
+
flavor' = flavor,
|
|
310
|
+
mask' = mask,
|
|
311
|
+
checked' = if (and(controlled, validPick(v, mask))) v else checked,
|
|
312
|
+
focus' = focus,
|
|
313
|
+
controlled' = controlled,
|
|
314
|
+
accepting' = accepting,
|
|
315
|
+
valueProp' = if (and(controlled, validPick(v, mask))) v else valueProp,
|
|
316
|
+
requested' = requested,
|
|
317
|
+
requests' = requests,
|
|
318
|
+
outcome' = if (controlled) "none" else outcome,
|
|
319
|
+
base' = base
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// configuration: the host starts owning the check, adopting the current one
|
|
324
|
+
action control = all {
|
|
325
|
+
flavor' = flavor,
|
|
326
|
+
mask' = mask,
|
|
327
|
+
checked' = checked,
|
|
328
|
+
focus' = focus,
|
|
329
|
+
controlled' = true,
|
|
330
|
+
accepting' = accepting,
|
|
331
|
+
valueProp' = checked,
|
|
332
|
+
requested' = requested,
|
|
333
|
+
requests' = requests,
|
|
334
|
+
outcome' = outcome,
|
|
335
|
+
base' = base
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// configuration: the controlled host answers every request with its intent
|
|
339
|
+
action acceptRequests = all {
|
|
340
|
+
flavor' = flavor,
|
|
341
|
+
mask' = mask,
|
|
342
|
+
checked' = checked,
|
|
343
|
+
focus' = focus,
|
|
344
|
+
controlled' = controlled,
|
|
345
|
+
accepting' = true,
|
|
346
|
+
valueProp' = valueProp,
|
|
347
|
+
requested' = requested,
|
|
348
|
+
requests' = requests,
|
|
349
|
+
outcome' = outcome,
|
|
350
|
+
base' = base
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
action step = any { init, set_flavor, set_disabled, preselect, enter, leave, arrow_next, arrow_prev, space, click, setValue, control, acceptRequests }
|
|
354
|
+
|
|
355
|
+
// never more than one radio is aria-checked="true"
|
|
356
|
+
val AtMostOneChecked: bool = checkedCount(checked) <= 1
|
|
357
|
+
// exactly one tab stop for the whole group, in both variants
|
|
358
|
+
val ExactlyOneTabStop: bool = tabStopCount(flavor, mask, checked) == 1
|
|
359
|
+
// the tab stop is the checked radio, else the first (enabled) one
|
|
360
|
+
val TabStopIsCheckedOrFirst: bool =
|
|
361
|
+
if (checked >= 0) tabStop(mask, checked) == checked else tabStop(mask, checked) == firstEnabled(mask)
|
|
362
|
+
// a disabled radio is never checked, focused or the tab stop
|
|
363
|
+
val DisabledNeverActive: bool = and(
|
|
364
|
+
or(checked < 0, isEnabled(mask, checked)),
|
|
365
|
+
or(focus < 0, isEnabled(mask, focus)),
|
|
366
|
+
isEnabled(mask, tabStop(mask, checked))
|
|
367
|
+
)
|
|
368
|
+
// focus and checked move together: once a radio is checked, focus inside
|
|
369
|
+
// the group is always on it; before that it can only be on the first radio.
|
|
370
|
+
// A controlled host that rejects a request leaves focus on the requested
|
|
371
|
+
// radio, so this is an uncontrolled guarantee.
|
|
372
|
+
val FocusFollowsChecked: bool = or(
|
|
373
|
+
controlled,
|
|
374
|
+
focus < 0,
|
|
375
|
+
if (checked >= 0) focus == checked else focus == firstEnabled(mask)
|
|
376
|
+
)
|
|
377
|
+
// at least one radio can always be reached
|
|
378
|
+
val SomeRadioEnabled: bool = or(isEnabled(mask, 0), isEnabled(mask, 1), isEnabled(mask, 2), isEnabled(mask, 3))
|
|
379
|
+
// aria-activedescendant exists only while focus is inside an activedescendant group
|
|
380
|
+
val ActiveDescendantContract: bool =
|
|
381
|
+
if (flavor == "activedescendant")
|
|
382
|
+
and(activeDescendant(flavor, focus) == focus, or(focus < 0, domFocus(flavor, focus) == 4))
|
|
383
|
+
else
|
|
384
|
+
and(activeDescendant(flavor, focus) == -1, domFocus(flavor, focus) == focus)
|
|
385
|
+
val VariantValid: bool = or(flavor == "roving", flavor == "activedescendant")
|
|
386
|
+
// callbacks only ever count up from zero
|
|
387
|
+
val RequestsNonNegative: bool = requests >= 0
|
|
388
|
+
// controlled: only the value prop sets the check, so no event commits
|
|
389
|
+
val ControlledFollowsProp: bool = or(not(controlled), checked == valueProp)
|
|
390
|
+
// a rejected request leaves the check as it found it
|
|
391
|
+
val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), checked == base)
|
|
392
|
+
// an accepted request committed exactly its intent
|
|
393
|
+
val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), checked == requested)
|
|
394
|
+
}
|