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