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