@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,131 @@
|
|
|
1
|
+
// APG Alert — https://www.w3.org/WAI/ARIA/apg/patterns/alert/
|
|
2
|
+
//
|
|
3
|
+
// A brief, important message in a role=alert live region. The APG contract the
|
|
4
|
+
// machine checks:
|
|
5
|
+
// - a message rendered or changed AFTER page load is announced;
|
|
6
|
+
// - a message already present when the page finished loading is NOT announced
|
|
7
|
+
// (screen readers ignore alerts present before load completes);
|
|
8
|
+
// - an alert never takes keyboard focus (that is the alertdialog pattern);
|
|
9
|
+
// - an alert never disappears on its own (WCAG 2.2.3): there is no timer
|
|
10
|
+
// event, the only way out is the user's `dismiss`.
|
|
11
|
+
//
|
|
12
|
+
// Events:
|
|
13
|
+
// show render a new message (mounts the alert, or replaces its text)
|
|
14
|
+
// load the page finishes loading
|
|
15
|
+
// dismiss the user closes the alert
|
|
16
|
+
// focusTask / focusOther the user moves focus around the page
|
|
17
|
+
//
|
|
18
|
+
// ids: every `show` takes a fresh message id; `announced` is the id the
|
|
19
|
+
// assistive technology last spoke (0 = nothing yet), `preloaded` the id that
|
|
20
|
+
// was already rendered when the page finished loading (0 = none).
|
|
21
|
+
//
|
|
22
|
+
// ponytail: queueing / politeness (aria-live=assertive vs polite) is not
|
|
23
|
+
// modelled; add a `queue` var if overlapping alerts matter.
|
|
24
|
+
module apg_alert {
|
|
25
|
+
var loaded: bool // page load completed
|
|
26
|
+
var mounted: bool // the alert element is in the DOM
|
|
27
|
+
var role: str // rendered role: "alert" | "none"
|
|
28
|
+
var message: int // id of the message currently shown (0 = none)
|
|
29
|
+
var nextId: int // the next message id to hand out
|
|
30
|
+
var announced: int // last message id announced by AT (0 = none)
|
|
31
|
+
var announcements: int // number of announcements so far
|
|
32
|
+
var preloaded: int // message id present at load time (0 = none)
|
|
33
|
+
var focus: str // "task" | "other" | "alert"
|
|
34
|
+
var dismissals: int // user dismissals
|
|
35
|
+
|
|
36
|
+
action init = all {
|
|
37
|
+
loaded' = false,
|
|
38
|
+
mounted' = false,
|
|
39
|
+
role' = "none",
|
|
40
|
+
message' = 0,
|
|
41
|
+
nextId' = 1,
|
|
42
|
+
announced' = 0,
|
|
43
|
+
announcements' = 0,
|
|
44
|
+
preloaded' = 0,
|
|
45
|
+
focus' = "task",
|
|
46
|
+
dismissals' = 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// before load the message renders silently; after load it is announced
|
|
50
|
+
action show = all {
|
|
51
|
+
loaded' = loaded,
|
|
52
|
+
mounted' = true,
|
|
53
|
+
role' = "alert",
|
|
54
|
+
message' = nextId,
|
|
55
|
+
nextId' = nextId + 1,
|
|
56
|
+
announced' = if (loaded) nextId else announced,
|
|
57
|
+
announcements' = if (loaded) announcements + 1 else announcements,
|
|
58
|
+
preloaded' = if (loaded) preloaded else nextId,
|
|
59
|
+
focus' = focus,
|
|
60
|
+
dismissals' = dismissals
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
action load = all {
|
|
64
|
+
loaded' = true,
|
|
65
|
+
mounted' = mounted,
|
|
66
|
+
role' = role,
|
|
67
|
+
message' = message,
|
|
68
|
+
nextId' = nextId,
|
|
69
|
+
announced' = announced,
|
|
70
|
+
announcements' = announcements,
|
|
71
|
+
preloaded' = if (loaded) preloaded else message,
|
|
72
|
+
focus' = focus,
|
|
73
|
+
dismissals' = dismissals
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
action dismiss = all {
|
|
77
|
+
loaded' = loaded,
|
|
78
|
+
mounted' = false,
|
|
79
|
+
role' = "none",
|
|
80
|
+
message' = 0,
|
|
81
|
+
nextId' = nextId,
|
|
82
|
+
announced' = announced,
|
|
83
|
+
announcements' = announcements,
|
|
84
|
+
preloaded' = preloaded,
|
|
85
|
+
focus' = focus,
|
|
86
|
+
dismissals' = if (mounted) dismissals + 1 else dismissals
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
action focusTask = all {
|
|
90
|
+
loaded' = loaded,
|
|
91
|
+
mounted' = mounted,
|
|
92
|
+
role' = role,
|
|
93
|
+
message' = message,
|
|
94
|
+
nextId' = nextId,
|
|
95
|
+
announced' = announced,
|
|
96
|
+
announcements' = announcements,
|
|
97
|
+
preloaded' = preloaded,
|
|
98
|
+
focus' = "task",
|
|
99
|
+
dismissals' = dismissals
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
action focusOther = all {
|
|
103
|
+
loaded' = loaded,
|
|
104
|
+
mounted' = mounted,
|
|
105
|
+
role' = role,
|
|
106
|
+
message' = message,
|
|
107
|
+
nextId' = nextId,
|
|
108
|
+
announced' = announced,
|
|
109
|
+
announcements' = announcements,
|
|
110
|
+
preloaded' = preloaded,
|
|
111
|
+
focus' = "other",
|
|
112
|
+
dismissals' = dismissals
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
action step = any { init, show, load, dismiss, focusTask, focusOther }
|
|
116
|
+
|
|
117
|
+
// APG: an alert never affects keyboard focus
|
|
118
|
+
val FocusNeverOnAlert: bool = focus != "alert"
|
|
119
|
+
// the live-region role is present exactly while the alert is mounted
|
|
120
|
+
val RoleWhenMounted: bool = (role == "alert") == mounted
|
|
121
|
+
// a mounted alert carries a message, an unmounted one carries none
|
|
122
|
+
val MountedHasMessage: bool = mounted == (message != 0)
|
|
123
|
+
// alerts present before load completes are never announced
|
|
124
|
+
val PreloadedNotAnnounced: bool = or(preloaded == 0, announced != preloaded)
|
|
125
|
+
// after load, a live message is the one that was announced
|
|
126
|
+
val LiveMessageAnnounced: bool = or(not(and(loaded, mounted)), or(message == preloaded, announced == message))
|
|
127
|
+
// nothing is announced before the page has loaded
|
|
128
|
+
val SilentBeforeLoad: bool = or(loaded, announcements == 0)
|
|
129
|
+
// ids and counters only move forward
|
|
130
|
+
val CountersConsistent: bool = and(announced < nextId, and(announcements >= 0, dismissals >= 0))
|
|
131
|
+
}
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// APG Alert and Message Dialogs pattern — the discard-notes confirmation.
|
|
2
|
+
//
|
|
3
|
+
// Source: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
|
|
4
|
+
// sections: About This Pattern, Keyboard Interaction (defers to the modal
|
|
5
|
+
// dialog pattern), WAI-ARIA Roles, States, and Properties
|
|
6
|
+
// example: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/examples/alertdialog/
|
|
7
|
+
// keyboard: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ (Keyboard
|
|
8
|
+
// Interaction, Notes 1-2)
|
|
9
|
+
//
|
|
10
|
+
// A page with a Notes textarea, Save and Discard buttons; Discard opens an
|
|
11
|
+
// alertdialog with No and Yes buttons. Focus is one encoded target
|
|
12
|
+
// `layer * 100 + position`: layer 0 is the page (1 textarea, 2 Save,
|
|
13
|
+
// 3 Discard), layer 1 the alertdialog (1 No, 2 Yes).
|
|
14
|
+
//
|
|
15
|
+
// Modelled
|
|
16
|
+
// - open: activating an enabled Discard opens the alertdialog and moves focus
|
|
17
|
+
// to the LEAST DESTRUCTIVE action ("No"); the invoker is remembered.
|
|
18
|
+
// - Tab / Shift+Tab: cycle (wrap) within the dialog, never leaving it.
|
|
19
|
+
// - Escape or "No": close without discarding; "Yes": discard the notes and
|
|
20
|
+
// storage, then close. Focus returns to the invoker (Discard); when the
|
|
21
|
+
// buttons use aria-disabled (the example) the emptied Discard stays
|
|
22
|
+
// focusable, so focus really lands there.
|
|
23
|
+
// - Discard is aria-disabled while the textarea is empty and Save while the
|
|
24
|
+
// textarea equals storage; aria-disabled controls stay in the tab sequence
|
|
25
|
+
// but cannot be activated. USE_ARIA_DISABLED=false models the HTML
|
|
26
|
+
// `disabled` variant for Discard (it drops out of the tab sequence and the
|
|
27
|
+
// return focus falls back to the textarea).
|
|
28
|
+
// - Ctrl/Cmd+S in the textarea saves; typing/clearing edit the textarea.
|
|
29
|
+
// - focus_attempt (pointer click / programmatic focus) is refused outside
|
|
30
|
+
// the active layer; pointer_outside never dismisses an alertdialog (it
|
|
31
|
+
// demands a response).
|
|
32
|
+
// - ARIA contract (derived defs): role=alertdialog, aria-modal=true, a name
|
|
33
|
+
// (aria-labelledby: the "Confirmation" heading) and a REQUIRED
|
|
34
|
+
// aria-describedby referencing the message.
|
|
35
|
+
//
|
|
36
|
+
// Request vs commit (map ticket rt1p). The dialog's `isOpen` is the controlled
|
|
37
|
+
// value: `activate` and `escape` are requests carrying the open intent
|
|
38
|
+
// (`requested`). A request fires onValueChange (`requests` counts the calls)
|
|
39
|
+
// when the intent differs from the current open state, and then:
|
|
40
|
+
// uncontrolled the request is accepted: the state commits the intent
|
|
41
|
+
// controlled, holds the host ignores it ("rejected"): the state is untouched
|
|
42
|
+
// controlled, accepts the host writes the intent back (`accepting`)
|
|
43
|
+
// `valueProp` is the host's copy (meaningful while `controlled`); `setValue` /
|
|
44
|
+
// `hostToggle` are the host writing it, the only commit while controlled.
|
|
45
|
+
// `control` / `acceptRequests` configure the host (init is literal-only). A
|
|
46
|
+
// rejected open/close request leaves focus, the return target and the notes
|
|
47
|
+
// untouched too, so the whole transition is gated by the rejection.
|
|
48
|
+
//
|
|
49
|
+
// Invariants for the model: ControlledFollowsProp (in controlled mode no event
|
|
50
|
+
// commits), RejectedRequestUntouched, AcceptedRequestCommits.
|
|
51
|
+
//
|
|
52
|
+
// Deliberately omitted
|
|
53
|
+
// - the non-modal Alert pattern shown beside it (the "saved" alert), which
|
|
54
|
+
// never moves focus or changes this machine's state;
|
|
55
|
+
// - Save's own aria-disabled/disabled variant (only Discard has the variant);
|
|
56
|
+
// - stacking an alertdialog over another modal (see dialog-modal.qnt);
|
|
57
|
+
// - the notes' real text: contents are three revisions (0 empty, 1, 2);
|
|
58
|
+
// - the system alert sound, visual dimming, aria-hidden legacy handling.
|
|
59
|
+
module apg_alertdialog {
|
|
60
|
+
var isOpen: bool // the alertdialog is open
|
|
61
|
+
var focus: int // layer * 100 + position
|
|
62
|
+
var ret: int // focus target to restore on close (0 = none)
|
|
63
|
+
var interacted: bool // focus moved since the dialog opened
|
|
64
|
+
var notes: int // textarea content revision (0 = empty)
|
|
65
|
+
var stored: int // localStorage content revision (0 = empty)
|
|
66
|
+
var requests: int // number of requests that fired onValueChange
|
|
67
|
+
var controlled: bool // the host owns the open state (`value` prop present)
|
|
68
|
+
var accepting: bool // controlled host that answers every request with its intent
|
|
69
|
+
var valueProp: bool // the controlled value prop (meaningful while controlled)
|
|
70
|
+
var requested: bool // intent of the last request
|
|
71
|
+
var outcome: str // "none" | "accepted" | "rejected"
|
|
72
|
+
var base: bool // open state when the last request was received
|
|
73
|
+
|
|
74
|
+
// --- configuration -----------------------------------------------------------
|
|
75
|
+
def USE_ARIA_DISABLED: bool = true
|
|
76
|
+
def NO_POS: int = 1 // least destructive action
|
|
77
|
+
def YES_POS: int = 2 // destructive action
|
|
78
|
+
def FALLBACK_TARGET: int = 1 // textarea, when the invoker cannot take focus
|
|
79
|
+
|
|
80
|
+
// --- ARIA contract -----------------------------------------------------------
|
|
81
|
+
def dialogRole: str = "alertdialog"
|
|
82
|
+
def ariaModal(open: bool): bool = open
|
|
83
|
+
def hasAccessibleName: bool = true // aria-labelledby -> heading "Confirmation"
|
|
84
|
+
def hasDescription: bool = true // aria-describedby is required for alertdialog
|
|
85
|
+
def contractOk(open: bool): bool =
|
|
86
|
+
and(dialogRole == "alertdialog", ariaModal(open), hasAccessibleName, hasDescription)
|
|
87
|
+
def discardAriaDisabled: bool = notes == 0
|
|
88
|
+
def saveAriaDisabled: bool = notes == stored
|
|
89
|
+
def pageInert: bool = isOpen
|
|
90
|
+
|
|
91
|
+
// --- derived state -------------------------------------------------------------
|
|
92
|
+
def discardFocusable: bool = or(USE_ARIA_DISABLED, notes != 0)
|
|
93
|
+
def layer: int = if (isOpen) 1 else 0
|
|
94
|
+
def focusLayer: int = focus / 100
|
|
95
|
+
def focusPos: int = focus % 100
|
|
96
|
+
def tabCount(l: int): int =
|
|
97
|
+
if (l == 1) 2 else if (discardFocusable) 3 else 2
|
|
98
|
+
def validPos(l: int, pos: int): bool = and(pos >= 1, pos <= tabCount(l))
|
|
99
|
+
def validTarget(target: int): bool =
|
|
100
|
+
and(target / 100 == layer, validPos(layer, target % 100))
|
|
101
|
+
def nextPos(pos: int, n: int): int = if (pos >= n) 1 else pos + 1
|
|
102
|
+
def prevPos(pos: int, n: int): int = if (pos <= 1) n else pos - 1
|
|
103
|
+
def isDestructive(pos: int): bool = pos == YES_POS
|
|
104
|
+
// focus restored on close, given the notes revision after the close
|
|
105
|
+
def retFocus(notesAfter: int): int =
|
|
106
|
+
if (and(ret == 3, not(or(USE_ARIA_DISABLED, notesAfter != 0)))) FALLBACK_TARGET else ret
|
|
107
|
+
|
|
108
|
+
// --- transition kernel ---------------------------------------------------------
|
|
109
|
+
// ev: 1 escape, 2 activate, 3 tab, 4 shift_tab, 5 focus_attempt, 6 ctrl_s,
|
|
110
|
+
// 7 type_text, 8 clear_text, 9 pointer_outside
|
|
111
|
+
// mode: 0 none, 1 open, 2 close (dismiss), 3 close (confirm discard),
|
|
112
|
+
// 4 save, 5 type, 6 clear
|
|
113
|
+
def activateMode: int =
|
|
114
|
+
if (isOpen) (if (focus == 100 + NO_POS) 2 else if (focus == 100 + YES_POS) 3 else 0)
|
|
115
|
+
else if (and(focus == 3, notes != 0)) 1
|
|
116
|
+
else if (and(focus == 2, notes != stored)) 4
|
|
117
|
+
else 0
|
|
118
|
+
def modeOf(ev: int): int =
|
|
119
|
+
if (ev == 1) (if (isOpen) 2 else 0)
|
|
120
|
+
else if (ev == 2) activateMode
|
|
121
|
+
else if (ev == 6) (if (and(not(isOpen), focus == 1, notes != stored)) 4 else 0)
|
|
122
|
+
else if (ev == 7) (if (and(not(isOpen), focus == 1)) 5 else 0)
|
|
123
|
+
else if (ev == 8) (if (and(not(isOpen), focus == 1)) 6 else 0)
|
|
124
|
+
else 0
|
|
125
|
+
|
|
126
|
+
def nextOpen(ev: int): bool =
|
|
127
|
+
if (modeOf(ev) == 1) true
|
|
128
|
+
else if (or(modeOf(ev) == 2, modeOf(ev) == 3)) false
|
|
129
|
+
else isOpen
|
|
130
|
+
def nextNotes(ev: int): int =
|
|
131
|
+
if (or(modeOf(ev) == 3, modeOf(ev) == 6)) 0
|
|
132
|
+
else if (modeOf(ev) == 5) (if (notes == 1) 2 else 1)
|
|
133
|
+
else notes
|
|
134
|
+
def nextStored(ev: int): int =
|
|
135
|
+
if (modeOf(ev) == 3) 0
|
|
136
|
+
else if (modeOf(ev) == 4) notes
|
|
137
|
+
else stored
|
|
138
|
+
def nextRet(ev: int): int =
|
|
139
|
+
if (modeOf(ev) == 1) focus
|
|
140
|
+
else if (or(modeOf(ev) == 2, modeOf(ev) == 3)) 0
|
|
141
|
+
else ret
|
|
142
|
+
def nextInteracted(ev: int, arg: int): bool =
|
|
143
|
+
if (modeOf(ev) == 1) false
|
|
144
|
+
else if (or(ev == 3, ev == 4)) true
|
|
145
|
+
else if (and(ev == 5, validTarget(arg))) true
|
|
146
|
+
else interacted
|
|
147
|
+
def nextFocus(ev: int, arg: int): int =
|
|
148
|
+
if (modeOf(ev) == 1) 100 + NO_POS
|
|
149
|
+
else if (modeOf(ev) == 2) retFocus(notes)
|
|
150
|
+
else if (modeOf(ev) == 3) retFocus(0)
|
|
151
|
+
else if (ev == 3) layer * 100 + nextPos(focusPos, tabCount(layer))
|
|
152
|
+
else if (ev == 4) layer * 100 + prevPos(focusPos, tabCount(layer))
|
|
153
|
+
else if (and(ev == 5, validTarget(arg))) arg
|
|
154
|
+
else focus
|
|
155
|
+
|
|
156
|
+
// --- request / commit (map ticket rt1p) ----------------------------------
|
|
157
|
+
def moved(a: bool, b: bool): int = if (a == b) 0 else 1
|
|
158
|
+
def holds: bool = and(controlled, not(accepting))
|
|
159
|
+
def follows: bool = and(controlled, accepting)
|
|
160
|
+
def verdict: str = if (holds) "rejected" else "accepted"
|
|
161
|
+
// the open state after a request for `want`
|
|
162
|
+
def landed(h: bool, v: bool, want: bool): bool = if (h) v else want
|
|
163
|
+
// the value prop after a request for `want`
|
|
164
|
+
def propAfter(f: bool, p: bool, want: bool): bool = if (f) want else p
|
|
165
|
+
// the open intent of each request event
|
|
166
|
+
def wantOpenActivate: bool = nextOpen(2)
|
|
167
|
+
def wantOpenEscape: bool = nextOpen(1)
|
|
168
|
+
// only a request that actually changes the open state can be rejected
|
|
169
|
+
def rejectActivate: bool = and(holds, wantOpenActivate != isOpen)
|
|
170
|
+
def rejectEscape: bool = and(holds, wantOpenEscape != isOpen)
|
|
171
|
+
|
|
172
|
+
// --- host writes (the only commit while controlled) ----------------------
|
|
173
|
+
// opening keeps the least destructive focus and remembers Discard as invoker
|
|
174
|
+
def setOpen(c: bool, v: bool): bool = if (c) v else false
|
|
175
|
+
def openingSet(c: bool, v: bool, open: bool): bool = and(c, and(v, not(open)))
|
|
176
|
+
def closingSet(c: bool, v: bool, open: bool): bool = and(c, and(not(v), open))
|
|
177
|
+
def toggleOpen(c: bool, open: bool): bool = if (c) not(open) else open
|
|
178
|
+
def toggleOpening(c: bool, open: bool): bool = and(c, not(open))
|
|
179
|
+
def toggleClosing(c: bool, open: bool): bool = and(c, open)
|
|
180
|
+
|
|
181
|
+
action init = all {
|
|
182
|
+
isOpen' = false,
|
|
183
|
+
focus' = 1,
|
|
184
|
+
ret' = 0,
|
|
185
|
+
interacted' = false,
|
|
186
|
+
notes' = 1,
|
|
187
|
+
stored' = 1,
|
|
188
|
+
requests' = 0,
|
|
189
|
+
controlled' = false,
|
|
190
|
+
accepting' = false,
|
|
191
|
+
valueProp' = false,
|
|
192
|
+
requested' = false,
|
|
193
|
+
outcome' = "none",
|
|
194
|
+
base' = false
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// request: Escape closes the dialog (a no-op while already closed)
|
|
198
|
+
action escape = all {
|
|
199
|
+
isOpen' = landed(holds, isOpen, wantOpenEscape),
|
|
200
|
+
focus' = if (rejectEscape) focus else nextFocus(1, 0),
|
|
201
|
+
ret' = if (rejectEscape) ret else nextRet(1),
|
|
202
|
+
interacted' = if (rejectEscape) interacted else nextInteracted(1, 0),
|
|
203
|
+
notes' = if (rejectEscape) notes else nextNotes(1),
|
|
204
|
+
stored' = if (rejectEscape) stored else nextStored(1),
|
|
205
|
+
requests' = requests + moved(isOpen, wantOpenEscape),
|
|
206
|
+
controlled' = controlled,
|
|
207
|
+
accepting' = accepting,
|
|
208
|
+
valueProp' = propAfter(follows, valueProp, wantOpenEscape),
|
|
209
|
+
requested' = if (isOpen == wantOpenEscape) requested else wantOpenEscape,
|
|
210
|
+
outcome' = if (isOpen == wantOpenEscape) outcome else verdict,
|
|
211
|
+
base' = if (isOpen == wantOpenEscape) base else isOpen
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// request: activate (Discard opens, "No" closes, "Yes" discards and closes,
|
|
215
|
+
// Save stores; only the open-changing paths are requests)
|
|
216
|
+
action activate = all {
|
|
217
|
+
isOpen' = landed(holds, isOpen, wantOpenActivate),
|
|
218
|
+
focus' = if (rejectActivate) focus else nextFocus(2, 0),
|
|
219
|
+
ret' = if (rejectActivate) ret else nextRet(2),
|
|
220
|
+
interacted' = if (rejectActivate) interacted else nextInteracted(2, 0),
|
|
221
|
+
notes' = if (rejectActivate) notes else nextNotes(2),
|
|
222
|
+
stored' = if (rejectActivate) stored else nextStored(2),
|
|
223
|
+
requests' = requests + moved(isOpen, wantOpenActivate),
|
|
224
|
+
controlled' = controlled,
|
|
225
|
+
accepting' = accepting,
|
|
226
|
+
valueProp' = propAfter(follows, valueProp, wantOpenActivate),
|
|
227
|
+
requested' = if (isOpen == wantOpenActivate) requested else wantOpenActivate,
|
|
228
|
+
outcome' = if (isOpen == wantOpenActivate) outcome else verdict,
|
|
229
|
+
base' = if (isOpen == wantOpenActivate) base else isOpen
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
action tab = all {
|
|
233
|
+
isOpen' = nextOpen(3),
|
|
234
|
+
focus' = nextFocus(3, 0),
|
|
235
|
+
ret' = nextRet(3),
|
|
236
|
+
interacted' = nextInteracted(3, 0),
|
|
237
|
+
notes' = nextNotes(3),
|
|
238
|
+
stored' = nextStored(3),
|
|
239
|
+
requests' = requests,
|
|
240
|
+
controlled' = controlled,
|
|
241
|
+
accepting' = accepting,
|
|
242
|
+
valueProp' = valueProp,
|
|
243
|
+
requested' = requested,
|
|
244
|
+
outcome' = outcome,
|
|
245
|
+
base' = base
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
action shift_tab = all {
|
|
249
|
+
isOpen' = nextOpen(4),
|
|
250
|
+
focus' = nextFocus(4, 0),
|
|
251
|
+
ret' = nextRet(4),
|
|
252
|
+
interacted' = nextInteracted(4, 0),
|
|
253
|
+
notes' = nextNotes(4),
|
|
254
|
+
stored' = nextStored(4),
|
|
255
|
+
requests' = requests,
|
|
256
|
+
controlled' = controlled,
|
|
257
|
+
accepting' = accepting,
|
|
258
|
+
valueProp' = valueProp,
|
|
259
|
+
requested' = requested,
|
|
260
|
+
outcome' = outcome,
|
|
261
|
+
base' = base
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// pointer click / programmatic focus on any encoded target (valid or not)
|
|
265
|
+
action focus_attempt = {
|
|
266
|
+
nondet target = Set(0, 1, 2, 3, 4, 100, 101, 102, 103).oneOf()
|
|
267
|
+
all {
|
|
268
|
+
isOpen' = nextOpen(5),
|
|
269
|
+
focus' = nextFocus(5, target),
|
|
270
|
+
ret' = nextRet(5),
|
|
271
|
+
interacted' = nextInteracted(5, target),
|
|
272
|
+
notes' = nextNotes(5),
|
|
273
|
+
stored' = nextStored(5),
|
|
274
|
+
requests' = requests,
|
|
275
|
+
controlled' = controlled,
|
|
276
|
+
accepting' = accepting,
|
|
277
|
+
valueProp' = valueProp,
|
|
278
|
+
requested' = requested,
|
|
279
|
+
outcome' = outcome,
|
|
280
|
+
base' = base
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
action ctrl_s = all {
|
|
285
|
+
isOpen' = nextOpen(6),
|
|
286
|
+
focus' = nextFocus(6, 0),
|
|
287
|
+
ret' = nextRet(6),
|
|
288
|
+
interacted' = nextInteracted(6, 0),
|
|
289
|
+
notes' = nextNotes(6),
|
|
290
|
+
stored' = nextStored(6),
|
|
291
|
+
requests' = requests,
|
|
292
|
+
controlled' = controlled,
|
|
293
|
+
accepting' = accepting,
|
|
294
|
+
valueProp' = valueProp,
|
|
295
|
+
requested' = requested,
|
|
296
|
+
outcome' = outcome,
|
|
297
|
+
base' = base
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
action type_text = all {
|
|
301
|
+
isOpen' = nextOpen(7),
|
|
302
|
+
focus' = nextFocus(7, 0),
|
|
303
|
+
ret' = nextRet(7),
|
|
304
|
+
interacted' = nextInteracted(7, 0),
|
|
305
|
+
notes' = nextNotes(7),
|
|
306
|
+
stored' = nextStored(7),
|
|
307
|
+
requests' = requests,
|
|
308
|
+
controlled' = controlled,
|
|
309
|
+
accepting' = accepting,
|
|
310
|
+
valueProp' = valueProp,
|
|
311
|
+
requested' = requested,
|
|
312
|
+
outcome' = outcome,
|
|
313
|
+
base' = base
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
action clear_text = all {
|
|
317
|
+
isOpen' = nextOpen(8),
|
|
318
|
+
focus' = nextFocus(8, 0),
|
|
319
|
+
ret' = nextRet(8),
|
|
320
|
+
interacted' = nextInteracted(8, 0),
|
|
321
|
+
notes' = nextNotes(8),
|
|
322
|
+
stored' = nextStored(8),
|
|
323
|
+
requests' = requests,
|
|
324
|
+
controlled' = controlled,
|
|
325
|
+
accepting' = accepting,
|
|
326
|
+
valueProp' = valueProp,
|
|
327
|
+
requested' = requested,
|
|
328
|
+
outcome' = outcome,
|
|
329
|
+
base' = base
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// a click on the inert page never dismisses an alertdialog
|
|
333
|
+
action pointer_outside = all {
|
|
334
|
+
isOpen' = nextOpen(9),
|
|
335
|
+
focus' = nextFocus(9, 0),
|
|
336
|
+
ret' = nextRet(9),
|
|
337
|
+
interacted' = nextInteracted(9, 0),
|
|
338
|
+
notes' = nextNotes(9),
|
|
339
|
+
stored' = nextStored(9),
|
|
340
|
+
requests' = requests,
|
|
341
|
+
controlled' = controlled,
|
|
342
|
+
accepting' = accepting,
|
|
343
|
+
valueProp' = valueProp,
|
|
344
|
+
requested' = requested,
|
|
345
|
+
outcome' = outcome,
|
|
346
|
+
base' = base
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// commit: the host writes the controlled open state (a no-op while uncontrolled)
|
|
350
|
+
action setValue = {
|
|
351
|
+
nondet v = Set(true, false).oneOf()
|
|
352
|
+
all {
|
|
353
|
+
isOpen' = if (controlled) setOpen(true, v) else isOpen,
|
|
354
|
+
focus' = if (openingSet(controlled, v, isOpen)) (100 + NO_POS)
|
|
355
|
+
else if (closingSet(controlled, v, isOpen)) retFocus(notes) else focus,
|
|
356
|
+
ret' = if (openingSet(controlled, v, isOpen)) 3
|
|
357
|
+
else if (closingSet(controlled, v, isOpen)) 0 else ret,
|
|
358
|
+
interacted' = if (openingSet(controlled, v, isOpen)) false else interacted,
|
|
359
|
+
notes' = notes,
|
|
360
|
+
stored' = stored,
|
|
361
|
+
requests' = requests,
|
|
362
|
+
controlled' = controlled,
|
|
363
|
+
accepting' = accepting,
|
|
364
|
+
valueProp' = if (controlled) setOpen(true, v) else valueProp,
|
|
365
|
+
requested' = requested,
|
|
366
|
+
outcome' = if (controlled) "none" else outcome,
|
|
367
|
+
base' = base
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// commit: the host flips its own copy of the open state
|
|
372
|
+
action hostToggle = all {
|
|
373
|
+
isOpen' = if (controlled) toggleOpen(true, isOpen) else isOpen,
|
|
374
|
+
focus' = if (toggleOpening(controlled, isOpen)) (100 + NO_POS)
|
|
375
|
+
else if (toggleClosing(controlled, isOpen)) retFocus(notes) else focus,
|
|
376
|
+
ret' = if (toggleOpening(controlled, isOpen)) 3
|
|
377
|
+
else if (toggleClosing(controlled, isOpen)) 0 else ret,
|
|
378
|
+
interacted' = if (toggleOpening(controlled, isOpen)) false else interacted,
|
|
379
|
+
notes' = notes,
|
|
380
|
+
stored' = stored,
|
|
381
|
+
requests' = requests,
|
|
382
|
+
controlled' = controlled,
|
|
383
|
+
accepting' = accepting,
|
|
384
|
+
valueProp' = if (controlled) toggleOpen(true, isOpen) else valueProp,
|
|
385
|
+
requested' = requested,
|
|
386
|
+
outcome' = if (controlled) "none" else outcome,
|
|
387
|
+
base' = base
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// configuration: the host starts owning the open state, adopting the current one
|
|
391
|
+
action control = all {
|
|
392
|
+
isOpen' = isOpen,
|
|
393
|
+
focus' = focus,
|
|
394
|
+
ret' = ret,
|
|
395
|
+
interacted' = interacted,
|
|
396
|
+
notes' = notes,
|
|
397
|
+
stored' = stored,
|
|
398
|
+
requests' = requests,
|
|
399
|
+
controlled' = true,
|
|
400
|
+
accepting' = accepting,
|
|
401
|
+
valueProp' = isOpen,
|
|
402
|
+
requested' = requested,
|
|
403
|
+
outcome' = outcome,
|
|
404
|
+
base' = base
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// configuration: the controlled host answers every request with its intent
|
|
408
|
+
action acceptRequests = all {
|
|
409
|
+
isOpen' = isOpen,
|
|
410
|
+
focus' = focus,
|
|
411
|
+
ret' = ret,
|
|
412
|
+
interacted' = interacted,
|
|
413
|
+
notes' = notes,
|
|
414
|
+
stored' = stored,
|
|
415
|
+
requests' = requests,
|
|
416
|
+
controlled' = controlled,
|
|
417
|
+
accepting' = true,
|
|
418
|
+
valueProp' = valueProp,
|
|
419
|
+
requested' = requested,
|
|
420
|
+
outcome' = outcome,
|
|
421
|
+
base' = base
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
action step = any {
|
|
425
|
+
init, escape, activate, tab, shift_tab, focus_attempt, ctrl_s, type_text,
|
|
426
|
+
clear_text, pointer_outside, setValue, hostToggle, control, acceptRequests
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- invariants ------------------------------------------------------------
|
|
430
|
+
// focus never leaves the alertdialog while open (page when closed)
|
|
431
|
+
val FocusInActiveLayer: bool = focusLayer == layer
|
|
432
|
+
// focus sits on a real, focusable element (a disabled Discard is not one
|
|
433
|
+
// unless it uses aria-disabled)
|
|
434
|
+
val FocusPositionValid: bool = validPos(layer, focusPos)
|
|
435
|
+
// the dialog is only reachable through an enabled Discard, so notes exist
|
|
436
|
+
// (a controlled host may still force it open with an empty textarea)
|
|
437
|
+
val OpenImpliesNotes: bool = or(not(isOpen), or(notes != 0, controlled))
|
|
438
|
+
// return focus: the invoker (Discard) while open, nothing remembered when closed
|
|
439
|
+
val ReturnTargetIsInvoker: bool = if (isOpen) ret == 3 else ret == 0
|
|
440
|
+
// initial focus is the least destructive action until the user moves focus
|
|
441
|
+
val InitialFocusLeastDestructive: bool =
|
|
442
|
+
or(not(isOpen), interacted, not(isDestructive(focusPos)))
|
|
443
|
+
// role=alertdialog, aria-modal=true, accessible name and description
|
|
444
|
+
val AlertDialogHonoursContract: bool = or(not(isOpen), contractOk(isOpen))
|
|
445
|
+
// the page is inert exactly while the dialog is open
|
|
446
|
+
val PageInertIffOpen: bool = if (isOpen) focusLayer == 1 else not(pageInert)
|
|
447
|
+
// callbacks only ever count up from zero
|
|
448
|
+
val RequestsNonNegative: bool = requests >= 0
|
|
449
|
+
// controlled: only the value prop sets the state, so no event commits
|
|
450
|
+
val ControlledFollowsProp: bool = or(not(controlled), isOpen == valueProp)
|
|
451
|
+
// a rejected request leaves the state as it found it
|
|
452
|
+
val RejectedRequestUntouched: bool = or(not(outcome == "rejected"), isOpen == base)
|
|
453
|
+
// an accepted request committed exactly its intent
|
|
454
|
+
val AcceptedRequestCommits: bool = or(not(outcome == "accepted"), isOpen == requested)
|
|
455
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// APG Breadcrumb — https://www.w3.org/WAI/ARIA/apg/patterns/breadcrumb/
|
|
2
|
+
//
|
|
3
|
+
// A trail of links to the parent pages of the current page, in hierarchical
|
|
4
|
+
// order; the current page is the LAST item. The APG contract the machine
|
|
5
|
+
// checks, as the trail changes while the user navigates:
|
|
6
|
+
// - the link to the current page carries aria-current="page";
|
|
7
|
+
// - if the current item is not a link, aria-current is optional;
|
|
8
|
+
// - aria-current never sits on an ancestor (at most one item, and it is the
|
|
9
|
+
// last one).
|
|
10
|
+
// The navigation landmark + its label are a static DOM contract, see
|
|
11
|
+
// specs/apg/STATIC.md. Keyboard: not applicable (plain links).
|
|
12
|
+
//
|
|
13
|
+
// Events:
|
|
14
|
+
// descend the user goes one level deeper (a new last item)
|
|
15
|
+
// ancestor the user follows an ancestor link; the trail is cut
|
|
16
|
+
// back to that item, which becomes the current page
|
|
17
|
+
// renderLink / renderText the current item is a link / plain text
|
|
18
|
+
// toggleMark optional aria-current on a non-link current item
|
|
19
|
+
//
|
|
20
|
+
// currentIndex: 1-based index of the item with aria-current="page" (0 = none).
|
|
21
|
+
module apg_breadcrumb {
|
|
22
|
+
var depth: int // items in the trail (the current page is item `depth`)
|
|
23
|
+
var currentIsLink: bool // the last item is rendered as a link
|
|
24
|
+
var currentIndex: int // item carrying aria-current=page (0 = none)
|
|
25
|
+
var navigations: int // ancestor links followed
|
|
26
|
+
|
|
27
|
+
def deeper: int = if (depth < 4) depth + 1 else depth
|
|
28
|
+
def cutTo(t: int): int = if (t < depth) t else depth
|
|
29
|
+
def remark(d: int): int = if (currentIndex == 0) 0 else d
|
|
30
|
+
|
|
31
|
+
action init = all {
|
|
32
|
+
depth' = 1,
|
|
33
|
+
currentIsLink' = true,
|
|
34
|
+
currentIndex' = 1,
|
|
35
|
+
navigations' = 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
action descend = all {
|
|
39
|
+
depth' = deeper,
|
|
40
|
+
currentIsLink' = currentIsLink,
|
|
41
|
+
currentIndex' = remark(deeper),
|
|
42
|
+
navigations' = navigations
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
action ancestor = {
|
|
46
|
+
nondet target = Set(1, 2, 3).oneOf()
|
|
47
|
+
all {
|
|
48
|
+
depth' = cutTo(target),
|
|
49
|
+
currentIsLink' = currentIsLink,
|
|
50
|
+
currentIndex' = remark(cutTo(target)),
|
|
51
|
+
navigations' = if (target < depth) navigations + 1 else navigations
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
action renderLink = all {
|
|
56
|
+
depth' = depth,
|
|
57
|
+
currentIsLink' = true,
|
|
58
|
+
currentIndex' = depth,
|
|
59
|
+
navigations' = navigations
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
action renderText = all {
|
|
63
|
+
depth' = depth,
|
|
64
|
+
currentIsLink' = false,
|
|
65
|
+
currentIndex' = currentIndex,
|
|
66
|
+
navigations' = navigations
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
action toggleMark = all {
|
|
70
|
+
depth' = depth,
|
|
71
|
+
currentIsLink' = currentIsLink,
|
|
72
|
+
currentIndex' = if (currentIsLink) currentIndex else if (currentIndex == 0) depth else 0,
|
|
73
|
+
navigations' = navigations
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
action step = any { init, descend, ancestor, renderLink, renderText, toggleMark }
|
|
77
|
+
|
|
78
|
+
// APG: aria-current=page is on the last item or absent, never on an ancestor
|
|
79
|
+
val CurrentIsLastOrAbsent: bool = or(currentIndex == 0, currentIndex == depth)
|
|
80
|
+
// APG: the link to the current page always has aria-current=page
|
|
81
|
+
val LinkCurrentIsMarked: bool = or(not(currentIsLink), currentIndex == depth)
|
|
82
|
+
// the trail always holds the current page
|
|
83
|
+
val TrailNotEmpty: bool = depth >= 1
|
|
84
|
+
// the trail is bounded
|
|
85
|
+
val TrailBounded: bool = depth <= 4
|
|
86
|
+
// the counter only grows from zero
|
|
87
|
+
val NavigationsNonNegative: bool = navigations >= 0
|
|
88
|
+
}
|