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.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. 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
+ }