react-x11 1.2.0 → 2.0.0
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/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
package/src/a11y.js
ADDED
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
// The accessibility model: standard `role` / `aria-*` props resolved against
|
|
2
|
+
// the retained node tree, plus the hook slots the renderer polls.
|
|
3
|
+
//
|
|
4
|
+
// This file is the cheap half. It is imported unconditionally by nodes.js,
|
|
5
|
+
// events.js and Reconciler.js, so it must cost nothing when accessibility is
|
|
6
|
+
// off: no D-Bus, no node builtins, no side effects — the hot paths pay one
|
|
7
|
+
// property read per hook (`hooks.focus?.(…)`), exactly the trace-registry
|
|
8
|
+
// pattern. The expensive half — the AT-SPI2 bridge that mirrors this model
|
|
9
|
+
// onto the accessibility bus — lives in atspi.js and is only imported once a
|
|
10
|
+
// root exists (and never with REACT_X11_A11Y=0).
|
|
11
|
+
//
|
|
12
|
+
// ## The vocabulary is the web's, deliberately
|
|
13
|
+
//
|
|
14
|
+
// `role` takes ARIA role names ('button', 'checkbox', 'tablist', …) and the
|
|
15
|
+
// states are `aria-*` props (`aria-checked`, `aria-expanded`,
|
|
16
|
+
// `aria-valuenow`, …) — the same names react-dom accepts and React Native
|
|
17
|
+
// adopted in 0.71. NEXT_STEPS §11.3 originally suggested the older RN
|
|
18
|
+
// vocabulary (`accessibilityRole`, `accessibilityState`); the widgets were
|
|
19
|
+
// then built carrying web-style `role` strings years before anything read
|
|
20
|
+
// them, which settled the question: the standard names are the ones this
|
|
21
|
+
// codebase already speaks. A component library that sets `role="option"` and
|
|
22
|
+
// `aria-selected` on a `<box>` is accessible here with no react-x11-specific
|
|
23
|
+
// knowledge at all.
|
|
24
|
+
//
|
|
25
|
+
// What there is NOT: `aria-labelledby`/`aria-describedby` (no id registry to
|
|
26
|
+
// resolve them against — `aria-label` and name-from-contents cover the
|
|
27
|
+
// widgets; relations are a later addition), `aria-live` regions (announce()
|
|
28
|
+
// is the explicit version), and no `aria-disabled` — `disabled` is already a
|
|
29
|
+
// host prop with real behaviour (it blocks focus), and a parallel prop that
|
|
30
|
+
// only *said* disabled would let the two disagree.
|
|
31
|
+
//
|
|
32
|
+
// ## Roles and states are AT-SPI's, numerically
|
|
33
|
+
//
|
|
34
|
+
// The tables below are the AT-SPI2 enums, generated from the installed
|
|
35
|
+
// introspection data (at-spi2-core 2.60.4, gi.repository.Atspi — the same
|
|
36
|
+
// tables Orca reads) and checked against at-spi2-core's xml/. They are
|
|
37
|
+
// append-only upstream, so the numbers are stable forever; regenerate with:
|
|
38
|
+
//
|
|
39
|
+
// python3 -c "import gi; gi.require_version('Atspi','2.0'); \
|
|
40
|
+
// from gi.repository import Atspi; \
|
|
41
|
+
// print({k: int(getattr(Atspi.Role, k)) for k in dir(Atspi.Role) if not k.startswith('_')})"
|
|
42
|
+
|
|
43
|
+
/** AT-SPI role numbers (AtspiRole). */
|
|
44
|
+
export const ATSPI_ROLE = Object.freeze({
|
|
45
|
+
INVALID: 0,
|
|
46
|
+
ALERT: 2,
|
|
47
|
+
CANVAS: 6,
|
|
48
|
+
CHECK_BOX: 7,
|
|
49
|
+
CHECK_MENU_ITEM: 8,
|
|
50
|
+
COLUMN_HEADER: 10,
|
|
51
|
+
COMBO_BOX: 11,
|
|
52
|
+
DIAL: 15,
|
|
53
|
+
DIALOG: 16,
|
|
54
|
+
DRAWING_AREA: 18,
|
|
55
|
+
FILE_CHOOSER: 19,
|
|
56
|
+
FILLER: 20,
|
|
57
|
+
FRAME: 23,
|
|
58
|
+
ICON: 26,
|
|
59
|
+
IMAGE: 27,
|
|
60
|
+
LABEL: 29,
|
|
61
|
+
LIST: 31,
|
|
62
|
+
LIST_ITEM: 32,
|
|
63
|
+
MENU: 33,
|
|
64
|
+
MENU_BAR: 34,
|
|
65
|
+
MENU_ITEM: 35,
|
|
66
|
+
PAGE_TAB: 37,
|
|
67
|
+
PAGE_TAB_LIST: 38,
|
|
68
|
+
PANEL: 39,
|
|
69
|
+
PASSWORD_TEXT: 40,
|
|
70
|
+
POPUP_MENU: 41,
|
|
71
|
+
PROGRESS_BAR: 42,
|
|
72
|
+
BUTTON: 43,
|
|
73
|
+
RADIO_BUTTON: 44,
|
|
74
|
+
RADIO_MENU_ITEM: 45,
|
|
75
|
+
ROW_HEADER: 47,
|
|
76
|
+
SCROLL_BAR: 48,
|
|
77
|
+
SCROLL_PANE: 49,
|
|
78
|
+
SEPARATOR: 50,
|
|
79
|
+
SLIDER: 51,
|
|
80
|
+
SPIN_BUTTON: 52,
|
|
81
|
+
SPLIT_PANE: 53,
|
|
82
|
+
STATUS_BAR: 54,
|
|
83
|
+
TABLE: 55,
|
|
84
|
+
TABLE_CELL: 56,
|
|
85
|
+
TABLE_COLUMN_HEADER: 57,
|
|
86
|
+
TABLE_ROW_HEADER: 58,
|
|
87
|
+
TEXT: 61,
|
|
88
|
+
TOGGLE_BUTTON: 62,
|
|
89
|
+
TOOL_BAR: 63,
|
|
90
|
+
TOOL_TIP: 64,
|
|
91
|
+
TREE: 65,
|
|
92
|
+
TREE_TABLE: 66,
|
|
93
|
+
UNKNOWN: 67,
|
|
94
|
+
VIEWPORT: 68,
|
|
95
|
+
WINDOW: 69,
|
|
96
|
+
HEADER: 71,
|
|
97
|
+
FOOTER: 72,
|
|
98
|
+
PARAGRAPH: 73,
|
|
99
|
+
APPLICATION: 75,
|
|
100
|
+
AUTOCOMPLETE: 76,
|
|
101
|
+
// another toolkit's widget hierarchy inside ours — AT-SPI's own word for
|
|
102
|
+
// what `<foreign>` holds. The AT walks into the client's tree, not ours.
|
|
103
|
+
EMBEDDED: 78,
|
|
104
|
+
ENTRY: 79,
|
|
105
|
+
CAPTION: 81,
|
|
106
|
+
DOCUMENT_FRAME: 82,
|
|
107
|
+
HEADING: 83,
|
|
108
|
+
SECTION: 85,
|
|
109
|
+
FORM: 87,
|
|
110
|
+
LINK: 88,
|
|
111
|
+
TABLE_ROW: 90,
|
|
112
|
+
TREE_ITEM: 91,
|
|
113
|
+
DOCUMENT_TEXT: 94,
|
|
114
|
+
DOCUMENT_WEB: 95,
|
|
115
|
+
COMMENT: 97,
|
|
116
|
+
LIST_BOX: 98,
|
|
117
|
+
GROUPING: 99,
|
|
118
|
+
NOTIFICATION: 101,
|
|
119
|
+
INFO_BAR: 102,
|
|
120
|
+
LEVEL_BAR: 103,
|
|
121
|
+
BLOCK_QUOTE: 105,
|
|
122
|
+
ARTICLE: 109,
|
|
123
|
+
LANDMARK: 110,
|
|
124
|
+
LOG: 111,
|
|
125
|
+
MARQUEE: 112,
|
|
126
|
+
MATH: 113,
|
|
127
|
+
RATING: 114,
|
|
128
|
+
TIMER: 115,
|
|
129
|
+
STATIC: 116,
|
|
130
|
+
DESCRIPTION_LIST: 121,
|
|
131
|
+
DESCRIPTION_TERM: 122,
|
|
132
|
+
DESCRIPTION_VALUE: 123,
|
|
133
|
+
SWITCH: 130,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/** AT-SPI state numbers (AtspiStateType). A state set is a 64-bit field
|
|
137
|
+
* carried as two uint32s. */
|
|
138
|
+
export const ATSPI_STATE = Object.freeze({
|
|
139
|
+
ACTIVE: 1,
|
|
140
|
+
BUSY: 3,
|
|
141
|
+
CHECKED: 4,
|
|
142
|
+
COLLAPSED: 5,
|
|
143
|
+
DEFUNCT: 6,
|
|
144
|
+
EDITABLE: 7,
|
|
145
|
+
ENABLED: 8,
|
|
146
|
+
EXPANDABLE: 9,
|
|
147
|
+
EXPANDED: 10,
|
|
148
|
+
FOCUSABLE: 11,
|
|
149
|
+
FOCUSED: 12,
|
|
150
|
+
HORIZONTAL: 14,
|
|
151
|
+
MODAL: 16,
|
|
152
|
+
MULTI_LINE: 17,
|
|
153
|
+
PRESSED: 20,
|
|
154
|
+
RESIZABLE: 21,
|
|
155
|
+
SELECTABLE: 22,
|
|
156
|
+
SELECTED: 23,
|
|
157
|
+
SENSITIVE: 24,
|
|
158
|
+
SHOWING: 25,
|
|
159
|
+
SINGLE_LINE: 26,
|
|
160
|
+
VERTICAL: 29,
|
|
161
|
+
VISIBLE: 30,
|
|
162
|
+
INDETERMINATE: 32,
|
|
163
|
+
REQUIRED: 33,
|
|
164
|
+
CHECKABLE: 41,
|
|
165
|
+
HAS_POPUP: 42,
|
|
166
|
+
READ_ONLY: 43,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The state-changed event's detail string for each state: the GLib enum
|
|
171
|
+
* nick, which is the constant name lowercased with dashes. Orca matches on
|
|
172
|
+
* these ("object:state-changed:focused").
|
|
173
|
+
*/
|
|
174
|
+
export const ATSPI_STATE_NICK = Object.freeze(
|
|
175
|
+
Object.fromEntries(
|
|
176
|
+
Object.entries(ATSPI_STATE).map(([name, bit]) => [
|
|
177
|
+
bit,
|
|
178
|
+
name.toLowerCase().replaceAll('_', '-'),
|
|
179
|
+
]),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The AT-SPI role *name* for each role number — the constant lowercased
|
|
185
|
+
* with spaces ("check box", "page tab list"), which is what
|
|
186
|
+
* `atspi_role_get_name` produces and therefore what every AT means by a
|
|
187
|
+
* role name.
|
|
188
|
+
*
|
|
189
|
+
* This is deliberately **not** the ARIA role the app wrote. libatspi
|
|
190
|
+
* derives role names locally from the number, so a bridge that answered
|
|
191
|
+
* `GetRoleName` with its own vocabulary would be inconsistent with its own
|
|
192
|
+
* `GetRole` and nobody would notice until an AT asked over the wire. The
|
|
193
|
+
* app's ARIA role travels as the `xml-roles` attribute instead, which is
|
|
194
|
+
* where Chromium and Firefox put it and where Orca looks for it.
|
|
195
|
+
*/
|
|
196
|
+
export const ATSPI_ROLE_NICK = Object.freeze(
|
|
197
|
+
Object.fromEntries(
|
|
198
|
+
Object.entries(ATSPI_ROLE).map(([name, role]) => [
|
|
199
|
+
role,
|
|
200
|
+
name.toLowerCase().replaceAll('_', ' '),
|
|
201
|
+
]),
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* ARIA role name → AT-SPI role. The mapping follows what the web engines
|
|
207
|
+
* send to AT-SPI on Linux (Chromium's ax_platform_node_auralinux, Firefox's
|
|
208
|
+
* nsRoleMap), so Orca sees from a react-x11 app exactly what it sees from a
|
|
209
|
+
* browser. Roles with no AT-SPI counterpart borrow the engines' choices
|
|
210
|
+
* ('option' → LIST_ITEM, 'meter' → LEVEL_BAR).
|
|
211
|
+
*/
|
|
212
|
+
const ROLE_TO_ATSPI = new Map(
|
|
213
|
+
Object.entries({
|
|
214
|
+
alert: ATSPI_ROLE.ALERT,
|
|
215
|
+
alertdialog: ATSPI_ROLE.DIALOG,
|
|
216
|
+
article: ATSPI_ROLE.ARTICLE,
|
|
217
|
+
banner: ATSPI_ROLE.LANDMARK,
|
|
218
|
+
blockquote: ATSPI_ROLE.BLOCK_QUOTE,
|
|
219
|
+
button: ATSPI_ROLE.BUTTON,
|
|
220
|
+
caption: ATSPI_ROLE.CAPTION,
|
|
221
|
+
cell: ATSPI_ROLE.TABLE_CELL,
|
|
222
|
+
checkbox: ATSPI_ROLE.CHECK_BOX,
|
|
223
|
+
columnheader: ATSPI_ROLE.COLUMN_HEADER,
|
|
224
|
+
combobox: ATSPI_ROLE.COMBO_BOX,
|
|
225
|
+
comment: ATSPI_ROLE.COMMENT,
|
|
226
|
+
complementary: ATSPI_ROLE.LANDMARK,
|
|
227
|
+
contentinfo: ATSPI_ROLE.LANDMARK,
|
|
228
|
+
dialog: ATSPI_ROLE.DIALOG,
|
|
229
|
+
document: ATSPI_ROLE.DOCUMENT_FRAME,
|
|
230
|
+
form: ATSPI_ROLE.FORM,
|
|
231
|
+
grid: ATSPI_ROLE.TABLE,
|
|
232
|
+
gridcell: ATSPI_ROLE.TABLE_CELL,
|
|
233
|
+
group: ATSPI_ROLE.GROUPING,
|
|
234
|
+
heading: ATSPI_ROLE.HEADING,
|
|
235
|
+
img: ATSPI_ROLE.IMAGE,
|
|
236
|
+
link: ATSPI_ROLE.LINK,
|
|
237
|
+
list: ATSPI_ROLE.LIST,
|
|
238
|
+
listbox: ATSPI_ROLE.LIST_BOX,
|
|
239
|
+
listitem: ATSPI_ROLE.LIST_ITEM,
|
|
240
|
+
log: ATSPI_ROLE.LOG,
|
|
241
|
+
main: ATSPI_ROLE.LANDMARK,
|
|
242
|
+
marquee: ATSPI_ROLE.MARQUEE,
|
|
243
|
+
math: ATSPI_ROLE.MATH,
|
|
244
|
+
menu: ATSPI_ROLE.MENU,
|
|
245
|
+
menubar: ATSPI_ROLE.MENU_BAR,
|
|
246
|
+
menuitem: ATSPI_ROLE.MENU_ITEM,
|
|
247
|
+
menuitemcheckbox: ATSPI_ROLE.CHECK_MENU_ITEM,
|
|
248
|
+
menuitemradio: ATSPI_ROLE.RADIO_MENU_ITEM,
|
|
249
|
+
meter: ATSPI_ROLE.LEVEL_BAR,
|
|
250
|
+
navigation: ATSPI_ROLE.LANDMARK,
|
|
251
|
+
note: ATSPI_ROLE.COMMENT,
|
|
252
|
+
option: ATSPI_ROLE.LIST_ITEM,
|
|
253
|
+
paragraph: ATSPI_ROLE.PARAGRAPH,
|
|
254
|
+
progressbar: ATSPI_ROLE.PROGRESS_BAR,
|
|
255
|
+
radio: ATSPI_ROLE.RADIO_BUTTON,
|
|
256
|
+
radiogroup: ATSPI_ROLE.PANEL,
|
|
257
|
+
region: ATSPI_ROLE.LANDMARK,
|
|
258
|
+
row: ATSPI_ROLE.TABLE_ROW,
|
|
259
|
+
rowheader: ATSPI_ROLE.ROW_HEADER,
|
|
260
|
+
scrollbar: ATSPI_ROLE.SCROLL_BAR,
|
|
261
|
+
search: ATSPI_ROLE.LANDMARK,
|
|
262
|
+
searchbox: ATSPI_ROLE.ENTRY,
|
|
263
|
+
separator: ATSPI_ROLE.SEPARATOR,
|
|
264
|
+
slider: ATSPI_ROLE.SLIDER,
|
|
265
|
+
spinbutton: ATSPI_ROLE.SPIN_BUTTON,
|
|
266
|
+
status: ATSPI_ROLE.STATUS_BAR,
|
|
267
|
+
switch: ATSPI_ROLE.SWITCH,
|
|
268
|
+
tab: ATSPI_ROLE.PAGE_TAB,
|
|
269
|
+
table: ATSPI_ROLE.TABLE,
|
|
270
|
+
tablist: ATSPI_ROLE.PAGE_TAB_LIST,
|
|
271
|
+
tabpanel: ATSPI_ROLE.PANEL,
|
|
272
|
+
term: ATSPI_ROLE.DESCRIPTION_TERM,
|
|
273
|
+
textbox: ATSPI_ROLE.ENTRY,
|
|
274
|
+
timer: ATSPI_ROLE.TIMER,
|
|
275
|
+
toolbar: ATSPI_ROLE.TOOL_BAR,
|
|
276
|
+
tooltip: ATSPI_ROLE.TOOL_TIP,
|
|
277
|
+
tree: ATSPI_ROLE.TREE,
|
|
278
|
+
treegrid: ATSPI_ROLE.TREE_TABLE,
|
|
279
|
+
treeitem: ATSPI_ROLE.TREE_ITEM,
|
|
280
|
+
window: ATSPI_ROLE.WINDOW,
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
/** The web role names this renderer understands. Exported for the DEV
|
|
285
|
+
* warning's message and for tests. */
|
|
286
|
+
export const KNOWN_ROLES = Object.freeze([
|
|
287
|
+
...ROLE_TO_ATSPI.keys(),
|
|
288
|
+
'none',
|
|
289
|
+
'presentation',
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* What an element is when nothing says otherwise. `<box>` is FILLER — the
|
|
294
|
+
* role GTK gives its own layout containers, which screen readers know to
|
|
295
|
+
* step over silently — so an unlabelled tree is *boring* to a screen reader
|
|
296
|
+
* rather than noisy. Everything with real semantics gets them here, so an
|
|
297
|
+
* app that never writes a `role` still reads: windows are frames, text is a
|
|
298
|
+
* label, an input is an entry.
|
|
299
|
+
*/
|
|
300
|
+
const KIND_ROLES = {
|
|
301
|
+
window: ATSPI_ROLE.FRAME,
|
|
302
|
+
popup: ATSPI_ROLE.WINDOW,
|
|
303
|
+
box: ATSPI_ROLE.FILLER,
|
|
304
|
+
text: ATSPI_ROLE.LABEL,
|
|
305
|
+
image: ATSPI_ROLE.IMAGE,
|
|
306
|
+
canvas: ATSPI_ROLE.DRAWING_AREA,
|
|
307
|
+
textinput: ATSPI_ROLE.ENTRY,
|
|
308
|
+
textarea: ATSPI_ROLE.ENTRY,
|
|
309
|
+
svg: ATSPI_ROLE.IMAGE,
|
|
310
|
+
glarea: ATSPI_ROLE.CANVAS,
|
|
311
|
+
foreign: ATSPI_ROLE.EMBEDDED,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/** The kind → web-role-name table `roleOf()` in the test queries also
|
|
315
|
+
* reads, kept here so the queries and the bridge cannot disagree. */
|
|
316
|
+
export const KIND_ROLE_NAMES = Object.freeze({
|
|
317
|
+
window: 'window',
|
|
318
|
+
popup: 'window',
|
|
319
|
+
text: 'text',
|
|
320
|
+
image: 'img',
|
|
321
|
+
canvas: 'canvas',
|
|
322
|
+
textinput: 'textbox',
|
|
323
|
+
textarea: 'textbox',
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Roles whose accessible name comes from their contents when no
|
|
328
|
+
* `aria-label` names them — ARIA's name-from-content set, which is how
|
|
329
|
+
* `<Button>OK</Button>` gets the name "OK" from the `<text>` inside it.
|
|
330
|
+
*/
|
|
331
|
+
const NAME_FROM_CONTENTS = new Set([
|
|
332
|
+
'button',
|
|
333
|
+
'cell',
|
|
334
|
+
'checkbox',
|
|
335
|
+
'columnheader',
|
|
336
|
+
'combobox',
|
|
337
|
+
'gridcell',
|
|
338
|
+
'heading',
|
|
339
|
+
'link',
|
|
340
|
+
'listitem',
|
|
341
|
+
'menuitem',
|
|
342
|
+
'menuitemcheckbox',
|
|
343
|
+
'menuitemradio',
|
|
344
|
+
'option',
|
|
345
|
+
'radio',
|
|
346
|
+
'row',
|
|
347
|
+
'rowheader',
|
|
348
|
+
'switch',
|
|
349
|
+
'tab',
|
|
350
|
+
'tooltip',
|
|
351
|
+
'treeitem',
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
/** Roles that promise activation, so the bridge exposes an AT-SPI action
|
|
355
|
+
* for them even when the click handler lives on an ancestor. */
|
|
356
|
+
const ACTIVATABLE_ROLES = new Set([
|
|
357
|
+
'button',
|
|
358
|
+
'checkbox',
|
|
359
|
+
'combobox',
|
|
360
|
+
'link',
|
|
361
|
+
'menuitem',
|
|
362
|
+
'menuitemcheckbox',
|
|
363
|
+
'menuitemradio',
|
|
364
|
+
'option',
|
|
365
|
+
'radio',
|
|
366
|
+
'switch',
|
|
367
|
+
'tab',
|
|
368
|
+
'treeitem',
|
|
369
|
+
]);
|
|
370
|
+
|
|
371
|
+
/** Kinds that are internal structure, never accessibility objects: chunks
|
|
372
|
+
* and spans are the *content* of their `<text>`, SVG children belong to
|
|
373
|
+
* their `<svg>`'s raster. */
|
|
374
|
+
const INTERNAL_KINDS = new Set(['textchunk', 'svgchild']);
|
|
375
|
+
|
|
376
|
+
// --------------------------------------------------------------------------
|
|
377
|
+
// The tree projection
|
|
378
|
+
// --------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* A scroll pane is now a *style* on an ordinary container rather than an
|
|
382
|
+
* element of its own, so the role comes off `overflow` where it used to come
|
|
383
|
+
* off the kind. Deliberately `isScroller()` and not "is there anything to
|
|
384
|
+
* scroll right now": the role is what the node *is*, and a pane flickering
|
|
385
|
+
* between `scroll pane` and `filler` as its content grows would be reported
|
|
386
|
+
* to a screen reader as the object being replaced.
|
|
387
|
+
*/
|
|
388
|
+
const scrolls = (node) => !node.isWindow && node.isScroller?.() === true;
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The web role name in force on a node, in the order the answers get less
|
|
392
|
+
* specific: what the application wrote, what the element says it is
|
|
393
|
+
* (`node.a11yRole` — a registered element's own default, see
|
|
394
|
+
* docs/extending.md), what it holds, whether it scrolls, and finally the
|
|
395
|
+
* kind's default name (only the kinds with real semantics have one).
|
|
396
|
+
*
|
|
397
|
+
* A registered element's declaration sits **above** the scroller rule on
|
|
398
|
+
* purpose: an editor that scrolls its own painted text is a text box that
|
|
399
|
+
* happens to scroll, and reading it as a scroll pane would lose the only
|
|
400
|
+
* part a screen reader cares about.
|
|
401
|
+
*/
|
|
402
|
+
export function roleNameOf(node) {
|
|
403
|
+
const role = node.props?.role;
|
|
404
|
+
if (typeof role === 'string' && role !== '') return role;
|
|
405
|
+
const own = node.a11yRole;
|
|
406
|
+
if (typeof own === 'string' && own !== '') return own;
|
|
407
|
+
const text = textRoleName(node);
|
|
408
|
+
if (text !== null) return text;
|
|
409
|
+
if (scrolls(node)) return 'scrollable';
|
|
410
|
+
return KIND_ROLE_NAMES[node.kind] ?? null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The AT-SPI role number for a node, by the same order. An unknown role
|
|
414
|
+
* string falls back to the next answer rather than to nothing — a typo
|
|
415
|
+
* should not turn a button into a filler silently, but it must not crash
|
|
416
|
+
* either; the DEV check below is what reports it. */
|
|
417
|
+
export function atspiRoleOf(node) {
|
|
418
|
+
for (const name of [node.props?.role, node.a11yRole, textRoleName(node)]) {
|
|
419
|
+
if (typeof name !== 'string') continue;
|
|
420
|
+
const mapped = ROLE_TO_ATSPI.get(name);
|
|
421
|
+
if (mapped !== undefined) return mapped;
|
|
422
|
+
}
|
|
423
|
+
if (scrolls(node)) return ATSPI_ROLE.SCROLL_PANE;
|
|
424
|
+
return KIND_ROLES[node.kind] ?? ATSPI_ROLE.UNKNOWN;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** `role="none"`/`"presentation"`: the node is layout, its children are
|
|
428
|
+
* not. It disappears from the accessible tree and its children take its
|
|
429
|
+
* place — ARIA's semantics, and what keeps a wrapper `<box>` from adding a
|
|
430
|
+
* level of "filler" around every widget that needs one for styling. */
|
|
431
|
+
export function a11yErased(node) {
|
|
432
|
+
const role = node.props?.role;
|
|
433
|
+
return role === 'none' || role === 'presentation';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Whole-subtree removal: `aria-hidden`, the internal content kinds, and
|
|
437
|
+
* `<text>` spans (the outer `<text>` speaks for the whole run). */
|
|
438
|
+
export function a11yPruned(node) {
|
|
439
|
+
if (node.props?.['aria-hidden'] === true) return true;
|
|
440
|
+
if (INTERNAL_KINDS.has(node.kind)) return true;
|
|
441
|
+
if (node.kind === 'text' && node.isSpan) return true;
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A node's children as assistive technology sees them: pruned subtrees
|
|
447
|
+
* gone, erased nodes replaced by their children, and — for an element that
|
|
448
|
+
* draws its own scene — the items it says are in it (see below).
|
|
449
|
+
* `<glarea>` children are 3D scene nodes with their own vocabulary and no
|
|
450
|
+
* rectangles — a glarea is a leaf up here.
|
|
451
|
+
*/
|
|
452
|
+
export function a11yChildren(node) {
|
|
453
|
+
if (node.kind === 'glarea') return [];
|
|
454
|
+
const out = [];
|
|
455
|
+
for (const child of node.children ?? []) accessibleInto(out, child);
|
|
456
|
+
// What an element drew comes after what it holds: both are its children,
|
|
457
|
+
// and only the retained ones have an order React gave them.
|
|
458
|
+
for (const item of a11ySceneItems(node)) accessibleInto(out, item);
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function accessibleInto(out, child) {
|
|
463
|
+
if (child.destroyed || a11yPruned(child)) return;
|
|
464
|
+
if (a11yErased(child)) out.push(...a11yChildren(child));
|
|
465
|
+
else out.push(child);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The accessible parent: the nearest ancestor that is itself in the
|
|
469
|
+
* accessible tree (skipping erased wrappers). Null for a toplevel window —
|
|
470
|
+
* the bridge parents those on the application. */
|
|
471
|
+
export function a11yParent(node) {
|
|
472
|
+
for (let p = node.parent; p; p = p.parent) {
|
|
473
|
+
if (!a11yErased(p) && !a11yPruned(p)) return p;
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Index of `node` among its accessible siblings, -1 when detached. */
|
|
479
|
+
export function a11yIndexIn(parent, node) {
|
|
480
|
+
return a11yChildren(parent).indexOf(node);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// --------------------------------------------------------------------------
|
|
484
|
+
// The scene an element draws (issue #304)
|
|
485
|
+
// --------------------------------------------------------------------------
|
|
486
|
+
//
|
|
487
|
+
// A registered element that draws N interactive things — a graph, a chart, a
|
|
488
|
+
// timeline, a seating plan — is one retained node, so it is one accessible:
|
|
489
|
+
// "Flow graph, group", and nothing inside it exists. The way out is the same
|
|
490
|
+
// one every canvas-scene toolkit ends up at (the web's fallback DOM, Qt's
|
|
491
|
+
// QAccessibleInterface children): the element *describes* what it drew, and
|
|
492
|
+
// those descriptions become accessible children.
|
|
493
|
+
//
|
|
494
|
+
// They are objects rather than plain descriptors because the bridge exports
|
|
495
|
+
// one accessible per child and keys it on the object — a list rebuilt every
|
|
496
|
+
// frame would otherwise be a tree that replaces itself between two reads,
|
|
497
|
+
// with every ref an AT is holding dead. `id` is what survives the frame;
|
|
498
|
+
// everything else is re-read from the element each time.
|
|
499
|
+
//
|
|
500
|
+
// Everything below is written so that a scene item is read by the same
|
|
501
|
+
// functions a `<box>` is read by: it carries `props` in the same `role` /
|
|
502
|
+
// `aria-*` vocabulary, an `abs` rect in the same window coordinates, and a
|
|
503
|
+
// `parent`. There is no second model here, only a second way of producing
|
|
504
|
+
// the one there is.
|
|
505
|
+
|
|
506
|
+
/** The shared empty list, so a node with no scene allocates nothing. */
|
|
507
|
+
const NO_ITEMS = Object.freeze([]);
|
|
508
|
+
|
|
509
|
+
/** The states a scene item declares, and the prop each is read as. The
|
|
510
|
+
* element writes `selected`; the model reads `aria-selected` — the same
|
|
511
|
+
* props the element itself would have carried, so states are derived by
|
|
512
|
+
* `a11yStates` rather than by a table of their own. */
|
|
513
|
+
const ITEM_STATE_PROPS = Object.entries({
|
|
514
|
+
selected: 'aria-selected',
|
|
515
|
+
checked: 'aria-checked',
|
|
516
|
+
expanded: 'aria-expanded',
|
|
517
|
+
busy: 'aria-busy',
|
|
518
|
+
disabled: 'disabled',
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const finite = (value) => (Number.isFinite(value) ? value : 0);
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* One thing an element drew, as an accessibility object. Data and identity
|
|
525
|
+
* only: what an assistive technology *does* to it is routed back to the
|
|
526
|
+
* element by the bridge, which is where every other AT-initiated behaviour
|
|
527
|
+
* lives.
|
|
528
|
+
*/
|
|
529
|
+
class SceneItem {
|
|
530
|
+
constructor(owner, parent, id) {
|
|
531
|
+
/** The element that drew this and the id it knows it by — the two
|
|
532
|
+
* halves of an action's address. */
|
|
533
|
+
this.a11yOwner = owner;
|
|
534
|
+
this.a11yId = id;
|
|
535
|
+
// The owner's element name on purpose: an item is part of that element,
|
|
536
|
+
// so a DEV warning about a role names the tag its author wrote rather
|
|
537
|
+
// than a word from in here. The role never falls through to the kind
|
|
538
|
+
// table anyway — `a11yRole` below is always set.
|
|
539
|
+
this.kind = owner.kind;
|
|
540
|
+
this.parent = parent;
|
|
541
|
+
this.children = NO_ITEMS;
|
|
542
|
+
this.props = {};
|
|
543
|
+
this.abs = { x: 0, y: 0, width: 0, height: 0 };
|
|
544
|
+
this.destroyed = false;
|
|
545
|
+
this.hidden = false;
|
|
546
|
+
/** What an item is when it names no role: audible, and promising
|
|
547
|
+
* nothing the element has not said. A real role — `listitem`,
|
|
548
|
+
* `button`, `treeitem` — is what makes it activatable. */
|
|
549
|
+
this.a11yRole = 'group';
|
|
550
|
+
/** The element's own keyboard cursor. It never reaches the window's
|
|
551
|
+
* focus manager, which is still holding the element itself. */
|
|
552
|
+
this.a11yFocused = false;
|
|
553
|
+
this.focusableByDefault = true;
|
|
554
|
+
/** id -> SceneItem for the nested scene, built only if there is one. */
|
|
555
|
+
this._a11ySceneCache = null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The owning window, for the states and the coordinate systems. */
|
|
559
|
+
get root() {
|
|
560
|
+
return this.a11yOwner.root;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
get isWindow() {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Re-read one frame's description. Same object, new facts. */
|
|
568
|
+
_read(desc) {
|
|
569
|
+
const props = { ...desc.props };
|
|
570
|
+
if (desc.role != null) props.role = desc.role;
|
|
571
|
+
if (desc.name != null) props['aria-label'] = String(desc.name);
|
|
572
|
+
if (desc.description != null) {
|
|
573
|
+
props['aria-description'] = String(desc.description);
|
|
574
|
+
}
|
|
575
|
+
const states = desc.states;
|
|
576
|
+
if (states) {
|
|
577
|
+
for (const [name, prop] of ITEM_STATE_PROPS) {
|
|
578
|
+
if (states[name] !== undefined) props[prop] = states[name];
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
this.props = props;
|
|
582
|
+
this.a11yFocused = states?.focused === true;
|
|
583
|
+
this.focusableByDefault = desc.focusable !== false;
|
|
584
|
+
const rect = desc.rect;
|
|
585
|
+
this.abs = {
|
|
586
|
+
x: finite(rect?.x),
|
|
587
|
+
y: finite(rect?.y),
|
|
588
|
+
width: finite(rect?.width),
|
|
589
|
+
height: finite(rect?.height),
|
|
590
|
+
};
|
|
591
|
+
const children = desc.children;
|
|
592
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
593
|
+
this._a11ySceneCache ??= new Map();
|
|
594
|
+
this.children = reconcileScene(
|
|
595
|
+
this.a11yOwner,
|
|
596
|
+
this,
|
|
597
|
+
children,
|
|
598
|
+
this._a11ySceneCache,
|
|
599
|
+
);
|
|
600
|
+
} else {
|
|
601
|
+
if (this._a11ySceneCache?.size > 0) dropScene(this._a11ySceneCache);
|
|
602
|
+
this.children = NO_ITEMS;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** An item that left the scene is defunct rather than forgotten: an AT may
|
|
608
|
+
* still be holding it, and the bridge has to walk the subtree it is losing
|
|
609
|
+
* to release the paths under it. */
|
|
610
|
+
function markGone(item) {
|
|
611
|
+
item.destroyed = true;
|
|
612
|
+
for (const child of item.children) markGone(child);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function dropScene(cache) {
|
|
616
|
+
for (const item of cache.values()) markGone(item);
|
|
617
|
+
cache.clear();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const badIds = new Set();
|
|
621
|
+
|
|
622
|
+
function devCheckItemId(owner, id, duplicate) {
|
|
623
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
624
|
+
const key = `${owner.kind}:${id}:${duplicate}`;
|
|
625
|
+
if (badIds.has(key)) return;
|
|
626
|
+
badIds.add(key);
|
|
627
|
+
console.warn(
|
|
628
|
+
duplicate
|
|
629
|
+
? `react-x11: <${owner.kind}> reported two accessible children with ` +
|
|
630
|
+
`the id "${id}" — ids address an item for the whole time it is on ` +
|
|
631
|
+
'screen, so the second one is dropped.'
|
|
632
|
+
: `react-x11: <${owner.kind}> reported an accessible child with no ` +
|
|
633
|
+
'id. An id is what keeps a child the same object across frames, ' +
|
|
634
|
+
'so this one is dropped.',
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Match one frame's descriptions against the objects the last frame left:
|
|
640
|
+
* an id that is still there keeps its object — and with it its D-Bus path,
|
|
641
|
+
* its snapshot and every ref an AT is holding — a new one gets one, and one
|
|
642
|
+
* that is gone is marked defunct and dropped.
|
|
643
|
+
*/
|
|
644
|
+
function reconcileScene(owner, parent, declared, cache) {
|
|
645
|
+
if (!Array.isArray(declared) || declared.length === 0) {
|
|
646
|
+
if (cache.size > 0) dropScene(cache);
|
|
647
|
+
return NO_ITEMS;
|
|
648
|
+
}
|
|
649
|
+
const items = [];
|
|
650
|
+
const seen = new Set();
|
|
651
|
+
for (const desc of declared) {
|
|
652
|
+
const id = desc == null ? '' : String(desc.id ?? '');
|
|
653
|
+
if (id === '' || seen.has(id)) {
|
|
654
|
+
devCheckItemId(owner, id, id !== '');
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
seen.add(id);
|
|
658
|
+
let item = cache.get(id);
|
|
659
|
+
if (!item) {
|
|
660
|
+
item = new SceneItem(owner, parent, id);
|
|
661
|
+
cache.set(id, item);
|
|
662
|
+
}
|
|
663
|
+
item._read(desc);
|
|
664
|
+
items.push(item);
|
|
665
|
+
}
|
|
666
|
+
for (const [id, item] of cache) {
|
|
667
|
+
if (seen.has(id)) continue;
|
|
668
|
+
markGone(item);
|
|
669
|
+
cache.delete(id);
|
|
670
|
+
}
|
|
671
|
+
return items;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* What an element says it drew, as accessibility objects with an identity
|
|
676
|
+
* that survives the frame. Empty — and free — for everything that draws no
|
|
677
|
+
* scene, which is every element core ships.
|
|
678
|
+
*
|
|
679
|
+
* Called once per question the bridge answers about the element's children,
|
|
680
|
+
* so `a11yScene()` has to be a cheap read of what the element already
|
|
681
|
+
* holds, exactly as `a11yTextState()` is.
|
|
682
|
+
*/
|
|
683
|
+
export function a11ySceneItems(node) {
|
|
684
|
+
if (typeof node.a11yScene !== 'function') return NO_ITEMS;
|
|
685
|
+
node._a11ySceneCache ??= new Map();
|
|
686
|
+
return reconcileScene(node, node, node.a11yScene(), node._a11ySceneCache);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** The accessible children of a node that are drawn rather than retained:
|
|
690
|
+
* an element's own scene, or a scene item's nested one. */
|
|
691
|
+
export function sceneChildrenOf(node) {
|
|
692
|
+
if (typeof node.a11yScene === 'function') return a11ySceneItems(node);
|
|
693
|
+
return node.a11yOwner ? node.children : NO_ITEMS;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** The scene items a node last reported, without asking it again — for
|
|
697
|
+
* teardown, where the element is going away and its answer is moot. */
|
|
698
|
+
export function knownSceneItems(node) {
|
|
699
|
+
if (typeof node.a11yScene !== 'function') return NO_ITEMS;
|
|
700
|
+
const cache = node._a11ySceneCache;
|
|
701
|
+
return cache && cache.size > 0 ? [...cache.values()] : NO_ITEMS;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Whether this object is one of an element's drawn children. */
|
|
705
|
+
export const isSceneItem = (node) => node instanceof SceneItem;
|
|
706
|
+
|
|
707
|
+
// --------------------------------------------------------------------------
|
|
708
|
+
// Name, description, states, value
|
|
709
|
+
// --------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
/** Visible text of a subtree, for name-from-contents: every chunk under the
|
|
712
|
+
* node, in tree order, hidden branches skipped. */
|
|
713
|
+
export function subtreeText(node) {
|
|
714
|
+
const parts = [];
|
|
715
|
+
const walk = (n) => {
|
|
716
|
+
if (n.props?.['aria-hidden'] === true || n.hidden) return;
|
|
717
|
+
if (n.kind === 'textchunk' && typeof n.text === 'string') {
|
|
718
|
+
parts.push(n.text);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
// do not read a nested control's own text as its parent's label — its
|
|
722
|
+
// value is its Text interface, not a name for whatever contains it
|
|
723
|
+
if (n !== node && textShapeOf(n) !== null) return;
|
|
724
|
+
for (const child of n.children ?? []) {
|
|
725
|
+
if (!child.isWindow) walk(child);
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
walk(node);
|
|
729
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The accessible name, by the ARIA precedence that fits what exists here:
|
|
734
|
+
* `aria-label` above everything; then what the element itself carries (a
|
|
735
|
+
* window's `title`, an image's `alt`, an input's `placeholder` as the
|
|
736
|
+
* fallback the web engines also use); then contents, for the roles whose
|
|
737
|
+
* contents are their label.
|
|
738
|
+
*/
|
|
739
|
+
export function a11yName(node) {
|
|
740
|
+
const label = node.props?.['aria-label'];
|
|
741
|
+
if (typeof label === 'string' && label !== '') return label;
|
|
742
|
+
if (node.isWindow) return node.props?.title ?? '';
|
|
743
|
+
if (node.kind === 'image' || node.kind === 'svg') {
|
|
744
|
+
return node.props?.alt ?? '';
|
|
745
|
+
}
|
|
746
|
+
if (node.kind === 'textinput' || node.kind === 'textarea') {
|
|
747
|
+
return node.props?.placeholder ?? '';
|
|
748
|
+
}
|
|
749
|
+
const role = roleNameOf(node);
|
|
750
|
+
if (role !== null && NAME_FROM_CONTENTS.has(role)) return subtreeText(node);
|
|
751
|
+
if (node.kind === 'text') return subtreeText(node);
|
|
752
|
+
return '';
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export function a11yDescription(node) {
|
|
756
|
+
const description = node.props?.['aria-description'];
|
|
757
|
+
return typeof description === 'string' ? description : '';
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Whether the event manager would let this node take focus — the same rule
|
|
762
|
+
* as `EventManager._isFocusable`, which now delegates here so the answer
|
|
763
|
+
* the keyboard gives and the answer the screen reader hears cannot drift.
|
|
764
|
+
*/
|
|
765
|
+
export function isFocusable(node) {
|
|
766
|
+
if (node.props.disabled) return false;
|
|
767
|
+
// A `selectable` surface is a focus target for the same reason a scroll
|
|
768
|
+
// pane is: the keys that operate it — Ctrl+A, Ctrl+C — have to arrive
|
|
769
|
+
// somewhere, and a document nobody can copy from with the keyboard is a
|
|
770
|
+
// WCAG 2.1.1 failure on the one feature it exists for. `tabIndex={-1}`
|
|
771
|
+
// keeps it clickable and takes it back out of the Tab cycle.
|
|
772
|
+
const byDefault =
|
|
773
|
+
(node.focusableByDefault ?? false) || node.props.selectable === true;
|
|
774
|
+
return (
|
|
775
|
+
node.props.focusable ?? (node.props.tabIndex != null ? true : byDefault)
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const bit = (states, state) => {
|
|
780
|
+
if (state < 32) states[0] |= 1 << state;
|
|
781
|
+
else states[1] |= 1 << (state - 32);
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
/** True when the node and every ancestor up to its window is neither
|
|
785
|
+
* `hidden` nor `display: 'none'`. Shared with the focus manager, which
|
|
786
|
+
* releases focus on exactly this answer turning false (src/events.js). */
|
|
787
|
+
export function effectivelyVisible(node) {
|
|
788
|
+
for (let n = node; n; n = n.parent) {
|
|
789
|
+
if (n.destroyed || n.hidden || n.style?.display === 'none') return false;
|
|
790
|
+
if (n.isWindow) break;
|
|
791
|
+
}
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* The AT-SPI state set, as the two uint32s the wire carries. Everything is
|
|
797
|
+
* derived — from the props, the kind, and the same live state the widgets
|
|
798
|
+
* draw from — so it cannot disagree with the screen.
|
|
799
|
+
*/
|
|
800
|
+
export function a11yStates(node) {
|
|
801
|
+
const states = [0, 0];
|
|
802
|
+
const props = node.props ?? {};
|
|
803
|
+
const S = ATSPI_STATE;
|
|
804
|
+
if (node.destroyed) {
|
|
805
|
+
bit(states, S.DEFUNCT);
|
|
806
|
+
return states;
|
|
807
|
+
}
|
|
808
|
+
const role = roleNameOf(node);
|
|
809
|
+
|
|
810
|
+
if (!props.disabled) {
|
|
811
|
+
bit(states, S.ENABLED);
|
|
812
|
+
bit(states, S.SENSITIVE);
|
|
813
|
+
}
|
|
814
|
+
if (isFocusable(node)) bit(states, S.FOCUSABLE);
|
|
815
|
+
// A scene item's focus is the element's own answer: a cursor inside a
|
|
816
|
+
// drawn scene never reaches the window's focus manager, which is still
|
|
817
|
+
// holding the element itself.
|
|
818
|
+
const focused = node.a11yFocused ?? node._focusManager?.()?.focused === node;
|
|
819
|
+
if (focused) bit(states, S.FOCUSED);
|
|
820
|
+
|
|
821
|
+
if (effectivelyVisible(node)) {
|
|
822
|
+
bit(states, S.VISIBLE);
|
|
823
|
+
// SHOWING additionally wants the pixels to be reachable: the owning
|
|
824
|
+
// window has to actually exist on the server
|
|
825
|
+
const owner = node.isWindow ? node : node.root;
|
|
826
|
+
if (owner?.window) bit(states, S.SHOWING);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const checked = props['aria-checked'];
|
|
830
|
+
if (
|
|
831
|
+
checked != null ||
|
|
832
|
+
role === 'checkbox' ||
|
|
833
|
+
role === 'radio' ||
|
|
834
|
+
role === 'switch' ||
|
|
835
|
+
role === 'menuitemcheckbox' ||
|
|
836
|
+
role === 'menuitemradio'
|
|
837
|
+
) {
|
|
838
|
+
bit(states, S.CHECKABLE);
|
|
839
|
+
}
|
|
840
|
+
if (checked === true || checked === 'true') bit(states, S.CHECKED);
|
|
841
|
+
if (checked === 'mixed') bit(states, S.INDETERMINATE);
|
|
842
|
+
|
|
843
|
+
const pressed = props['aria-pressed'];
|
|
844
|
+
if (pressed === true || pressed === 'true') bit(states, S.PRESSED);
|
|
845
|
+
|
|
846
|
+
const expanded = props['aria-expanded'];
|
|
847
|
+
if (expanded != null) {
|
|
848
|
+
bit(states, S.EXPANDABLE);
|
|
849
|
+
if (expanded === true || expanded === 'true') bit(states, S.EXPANDED);
|
|
850
|
+
else bit(states, S.COLLAPSED);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const selected = props['aria-selected'];
|
|
854
|
+
if (selected != null) bit(states, S.SELECTABLE);
|
|
855
|
+
if (selected === true || selected === 'true') bit(states, S.SELECTED);
|
|
856
|
+
|
|
857
|
+
if (props['aria-busy'] === true) bit(states, S.BUSY);
|
|
858
|
+
if (props['aria-required'] === true) bit(states, S.REQUIRED);
|
|
859
|
+
if (props['aria-haspopup']) bit(states, S.HAS_POPUP);
|
|
860
|
+
if (props['aria-modal'] === true || (role === 'dialog' && props.trapFocus)) {
|
|
861
|
+
bit(states, S.MODAL);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const orientation = props['aria-orientation'];
|
|
865
|
+
if (orientation === 'horizontal') bit(states, S.HORIZONTAL);
|
|
866
|
+
if (orientation === 'vertical') bit(states, S.VERTICAL);
|
|
867
|
+
|
|
868
|
+
const readOnly = props['aria-readonly'] === true;
|
|
869
|
+
if (readOnly) bit(states, S.READ_ONLY);
|
|
870
|
+
const text = textShapeOf(node);
|
|
871
|
+
if (text) {
|
|
872
|
+
// `multiline` is a claim about the element's shape, so a registered
|
|
873
|
+
// element that makes none gets neither state rather than a guess read
|
|
874
|
+
// off its current value — which would report the shape *changing* the
|
|
875
|
+
// first time somebody pressed Enter.
|
|
876
|
+
if (text.multiline === true) bit(states, S.MULTI_LINE);
|
|
877
|
+
else if (text.multiline === false) bit(states, S.SINGLE_LINE);
|
|
878
|
+
if (text.editable && !props.disabled && !readOnly) bit(states, S.EDITABLE);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (node.isWindow) {
|
|
882
|
+
if (node.props?.resizable !== false && !node.isPopup) {
|
|
883
|
+
bit(states, S.RESIZABLE);
|
|
884
|
+
}
|
|
885
|
+
if (node.events?.windowFocused && node.window) bit(states, S.ACTIVE);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return states;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* The AT-SPI value triple for anything that declares `aria-valuenow` —
|
|
893
|
+
* which is how a slider or progress bar built out of boxes gets a real
|
|
894
|
+
* Value interface. Null when the node has none.
|
|
895
|
+
*/
|
|
896
|
+
export function a11yValue(node) {
|
|
897
|
+
const now = node.props?.['aria-valuenow'];
|
|
898
|
+
if (typeof now !== 'number' || Number.isNaN(now)) return null;
|
|
899
|
+
const min = numberOr(node.props['aria-valuemin'], 0);
|
|
900
|
+
const max = numberOr(node.props['aria-valuemax'], 100);
|
|
901
|
+
const text = node.props['aria-valuetext'];
|
|
902
|
+
return {
|
|
903
|
+
now,
|
|
904
|
+
min,
|
|
905
|
+
max,
|
|
906
|
+
text: typeof text === 'string' ? text : '',
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function numberOr(value, fallback) {
|
|
911
|
+
return typeof value === 'number' && !Number.isNaN(value) ? value : fallback;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Object attributes (`Accessible.GetAttributes`): the ARIA leftovers that
|
|
915
|
+
* are neither role nor state. Orca reads `level` for "level 2", and
|
|
916
|
+
* `posinset`/`setsize` for "3 of 7". */
|
|
917
|
+
export function a11yAttributes(node) {
|
|
918
|
+
const attrs = [];
|
|
919
|
+
const props = node.props ?? {};
|
|
920
|
+
const role = props.role;
|
|
921
|
+
if (typeof role === 'string' && role !== '') attrs.push(['xml-roles', role]);
|
|
922
|
+
const level = props['aria-level'];
|
|
923
|
+
if (typeof level === 'number') attrs.push(['level', String(level)]);
|
|
924
|
+
const posinset = props['aria-posinset'];
|
|
925
|
+
if (typeof posinset === 'number') attrs.push(['posinset', String(posinset)]);
|
|
926
|
+
const setsize = props['aria-setsize'];
|
|
927
|
+
if (typeof setsize === 'number') attrs.push(['setsize', String(setsize)]);
|
|
928
|
+
const shortcuts = props['aria-keyshortcuts'];
|
|
929
|
+
if (typeof shortcuts === 'string' && shortcuts !== '') {
|
|
930
|
+
// the core-aam mapping ARIA gives it; Orca announces it with the item
|
|
931
|
+
attrs.push(['keyshortcuts', shortcuts]);
|
|
932
|
+
}
|
|
933
|
+
return attrs;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* **The one thing "activatable" means**: there is a click here to make.
|
|
938
|
+
*
|
|
939
|
+
* Three input routes stand on this — the pointer, an AT's `DoAction`, and
|
|
940
|
+
* the keyboard's Space/Enter (`Node.defaultKeyDown`, nodes.js) — and they
|
|
941
|
+
* all dispatch the same click, so a control cannot answer one of them and
|
|
942
|
+
* not another. It lives here, beside the bridge's rule, because the bridge
|
|
943
|
+
* is the layer that already had to write the rule down.
|
|
944
|
+
*/
|
|
945
|
+
export const hasClickHandler = (node) =>
|
|
946
|
+
typeof node.props?.onClick === 'function';
|
|
947
|
+
|
|
948
|
+
/** Whether the bridge should offer an "activate" action: an explicit
|
|
949
|
+
* handler, or a role that promises one (widgets often keep the handler on
|
|
950
|
+
* an ancestor of the role-carrying node — activation is dispatched through
|
|
951
|
+
* the tree, so the promise still holds).
|
|
952
|
+
*
|
|
953
|
+
* The role half is an AT's alone: it *advertises* an action to something
|
|
954
|
+
* that has no other way to ask for one, and the click it dispatches is
|
|
955
|
+
* answered by whichever ancestor holds the handler. The keyboard needs no
|
|
956
|
+
* advertisement — a key press is already at the node — so it takes the
|
|
957
|
+
* first clause and stops there (`hasClickHandler`). */
|
|
958
|
+
export function a11yActivatable(node) {
|
|
959
|
+
if (hasClickHandler(node)) return true;
|
|
960
|
+
// A drawn item whose element answers actions is activatable whatever it
|
|
961
|
+
// is called: implementing the seam is the same promise a handler is, and
|
|
962
|
+
// the roles a scene reaches for — `listitem` for a graph node — are
|
|
963
|
+
// mostly ones ARIA does not make that promise about.
|
|
964
|
+
if (typeof node.a11yOwner?.a11ySceneAction === 'function') return true;
|
|
965
|
+
const role = roleNameOf(node);
|
|
966
|
+
return role !== null && ACTIVATABLE_ROLES.has(role);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// --------------------------------------------------------------------------
|
|
970
|
+
// Text state — shared by the AT-SPI bridge's Text interface and the test
|
|
971
|
+
// spy, because both answer the same question: what would an assistive
|
|
972
|
+
// technology read out of this control right now?
|
|
973
|
+
// --------------------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
/** The built-in editable controls, which report through their own
|
|
976
|
+
* internals rather than through the seam below. */
|
|
977
|
+
export const isNativeTextControl = (node) =>
|
|
978
|
+
node.kind === 'textinput' || node.kind === 'textarea';
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* A registered element's own answer to "what text am I holding, and where
|
|
982
|
+
* is the caret" (issue #257), normalized into the shape the bridge and the
|
|
983
|
+
* spy already speak — or null for an element that holds none.
|
|
984
|
+
*
|
|
985
|
+
* The element writes `{ value, caret, selectionStart, selectionEnd,
|
|
986
|
+
* editable, multiline, preedit }` and every field but `value` is optional;
|
|
987
|
+
* offsets are code points (`Array.from`), clamped here so a stale caret an
|
|
988
|
+
* element hands over cannot become an out-of-range answer on the wire.
|
|
989
|
+
* Everything downstream — character counts, word and line granularity,
|
|
990
|
+
* attribute runs, the text-changed diff — is then the same code that
|
|
991
|
+
* serves `<textinput>`, which is the point: a third-party editor is
|
|
992
|
+
* readable by the same paths, not by a parallel set of them.
|
|
993
|
+
*
|
|
994
|
+
* This is called several times per change (role, states, the diff), so an
|
|
995
|
+
* element's implementation has to be a cheap read of what it already
|
|
996
|
+
* holds — never a shaping pass or a copy of its buffer.
|
|
997
|
+
*/
|
|
998
|
+
export function customTextState(node) {
|
|
999
|
+
if (typeof node.a11yTextState !== 'function') return null;
|
|
1000
|
+
const state = node.a11yTextState();
|
|
1001
|
+
if (state == null) return null;
|
|
1002
|
+
const chars = Array.from(String(state.value ?? ''));
|
|
1003
|
+
const at = (value, fallback) => {
|
|
1004
|
+
const index = Math.trunc(Number(value));
|
|
1005
|
+
return Number.isFinite(index)
|
|
1006
|
+
? Math.max(0, Math.min(index, chars.length))
|
|
1007
|
+
: fallback;
|
|
1008
|
+
};
|
|
1009
|
+
const start = at(state.selectionStart, 0);
|
|
1010
|
+
const end = at(state.selectionEnd, start);
|
|
1011
|
+
const composing = state.preedit ? String(state.preedit.text ?? '') : '';
|
|
1012
|
+
return {
|
|
1013
|
+
chars,
|
|
1014
|
+
caret: at(state.caret, end),
|
|
1015
|
+
selection: [Math.min(start, end), Math.max(start, end)],
|
|
1016
|
+
preedit: composing
|
|
1017
|
+
? {
|
|
1018
|
+
offset: at(state.preedit.offset, 0),
|
|
1019
|
+
length: Array.from(composing).length,
|
|
1020
|
+
text: composing,
|
|
1021
|
+
}
|
|
1022
|
+
: null,
|
|
1023
|
+
editable: state.editable === true,
|
|
1024
|
+
multiline: typeof state.multiline === 'boolean' ? state.multiline : null,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* What kind of text an element holds — whether it is editable, and whether
|
|
1030
|
+
* it is one line or many (`null` for an element that has not said). Null
|
|
1031
|
+
* when it holds none.
|
|
1032
|
+
*/
|
|
1033
|
+
export function textShapeOf(node) {
|
|
1034
|
+
if (isNativeTextControl(node)) {
|
|
1035
|
+
return { editable: true, multiline: node.kind === 'textarea' };
|
|
1036
|
+
}
|
|
1037
|
+
const custom = customTextState(node);
|
|
1038
|
+
if (!custom) return null;
|
|
1039
|
+
return { editable: custom.editable, multiline: custom.multiline };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** The role an element reporting text falls back to when it declares
|
|
1043
|
+
* none: an entry when an assistive technology may type into it, a
|
|
1044
|
+
* document when it may only read. */
|
|
1045
|
+
function textRoleName(node) {
|
|
1046
|
+
const shape = textShapeOf(node);
|
|
1047
|
+
if (!shape) return null;
|
|
1048
|
+
return shape.editable ? 'textbox' : 'document';
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Whether the value is editable text with a caret — what the EDITABLE
|
|
1052
|
+
* state and the AT-SPI EditableText interface are about. */
|
|
1053
|
+
export const isTextControl = (node) => textShapeOf(node)?.editable === true;
|
|
1054
|
+
|
|
1055
|
+
/** Everything that exposes the AT-SPI Text interface at all — the editable
|
|
1056
|
+
* controls, `<text>` labels (readable but caret-less), and any registered
|
|
1057
|
+
* element reporting a text state, editable or not. The read-only tier is
|
|
1058
|
+
* what makes a document viewer with a selection speak: markdown, a code
|
|
1059
|
+
* block, a terminal. */
|
|
1060
|
+
export const hasTextInterface = (node) =>
|
|
1061
|
+
isNativeTextControl(node) ||
|
|
1062
|
+
node.kind === 'text' ||
|
|
1063
|
+
customTextState(node) !== null;
|
|
1064
|
+
|
|
1065
|
+
/** Whether an assistive technology can write to this element's text: the
|
|
1066
|
+
* built-ins always can, a registered element when it implements the write
|
|
1067
|
+
* half of the seam. */
|
|
1068
|
+
export const acceptsTextEdits = (node) =>
|
|
1069
|
+
isNativeTextControl(node) || typeof node.a11yReplaceText === 'function';
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* The code points and caret/selection of anything with a Text interface —
|
|
1073
|
+
* offsets in AT-SPI are code points, which is also exactly what
|
|
1074
|
+
* TextInputNode's `_chars()`/`_caret` already count.
|
|
1075
|
+
*
|
|
1076
|
+
* The string is the one the control **draws**, an open composition
|
|
1077
|
+
* included, and the offsets are indices into it. That is not a detail of
|
|
1078
|
+
* wording: every geometric answer the Text interface gives — the extents of
|
|
1079
|
+
* character *n*, the offset under a point, where the caret is — is read off
|
|
1080
|
+
* the layout of the displayed string (`_valueLayout`), so reporting the
|
|
1081
|
+
* committed value while a preedit is showing would put a magnifier's
|
|
1082
|
+
* highlight and a braille cursor a preedit's width to the left of the
|
|
1083
|
+
* glyphs they are tracking.
|
|
1084
|
+
*
|
|
1085
|
+
* `preedit` is which part of it is uncommitted — `{ offset, length, text }`
|
|
1086
|
+
* in the same code-point space, or null. It is what lets a reader tell a
|
|
1087
|
+
* composition from the character it commits: the bridge marks the
|
|
1088
|
+
* composition's own churn `:system` and leaves the commit a plain insert
|
|
1089
|
+
* (see `_diffText` in atspi.js).
|
|
1090
|
+
*/
|
|
1091
|
+
export function textStateOf(node) {
|
|
1092
|
+
if (isNativeTextControl(node)) {
|
|
1093
|
+
const shown = node._displayValue ? node._displayValue() : null;
|
|
1094
|
+
const toDisplay = (index) => node._displayIndex?.(index) ?? index;
|
|
1095
|
+
const [start, end] = node._selection?.() ?? [0, 0];
|
|
1096
|
+
const preedit = node._preedit ?? '';
|
|
1097
|
+
return {
|
|
1098
|
+
chars: shown === null ? node._chars() : Array.from(shown),
|
|
1099
|
+
caret: toDisplay(node._caret ?? 0),
|
|
1100
|
+
selection: [toDisplay(start), toDisplay(end)],
|
|
1101
|
+
preedit: preedit
|
|
1102
|
+
? {
|
|
1103
|
+
offset: node._preeditStart?.() ?? 0,
|
|
1104
|
+
length: Array.from(preedit).length,
|
|
1105
|
+
text: preedit,
|
|
1106
|
+
}
|
|
1107
|
+
: null,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
const custom = customTextState(node);
|
|
1111
|
+
if (custom) return custom;
|
|
1112
|
+
const spans = node.collectSpans?.([]) ?? [];
|
|
1113
|
+
const chars = Array.from(spans.map((s) => s.text).join(''));
|
|
1114
|
+
// A label has no caret, but it can have a **selection**: a `<text>` inside
|
|
1115
|
+
// a `selectable` surface is part of a document the user drags across
|
|
1116
|
+
// (#259), and that range is the one thing about a label that moves at
|
|
1117
|
+
// runtime. Reporting it is what puts a magnifier's highlight and a braille
|
|
1118
|
+
// display's cursor on the same characters the highlight is painted over.
|
|
1119
|
+
const range = node.selectionRange;
|
|
1120
|
+
const selection = range ? [range.start, range.end] : [0, 0];
|
|
1121
|
+
return { chars, caret: selection[1], selection, preedit: null };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Whether a `[offset, offset + length)` code-point range lies inside the
|
|
1126
|
+
* composition of a `textStateOf` state — the question that separates a
|
|
1127
|
+
* preedit's own churn from an edit the user made.
|
|
1128
|
+
*/
|
|
1129
|
+
export function inPreedit(state, offset, length) {
|
|
1130
|
+
const pre = state?.preedit;
|
|
1131
|
+
if (!pre) return false;
|
|
1132
|
+
return offset >= pre.offset && offset + length <= pre.offset + pre.length;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Common-prefix/suffix diff of two code-point arrays →
|
|
1136
|
+
* `{ offset, removed, inserted }`, the exact shape AT-SPI text-changed
|
|
1137
|
+
* events want. Null when equal. */
|
|
1138
|
+
export function diffChars(before, after) {
|
|
1139
|
+
const bn = before.length;
|
|
1140
|
+
const an = after.length;
|
|
1141
|
+
let prefix = 0;
|
|
1142
|
+
const max = Math.min(bn, an);
|
|
1143
|
+
while (prefix < max && before[prefix] === after[prefix]) prefix++;
|
|
1144
|
+
let suffix = 0;
|
|
1145
|
+
while (
|
|
1146
|
+
suffix < max - prefix &&
|
|
1147
|
+
before[bn - 1 - suffix] === after[an - 1 - suffix]
|
|
1148
|
+
) {
|
|
1149
|
+
suffix++;
|
|
1150
|
+
}
|
|
1151
|
+
if (prefix === bn && prefix === an) return null;
|
|
1152
|
+
return {
|
|
1153
|
+
offset: prefix,
|
|
1154
|
+
removed: before.slice(prefix, bn - suffix),
|
|
1155
|
+
inserted: after.slice(prefix, an - suffix),
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// --------------------------------------------------------------------------
|
|
1160
|
+
// DEV validation
|
|
1161
|
+
// --------------------------------------------------------------------------
|
|
1162
|
+
|
|
1163
|
+
const warned = new Set();
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Report an unknown `role` once per name, in development only. Unknown
|
|
1167
|
+
* `aria-*` props are deliberately not swept: the set grows with ARIA and a
|
|
1168
|
+
* warning list that lags the spec teaches people to stop trusting it.
|
|
1169
|
+
*/
|
|
1170
|
+
export function devCheckA11yProps(node) {
|
|
1171
|
+
// the element's own default is checked too: a registered element that
|
|
1172
|
+
// misspells its `a11yRole` is silently the kind default forever, and it
|
|
1173
|
+
// is written once in a constructor rather than visibly at a call site
|
|
1174
|
+
for (const role of [node.props?.role, node.a11yRole]) {
|
|
1175
|
+
if (role == null) continue;
|
|
1176
|
+
if (typeof role !== 'string' || ROLE_TO_ATSPI.has(role)) continue;
|
|
1177
|
+
if (role === 'none' || role === 'presentation') continue;
|
|
1178
|
+
if (warned.has(role)) continue;
|
|
1179
|
+
warned.add(role);
|
|
1180
|
+
console.warn(
|
|
1181
|
+
`react-x11: unknown role "${role}" on <${node.kind}> — it will read ` +
|
|
1182
|
+
`as the element default. Known roles: ${KNOWN_ROLES.join(', ')}.`,
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// --------------------------------------------------------------------------
|
|
1188
|
+
// The hook slots, and turning the bridge on
|
|
1189
|
+
// --------------------------------------------------------------------------
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Slots the renderer's hot paths poll, all null until the AT-SPI bridge is
|
|
1193
|
+
* live — the cost of accessibility being off is one property read per
|
|
1194
|
+
* event, same design as trace-registry. atspi.js fills them; nothing else
|
|
1195
|
+
* may.
|
|
1196
|
+
*
|
|
1197
|
+
* - `rootMounted(windowNode)` / `rootUnmounted(windowNode)` — a toplevel
|
|
1198
|
+
* `<window>` entered or left a container.
|
|
1199
|
+
* - `attached(parent, child)` — a node joined a live tree (also popups).
|
|
1200
|
+
* - `detach(parent, child)` — about to leave; called while still wired.
|
|
1201
|
+
* - `propsChanged(node)` — after applyProps landed new props, and through
|
|
1202
|
+
* `Node.notifyA11ySceneChanged()` when what an element drew moved
|
|
1203
|
+
* without any prop doing so. Both mean the same thing to a listener:
|
|
1204
|
+
* re-read this node's accessible facts and announce the difference.
|
|
1205
|
+
* - `textContent(node)` — a text chunk's string changed.
|
|
1206
|
+
* - `textState(node)` — a text control's value/caret/selection may have
|
|
1207
|
+
* moved (funnelled through `_repaint`, which every edit path calls, and
|
|
1208
|
+
* through `Node.notifyA11yTextChanged()` for a registered element).
|
|
1209
|
+
* - `focus(previous, next)` — the owning manager moved focus.
|
|
1210
|
+
* - `windowFocus(windowNode, focused)` — the WM moved input focus.
|
|
1211
|
+
* - `commit()` — a React commit finished (resetAfterCommit).
|
|
1212
|
+
* - `announce(text, opts)` — announce() below, when a bridge is live.
|
|
1213
|
+
*/
|
|
1214
|
+
export const hooks = {
|
|
1215
|
+
rootMounted: null,
|
|
1216
|
+
rootUnmounted: null,
|
|
1217
|
+
attached: null,
|
|
1218
|
+
detach: null,
|
|
1219
|
+
propsChanged: null,
|
|
1220
|
+
textContent: null,
|
|
1221
|
+
textState: null,
|
|
1222
|
+
focus: null,
|
|
1223
|
+
windowFocus: null,
|
|
1224
|
+
commit: null,
|
|
1225
|
+
announce: null,
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
let startPromise = null;
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Whether to climb toward the accessibility bus at all, resolved from two
|
|
1232
|
+
* environment variables:
|
|
1233
|
+
*
|
|
1234
|
+
* - `REACT_X11_A11Y` — this renderer's own switch. `0` is off; any other
|
|
1235
|
+
* value is an explicit "on" that also makes the climb loud, and wins
|
|
1236
|
+
* over `NO_AT_BRIDGE`.
|
|
1237
|
+
* - `NO_AT_BRIDGE` — the ecosystem's switch, honoured because it already
|
|
1238
|
+
* means "do not register with AT-SPI" to every toolkit (at-spi2-atk
|
|
1239
|
+
* checks it, GTK's own test suite sets it). react-x11's test harness
|
|
1240
|
+
* sets it too, so a test run on a desktop does not parade phantom
|
|
1241
|
+
* applications through the live screen reader.
|
|
1242
|
+
*
|
|
1243
|
+
* An explicit `AT_SPI_BUS_ADDRESS` is intent — whoever set it wants a
|
|
1244
|
+
* bridge on that bus — so it overrides `NO_AT_BRIDGE`; the hermetic bridge
|
|
1245
|
+
* tests are exactly this case.
|
|
1246
|
+
*/
|
|
1247
|
+
function a11yEnabled() {
|
|
1248
|
+
const own = process.env.REACT_X11_A11Y;
|
|
1249
|
+
if (own === '0') return false;
|
|
1250
|
+
if (own) return true;
|
|
1251
|
+
if (process.env.AT_SPI_BUS_ADDRESS) return true;
|
|
1252
|
+
const noBridge = process.env.NO_AT_BRIDGE;
|
|
1253
|
+
if (noBridge && noBridge !== '0') return false;
|
|
1254
|
+
return true;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Kick off the AT-SPI bridge, once per process. Called from `createRoot()`
|
|
1259
|
+
* and deliberately not awaited there: the ladder this climbs — transport
|
|
1260
|
+
* installed? session bus? accessibility bus? — ends in "no" on most
|
|
1261
|
+
* machines this renderer targets (ssh, CI, macOS/Windows X servers, bare
|
|
1262
|
+
* startx), and every "no" is a normal, silent outcome that must cost the
|
|
1263
|
+
* app nothing. See docs/accessibility.md for the ladder.
|
|
1264
|
+
*
|
|
1265
|
+
* `REACT_X11_A11Y=0` (or the ecosystem's `NO_AT_BRIDGE=1`) skips even the
|
|
1266
|
+
* import; `REACT_X11_A11Y=1` makes the climb *loud* — each rung says why
|
|
1267
|
+
* it stopped — which is the debugging story for "why does Orca not see my
|
|
1268
|
+
* app".
|
|
1269
|
+
*/
|
|
1270
|
+
export function startA11y() {
|
|
1271
|
+
if (startPromise) return startPromise;
|
|
1272
|
+
if (!a11yEnabled()) {
|
|
1273
|
+
startPromise = Promise.resolve(null);
|
|
1274
|
+
return startPromise;
|
|
1275
|
+
}
|
|
1276
|
+
startPromise = import('./atspi.js')
|
|
1277
|
+
.then((atspi) => atspi.start())
|
|
1278
|
+
.catch((err) => {
|
|
1279
|
+
if (process.env.REACT_X11_A11Y) {
|
|
1280
|
+
console.warn('react-x11: accessibility bridge failed to start:', err);
|
|
1281
|
+
}
|
|
1282
|
+
return null;
|
|
1283
|
+
});
|
|
1284
|
+
return startPromise;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Say something through the screen reader without moving focus — the
|
|
1289
|
+
* explicit counterpart of an ARIA live region ("saved", "3 results",
|
|
1290
|
+
* "connection lost"). Returns true when a bridge was there to carry it;
|
|
1291
|
+
* false means nobody is listening (no accessibility bus, or it has not
|
|
1292
|
+
* connected yet) and the app may want a visible fallback.
|
|
1293
|
+
*
|
|
1294
|
+
* announce('Form saved');
|
|
1295
|
+
* announce('Connection lost', { assertive: true });
|
|
1296
|
+
*/
|
|
1297
|
+
export function announce(text, opts) {
|
|
1298
|
+
return hooks.announce?.(String(text), opts) ?? false;
|
|
1299
|
+
}
|