@olimsaidov/icdp 0.1.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/LICENSE +21 -0
- package/README.md +104 -0
- package/dist/frame/ax-tree.mjs +2186 -0
- package/dist/frame/index.d.mts +17 -0
- package/dist/frame/index.mjs +782 -0
- package/dist/host/index.d.mts +99 -0
- package/dist/host/index.mjs +328 -0
- package/dist/protocol.d.mts +102 -0
- package/dist/protocol.mjs +16 -0
- package/dist/relay/core.d.mts +54 -0
- package/dist/relay/core.mjs +411 -0
- package/dist/relay/node.d.mts +24 -0
- package/dist/relay/node.mjs +99 -0
- package/package.json +77 -0
- package/src/frame/ax-tree.ts +2393 -0
- package/src/frame/index.ts +1048 -0
- package/src/host/index.ts +422 -0
- package/src/protocol.ts +125 -0
- package/src/relay/core.ts +499 -0
- package/src/relay/node.ts +135 -0
|
@@ -0,0 +1,2393 @@
|
|
|
1
|
+
import type { ARIAProperty } from "aria-query";
|
|
2
|
+
import { roles as ariaRoles } from "aria-query";
|
|
3
|
+
import type Protocol from "devtools-protocol";
|
|
4
|
+
import { computeAccessibleDescription, getRole } from "dom-accessibility-api";
|
|
5
|
+
|
|
6
|
+
type AXValue = Protocol.Accessibility.AXValue;
|
|
7
|
+
type AXValueSource = Protocol.Accessibility.AXValueSource;
|
|
8
|
+
type AXProperty = Protocol.Accessibility.AXProperty;
|
|
9
|
+
type AXRelatedNode = Protocol.Accessibility.AXRelatedNode;
|
|
10
|
+
type AXPropertyName = Protocol.Accessibility.AXPropertyName;
|
|
11
|
+
type AXValueType = Protocol.Accessibility.AXValueType;
|
|
12
|
+
// `chromeRole` is in the CDP spec but may lag in the devtools-protocol package.
|
|
13
|
+
type AXNode = Protocol.Accessibility.AXNode & { chromeRole?: AXValue };
|
|
14
|
+
|
|
15
|
+
export type DomRegistry = {
|
|
16
|
+
backendIdFor(node: Node): Protocol.DOM.BackendNodeId;
|
|
17
|
+
nodeForBackendId(id: Protocol.DOM.BackendNodeId): Node | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type AXTreeOptions = {
|
|
21
|
+
document: Document;
|
|
22
|
+
frameId: Protocol.Page.FrameId;
|
|
23
|
+
registry: DomRegistry;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ax::mojom::Role ordinals from ui/accessibility/ax_enums.mojom (explicit, stable values).
|
|
27
|
+
const MOJOM_ROLE_ORDINALS: Record<string, number> = {
|
|
28
|
+
None: 0,
|
|
29
|
+
Abbr: 1,
|
|
30
|
+
Alert: 2,
|
|
31
|
+
AlertDialog: 3,
|
|
32
|
+
Application: 4,
|
|
33
|
+
Article: 5,
|
|
34
|
+
Audio: 6,
|
|
35
|
+
Banner: 7,
|
|
36
|
+
Blockquote: 8,
|
|
37
|
+
Button: 9,
|
|
38
|
+
Canvas: 10,
|
|
39
|
+
Caption: 11,
|
|
40
|
+
Caret: 12,
|
|
41
|
+
Cell: 13,
|
|
42
|
+
CheckBox: 14,
|
|
43
|
+
Client: 15,
|
|
44
|
+
Code: 16,
|
|
45
|
+
ColorWell: 17,
|
|
46
|
+
Column: 18,
|
|
47
|
+
ColumnHeader: 19,
|
|
48
|
+
ComboBoxGrouping: 20,
|
|
49
|
+
ComboBoxMenuButton: 21,
|
|
50
|
+
Complementary: 22,
|
|
51
|
+
Comment: 23,
|
|
52
|
+
ContentDeletion: 24,
|
|
53
|
+
ContentInsertion: 25,
|
|
54
|
+
ContentInfo: 26,
|
|
55
|
+
Date: 27,
|
|
56
|
+
DateTime: 28,
|
|
57
|
+
Definition: 29,
|
|
58
|
+
DescriptionList: 30,
|
|
59
|
+
DescriptionListDetailDeprecated: 31,
|
|
60
|
+
DescriptionListTermDeprecated: 32,
|
|
61
|
+
Desktop: 33,
|
|
62
|
+
Details: 34,
|
|
63
|
+
Dialog: 35,
|
|
64
|
+
DirectoryDeprecated: 36,
|
|
65
|
+
DisclosureTriangle: 37,
|
|
66
|
+
DocAbstract: 38,
|
|
67
|
+
DocAcknowledgments: 39,
|
|
68
|
+
DocAfterword: 40,
|
|
69
|
+
DocAppendix: 41,
|
|
70
|
+
DocBackLink: 42,
|
|
71
|
+
DocBiblioEntry: 43,
|
|
72
|
+
DocBibliography: 44,
|
|
73
|
+
DocBiblioRef: 45,
|
|
74
|
+
DocChapter: 46,
|
|
75
|
+
DocColophon: 47,
|
|
76
|
+
DocConclusion: 48,
|
|
77
|
+
DocCover: 49,
|
|
78
|
+
DocCredit: 50,
|
|
79
|
+
DocCredits: 51,
|
|
80
|
+
DocDedication: 52,
|
|
81
|
+
DocEndnote: 53,
|
|
82
|
+
DocEndnotes: 54,
|
|
83
|
+
DocEpigraph: 55,
|
|
84
|
+
DocEpilogue: 56,
|
|
85
|
+
DocErrata: 57,
|
|
86
|
+
DocExample: 58,
|
|
87
|
+
DocFootnote: 59,
|
|
88
|
+
DocForeword: 60,
|
|
89
|
+
DocGlossary: 61,
|
|
90
|
+
DocGlossRef: 62,
|
|
91
|
+
DocIndex: 63,
|
|
92
|
+
DocIntroduction: 64,
|
|
93
|
+
DocNoteRef: 65,
|
|
94
|
+
DocNotice: 66,
|
|
95
|
+
DocPageBreak: 67,
|
|
96
|
+
DocPageFooter: 68,
|
|
97
|
+
DocPageHeader: 69,
|
|
98
|
+
DocPageList: 70,
|
|
99
|
+
DocPart: 71,
|
|
100
|
+
DocPreface: 72,
|
|
101
|
+
DocPrologue: 73,
|
|
102
|
+
DocPullquote: 74,
|
|
103
|
+
DocQna: 75,
|
|
104
|
+
DocSubtitle: 76,
|
|
105
|
+
DocTip: 77,
|
|
106
|
+
DocToc: 78,
|
|
107
|
+
Document: 79,
|
|
108
|
+
EmbeddedObject: 80,
|
|
109
|
+
Emphasis: 81,
|
|
110
|
+
Feed: 82,
|
|
111
|
+
Figcaption: 83,
|
|
112
|
+
Figure: 84,
|
|
113
|
+
Footer: 85,
|
|
114
|
+
SectionFooter: 86,
|
|
115
|
+
Form: 87,
|
|
116
|
+
GenericContainer: 88,
|
|
117
|
+
GraphicsDocument: 89,
|
|
118
|
+
GraphicsObject: 90,
|
|
119
|
+
GraphicsSymbol: 91,
|
|
120
|
+
Grid: 92,
|
|
121
|
+
Group: 93,
|
|
122
|
+
Header: 94,
|
|
123
|
+
SectionHeader: 95,
|
|
124
|
+
Heading: 96,
|
|
125
|
+
Iframe: 97,
|
|
126
|
+
IframePresentational: 98,
|
|
127
|
+
Image: 99,
|
|
128
|
+
ImeCandidate: 100,
|
|
129
|
+
InlineTextBox: 101,
|
|
130
|
+
InputTime: 102,
|
|
131
|
+
Keyboard: 103,
|
|
132
|
+
LabelText: 104,
|
|
133
|
+
LayoutTable: 105,
|
|
134
|
+
LayoutTableCell: 106,
|
|
135
|
+
LayoutTableRow: 107,
|
|
136
|
+
Legend: 108,
|
|
137
|
+
LineBreak: 109,
|
|
138
|
+
Link: 110,
|
|
139
|
+
List: 111,
|
|
140
|
+
ListBox: 112,
|
|
141
|
+
ListBoxOption: 113,
|
|
142
|
+
ListGrid: 114,
|
|
143
|
+
ListItem: 115,
|
|
144
|
+
ListMarker: 116,
|
|
145
|
+
Log: 117,
|
|
146
|
+
Main: 118,
|
|
147
|
+
Mark: 119,
|
|
148
|
+
Marquee: 120,
|
|
149
|
+
Math: 121,
|
|
150
|
+
Menu: 122,
|
|
151
|
+
MenuBar: 123,
|
|
152
|
+
MenuItem: 124,
|
|
153
|
+
MenuItemCheckBox: 125,
|
|
154
|
+
MenuItemRadio: 126,
|
|
155
|
+
MenuListOption: 127,
|
|
156
|
+
MenuListPopup: 128,
|
|
157
|
+
Meter: 129,
|
|
158
|
+
Navigation: 130,
|
|
159
|
+
Note: 131,
|
|
160
|
+
Pane: 132,
|
|
161
|
+
Paragraph: 133,
|
|
162
|
+
PdfActionableHighlight: 134,
|
|
163
|
+
PdfRoot: 135,
|
|
164
|
+
PluginObject: 136,
|
|
165
|
+
PopUpButton: 137,
|
|
166
|
+
PortalDeprecated: 138,
|
|
167
|
+
PreDeprecated: 139,
|
|
168
|
+
ProgressIndicator: 140,
|
|
169
|
+
RadioButton: 141,
|
|
170
|
+
RadioGroup: 142,
|
|
171
|
+
Region: 143,
|
|
172
|
+
RootWebArea: 144,
|
|
173
|
+
Row: 145,
|
|
174
|
+
RowGroup: 146,
|
|
175
|
+
RowHeader: 147,
|
|
176
|
+
Ruby: 148,
|
|
177
|
+
RubyAnnotation: 149,
|
|
178
|
+
ScrollBar: 150,
|
|
179
|
+
ScrollView: 151,
|
|
180
|
+
Search: 152,
|
|
181
|
+
SearchBox: 153,
|
|
182
|
+
Section: 154,
|
|
183
|
+
Slider: 155,
|
|
184
|
+
SpinButton: 156,
|
|
185
|
+
Splitter: 157,
|
|
186
|
+
StaticText: 158,
|
|
187
|
+
Status: 159,
|
|
188
|
+
Strong: 160,
|
|
189
|
+
Suggestion: 161,
|
|
190
|
+
SvgRoot: 162,
|
|
191
|
+
Switch: 163,
|
|
192
|
+
Tab: 164,
|
|
193
|
+
TabList: 165,
|
|
194
|
+
TabPanel: 166,
|
|
195
|
+
Table: 167,
|
|
196
|
+
TableHeaderContainer: 168,
|
|
197
|
+
Term: 169,
|
|
198
|
+
TextField: 170,
|
|
199
|
+
TextFieldWithComboBox: 171,
|
|
200
|
+
Time: 172,
|
|
201
|
+
Timer: 173,
|
|
202
|
+
TitleBar: 174,
|
|
203
|
+
ToggleButton: 175,
|
|
204
|
+
Toolbar: 176,
|
|
205
|
+
Tooltip: 177,
|
|
206
|
+
Tree: 178,
|
|
207
|
+
TreeGrid: 179,
|
|
208
|
+
TreeItem: 180,
|
|
209
|
+
Unknown: 181,
|
|
210
|
+
Video: 182,
|
|
211
|
+
WebView: 183,
|
|
212
|
+
Window: 184,
|
|
213
|
+
Subscript: 185,
|
|
214
|
+
Superscript: 186,
|
|
215
|
+
MathMLMath: 187,
|
|
216
|
+
MathMLFraction: 188,
|
|
217
|
+
MathMLIdentifier: 189,
|
|
218
|
+
MathMLMultiscripts: 190,
|
|
219
|
+
MathMLNoneScript: 191,
|
|
220
|
+
MathMLNumber: 192,
|
|
221
|
+
MathMLOperator: 193,
|
|
222
|
+
MathMLOver: 194,
|
|
223
|
+
MathMLPrescriptDelimiter: 195,
|
|
224
|
+
MathMLRoot: 196,
|
|
225
|
+
MathMLRow: 197,
|
|
226
|
+
MathMLSquareRoot: 198,
|
|
227
|
+
MathMLStringLiteral: 199,
|
|
228
|
+
MathMLSub: 200,
|
|
229
|
+
MathMLSubSup: 201,
|
|
230
|
+
MathMLSup: 202,
|
|
231
|
+
MathMLTable: 203,
|
|
232
|
+
MathMLTableCell: 204,
|
|
233
|
+
MathMLTableRow: 205,
|
|
234
|
+
MathMLText: 206,
|
|
235
|
+
MathMLUnder: 207,
|
|
236
|
+
MathMLUnderOver: 208,
|
|
237
|
+
ComboBoxSelect: 209,
|
|
238
|
+
DisclosureTriangleGrouped: 210,
|
|
239
|
+
SectionWithoutName: 211,
|
|
240
|
+
GridCell: 212,
|
|
241
|
+
MenuItemSeparator: 213,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// ARIA role name -> Blink internal role (first internalRoles entry in aria_properties.json5).
|
|
245
|
+
const ARIA_TO_MOJOM: Record<string, string> = {
|
|
246
|
+
alert: "Alert",
|
|
247
|
+
alertdialog: "AlertDialog",
|
|
248
|
+
application: "Application",
|
|
249
|
+
article: "Article",
|
|
250
|
+
banner: "Banner",
|
|
251
|
+
blockquote: "Blockquote",
|
|
252
|
+
button: "Button",
|
|
253
|
+
caption: "Caption",
|
|
254
|
+
cell: "Cell",
|
|
255
|
+
checkbox: "CheckBox",
|
|
256
|
+
code: "Code",
|
|
257
|
+
columnheader: "ColumnHeader",
|
|
258
|
+
combobox: "ComboBoxGrouping",
|
|
259
|
+
comment: "Comment",
|
|
260
|
+
complementary: "Complementary",
|
|
261
|
+
contentinfo: "ContentInfo",
|
|
262
|
+
definition: "Definition",
|
|
263
|
+
deletion: "ContentDeletion",
|
|
264
|
+
dialog: "Dialog",
|
|
265
|
+
directory: "List",
|
|
266
|
+
"doc-abstract": "DocAbstract",
|
|
267
|
+
"doc-acknowledgments": "DocAcknowledgments",
|
|
268
|
+
"doc-afterword": "DocAfterword",
|
|
269
|
+
"doc-appendix": "DocAppendix",
|
|
270
|
+
"doc-backlink": "DocBackLink",
|
|
271
|
+
"doc-biblioentry": "DocBiblioEntry",
|
|
272
|
+
"doc-bibliography": "DocBibliography",
|
|
273
|
+
"doc-biblioref": "DocBiblioRef",
|
|
274
|
+
"doc-chapter": "DocChapter",
|
|
275
|
+
"doc-colophon": "DocColophon",
|
|
276
|
+
"doc-conclusion": "DocConclusion",
|
|
277
|
+
"doc-cover": "DocCover",
|
|
278
|
+
"doc-credit": "DocCredit",
|
|
279
|
+
"doc-credits": "DocCredits",
|
|
280
|
+
"doc-dedication": "DocDedication",
|
|
281
|
+
"doc-endnote": "DocEndnote",
|
|
282
|
+
"doc-endnotes": "DocEndnotes",
|
|
283
|
+
"doc-epigraph": "DocEpigraph",
|
|
284
|
+
"doc-epilogue": "DocEpilogue",
|
|
285
|
+
"doc-errata": "DocErrata",
|
|
286
|
+
"doc-example": "DocExample",
|
|
287
|
+
"doc-footnote": "DocFootnote",
|
|
288
|
+
"doc-foreword": "DocForeword",
|
|
289
|
+
"doc-glossary": "DocGlossary",
|
|
290
|
+
"doc-glossref": "DocGlossRef",
|
|
291
|
+
"doc-index": "DocIndex",
|
|
292
|
+
"doc-introduction": "DocIntroduction",
|
|
293
|
+
"doc-noteref": "DocNoteRef",
|
|
294
|
+
"doc-notice": "DocNotice",
|
|
295
|
+
"doc-pagebreak": "DocPageBreak",
|
|
296
|
+
"doc-pagefooter": "DocPageFooter",
|
|
297
|
+
"doc-pageheader": "DocPageHeader",
|
|
298
|
+
"doc-pagelist": "DocPageList",
|
|
299
|
+
"doc-part": "DocPart",
|
|
300
|
+
"doc-preface": "DocPreface",
|
|
301
|
+
"doc-prologue": "DocPrologue",
|
|
302
|
+
"doc-pullquote": "DocPullquote",
|
|
303
|
+
"doc-qna": "DocQna",
|
|
304
|
+
"doc-subtitle": "DocSubtitle",
|
|
305
|
+
"doc-tip": "DocTip",
|
|
306
|
+
"doc-toc": "DocToc",
|
|
307
|
+
document: "Document",
|
|
308
|
+
emphasis: "Emphasis",
|
|
309
|
+
feed: "Feed",
|
|
310
|
+
figure: "Figure",
|
|
311
|
+
form: "GenericContainer",
|
|
312
|
+
"graphics-document": "GraphicsDocument",
|
|
313
|
+
"graphics-object": "GraphicsObject",
|
|
314
|
+
"graphics-symbol": "GraphicsSymbol",
|
|
315
|
+
grid: "Grid",
|
|
316
|
+
gridcell: "GridCell",
|
|
317
|
+
group: "Group",
|
|
318
|
+
heading: "Heading",
|
|
319
|
+
image: "Image",
|
|
320
|
+
img: "Image",
|
|
321
|
+
insertion: "ContentInsertion",
|
|
322
|
+
link: "Link",
|
|
323
|
+
list: "List",
|
|
324
|
+
listbox: "ListBox",
|
|
325
|
+
listitem: "ListItem",
|
|
326
|
+
log: "Log",
|
|
327
|
+
main: "Main",
|
|
328
|
+
mark: "Mark",
|
|
329
|
+
marquee: "Marquee",
|
|
330
|
+
math: "Math",
|
|
331
|
+
menu: "Menu",
|
|
332
|
+
menubar: "MenuBar",
|
|
333
|
+
menuitem: "MenuItem",
|
|
334
|
+
menuitemcheckbox: "MenuItemCheckBox",
|
|
335
|
+
menuitemradio: "MenuItemRadio",
|
|
336
|
+
meter: "Meter",
|
|
337
|
+
navigation: "Navigation",
|
|
338
|
+
none: "None",
|
|
339
|
+
note: "Note",
|
|
340
|
+
option: "ListBoxOption",
|
|
341
|
+
paragraph: "Paragraph",
|
|
342
|
+
presentation: "None",
|
|
343
|
+
progressbar: "ProgressIndicator",
|
|
344
|
+
radio: "RadioButton",
|
|
345
|
+
radiogroup: "RadioGroup",
|
|
346
|
+
region: "Region",
|
|
347
|
+
row: "Row",
|
|
348
|
+
rowgroup: "RowGroup",
|
|
349
|
+
rowheader: "RowHeader",
|
|
350
|
+
scrollbar: "ScrollBar",
|
|
351
|
+
search: "Search",
|
|
352
|
+
searchbox: "SearchBox",
|
|
353
|
+
section: "Section",
|
|
354
|
+
sectionfooter: "SectionFooter",
|
|
355
|
+
sectionheader: "SectionHeader",
|
|
356
|
+
separator: "Splitter",
|
|
357
|
+
slider: "Slider",
|
|
358
|
+
spinbutton: "SpinButton",
|
|
359
|
+
status: "Status",
|
|
360
|
+
strong: "Strong",
|
|
361
|
+
subscript: "Subscript",
|
|
362
|
+
suggestion: "Suggestion",
|
|
363
|
+
superscript: "Superscript",
|
|
364
|
+
switch: "Switch",
|
|
365
|
+
tab: "Tab",
|
|
366
|
+
table: "Table",
|
|
367
|
+
tablist: "TabList",
|
|
368
|
+
tabpanel: "TabPanel",
|
|
369
|
+
term: "Term",
|
|
370
|
+
textbox: "TextField",
|
|
371
|
+
time: "Time",
|
|
372
|
+
timer: "Timer",
|
|
373
|
+
toolbar: "Toolbar",
|
|
374
|
+
tooltip: "Tooltip",
|
|
375
|
+
tree: "Tree",
|
|
376
|
+
treegrid: "TreeGrid",
|
|
377
|
+
treeitem: "TreeItem",
|
|
378
|
+
window: "Window",
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// Roles whose name is prohibited (nameFrom: ["prohibited"] in aria_properties.json5):
|
|
382
|
+
// only the aria-labelledby / aria-label candidates are considered, and no others.
|
|
383
|
+
const NAME_PROHIBITED_ROLES = new Set([
|
|
384
|
+
"caption",
|
|
385
|
+
"code",
|
|
386
|
+
"definition",
|
|
387
|
+
"deletion",
|
|
388
|
+
"emphasis",
|
|
389
|
+
"insertion",
|
|
390
|
+
"mark",
|
|
391
|
+
"none",
|
|
392
|
+
"paragraph",
|
|
393
|
+
"strong",
|
|
394
|
+
"subscript",
|
|
395
|
+
"suggestion",
|
|
396
|
+
"superscript",
|
|
397
|
+
"term",
|
|
398
|
+
"time",
|
|
399
|
+
"generic",
|
|
400
|
+
]);
|
|
401
|
+
|
|
402
|
+
const nodeToAXId = new WeakMap<Node, string>();
|
|
403
|
+
let nextAXId = 1;
|
|
404
|
+
|
|
405
|
+
export function createDomRegistry(): DomRegistry {
|
|
406
|
+
const nodeToBackendId = new WeakMap<Node, Protocol.DOM.BackendNodeId>();
|
|
407
|
+
const backendIdToNode = new Map<Protocol.DOM.BackendNodeId, Node>();
|
|
408
|
+
let nextBackendId = 1;
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
backendIdFor(node) {
|
|
412
|
+
const existing = nodeToBackendId.get(node);
|
|
413
|
+
if (existing) return existing;
|
|
414
|
+
const id = nextBackendId++;
|
|
415
|
+
nodeToBackendId.set(node, id);
|
|
416
|
+
backendIdToNode.set(id, node);
|
|
417
|
+
return id;
|
|
418
|
+
},
|
|
419
|
+
nodeForBackendId(id) {
|
|
420
|
+
return backendIdToNode.get(id);
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function ax(type: AXValueType, value?: unknown): AXValue {
|
|
426
|
+
return value === undefined ? { type: "valueUndefined" } : { type, value };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function axIdFor(node: Node): string {
|
|
430
|
+
const existing = nodeToAXId.get(node);
|
|
431
|
+
if (existing) return existing;
|
|
432
|
+
const id = String(nextAXId++);
|
|
433
|
+
nodeToAXId.set(node, id);
|
|
434
|
+
return id;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isElement(node: Node): node is Element {
|
|
438
|
+
return node.nodeType === Node.ELEMENT_NODE;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isText(node: Node): node is Text {
|
|
442
|
+
return node.nodeType === Node.TEXT_NODE;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function normalizeText(value: string | null | undefined): string {
|
|
446
|
+
return (value || "").replace(/\s+/g, " ").trim();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// Roles
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
// Wire roles serialized with AXValue type "internalRole" (no ARIA equivalent;
|
|
454
|
+
// Chromium's AXObject::RoleName is_internal flag).
|
|
455
|
+
const INTERNAL_WIRE_ROLES = new Set([
|
|
456
|
+
"StaticText",
|
|
457
|
+
"RootWebArea",
|
|
458
|
+
"ListMarker",
|
|
459
|
+
"DisclosureTriangle",
|
|
460
|
+
"LabelText",
|
|
461
|
+
"Iframe",
|
|
462
|
+
"Canvas",
|
|
463
|
+
"MenuListPopup",
|
|
464
|
+
"MathMLMath",
|
|
465
|
+
"MathMLIdentifier",
|
|
466
|
+
"MathMLOperator",
|
|
467
|
+
"MathMLNumber",
|
|
468
|
+
]);
|
|
469
|
+
|
|
470
|
+
function roleNameValue(role: string): AXValue {
|
|
471
|
+
return ax(INTERNAL_WIRE_ROLES.has(role) ? "internalRole" : "role", role);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function chromeRoleValue(mojomName: string): AXValue {
|
|
475
|
+
return ax("internalRole", MOJOM_ROLE_ORDINALS[mojomName] ?? 0);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function explicitRole(el: Element): string | null {
|
|
479
|
+
const attr = el.getAttribute("role");
|
|
480
|
+
if (!attr) return null;
|
|
481
|
+
for (const token of attr.trim().split(/\s+/)) {
|
|
482
|
+
// ARIA 1.2 introduced "image" as a synonym for "img" (aria-query only has img).
|
|
483
|
+
const role = token === "image" ? "img" : token;
|
|
484
|
+
if (ariaRoles.has(role as never)) return role;
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
type RoleInfo = {
|
|
490
|
+
wire: string | null; // null → no semantic role (generic/none decision elsewhere)
|
|
491
|
+
mojom: string; // Blink-internal role name for chromeRole
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const TEXT_ENTRY_INPUTS = new Set(["", "text", "email", "url", "tel", "password", "search"]);
|
|
495
|
+
|
|
496
|
+
function inputRole(el: HTMLInputElement): RoleInfo {
|
|
497
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
498
|
+
if (type === "checkbox") return { wire: "checkbox", mojom: "CheckBox" };
|
|
499
|
+
if (type === "radio") return { wire: "radio", mojom: "RadioButton" };
|
|
500
|
+
if (type === "range") return { wire: "slider", mojom: "Slider" };
|
|
501
|
+
if (type === "number") return { wire: "spinbutton", mojom: "SpinButton" };
|
|
502
|
+
if (type === "search") return { wire: "searchbox", mojom: "SearchBox" };
|
|
503
|
+
if (type === "color") return { wire: "ColorWell", mojom: "ColorWell" };
|
|
504
|
+
if (type === "date") return { wire: "Date", mojom: "Date" };
|
|
505
|
+
if (["datetime-local", "month", "week"].includes(type))
|
|
506
|
+
return { wire: "DateTime", mojom: "DateTime" };
|
|
507
|
+
if (type === "time") return { wire: "InputTime", mojom: "InputTime" };
|
|
508
|
+
if (["button", "submit", "reset", "image"].includes(type))
|
|
509
|
+
return { wire: "button", mojom: "Button" };
|
|
510
|
+
return { wire: "textbox", mojom: "TextField" };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** The element's native (non-ARIA) role, Chromium-aligned. */
|
|
514
|
+
function nativeRole(el: Element): RoleInfo {
|
|
515
|
+
const tag = el.localName;
|
|
516
|
+
const none: RoleInfo = { wire: null, mojom: "GenericContainer" };
|
|
517
|
+
if (tag === "html" || tag === "body") return none;
|
|
518
|
+
if (tag === "math") return { wire: "MathMLMath", mojom: "MathMLMath" };
|
|
519
|
+
if (tag === "mi") return { wire: "MathMLIdentifier", mojom: "MathMLMath" };
|
|
520
|
+
if (tag === "mo") return { wire: "MathMLOperator", mojom: "MathMLMath" };
|
|
521
|
+
if (tag === "mn") return { wire: "MathMLNumber", mojom: "MathMLMath" };
|
|
522
|
+
if (tag === "summary")
|
|
523
|
+
return el.parentElement?.localName === "details"
|
|
524
|
+
? { wire: "DisclosureTriangle", mojom: "DisclosureTriangle" }
|
|
525
|
+
: none;
|
|
526
|
+
if (tag === "label") return { wire: "LabelText", mojom: "LabelText" };
|
|
527
|
+
if (tag === "iframe" || tag === "frame") return { wire: "Iframe", mojom: "Iframe" };
|
|
528
|
+
if (tag === "svg") return { wire: "image", mojom: "Image" };
|
|
529
|
+
if (tag === "img") {
|
|
530
|
+
// alt="" is presentational unless a global attribute keeps it interesting.
|
|
531
|
+
if (
|
|
532
|
+
el.getAttribute("alt") === "" &&
|
|
533
|
+
!el.hasAttribute("title") &&
|
|
534
|
+
!el.hasAttribute("aria-label") &&
|
|
535
|
+
!el.hasAttribute("aria-labelledby")
|
|
536
|
+
)
|
|
537
|
+
return none;
|
|
538
|
+
return { wire: "image", mojom: "Image" };
|
|
539
|
+
}
|
|
540
|
+
if (tag === "button") return { wire: "button", mojom: "Button" };
|
|
541
|
+
if (tag === "a" && el.hasAttribute("href")) return { wire: "link", mojom: "Link" };
|
|
542
|
+
if (tag === "textarea") return { wire: "textbox", mojom: "TextField" };
|
|
543
|
+
if (el instanceof HTMLInputElement) return inputRole(el);
|
|
544
|
+
if (tag === "select")
|
|
545
|
+
return el.hasAttribute("multiple")
|
|
546
|
+
? { wire: "listbox", mojom: "ListBox" }
|
|
547
|
+
: { wire: "combobox", mojom: "ComboBoxSelect" };
|
|
548
|
+
if (tag === "option") return { wire: "option", mojom: "MenuListOption" };
|
|
549
|
+
if (tag === "optgroup") return { wire: "group", mojom: "Group" };
|
|
550
|
+
if (tag === "p") return { wire: "paragraph", mojom: "Paragraph" };
|
|
551
|
+
if (/^h[1-6]$/.test(tag)) return { wire: "heading", mojom: "Heading" };
|
|
552
|
+
if (tag === "ul" || tag === "ol") return { wire: "list", mojom: "List" };
|
|
553
|
+
if (tag === "li") return { wire: "listitem", mojom: "ListItem" };
|
|
554
|
+
if (tag === "table") return { wire: "table", mojom: "Table" };
|
|
555
|
+
if (tag === "thead" || tag === "tfoot") return { wire: "rowgroup", mojom: "RowGroup" };
|
|
556
|
+
if (tag === "tr") return { wire: "row", mojom: "Row" };
|
|
557
|
+
if (tag === "td") return { wire: "cell", mojom: "Cell" };
|
|
558
|
+
if (tag === "th") return { wire: "columnheader", mojom: "ColumnHeader" };
|
|
559
|
+
if (tag === "nav") return { wire: "navigation", mojom: "Navigation" };
|
|
560
|
+
if (tag === "main") return { wire: "main", mojom: "Main" };
|
|
561
|
+
if (tag === "dialog") return { wire: "dialog", mojom: "Dialog" };
|
|
562
|
+
if (tag === "canvas") return { wire: "Canvas", mojom: "Canvas" };
|
|
563
|
+
const computed = getRole(el);
|
|
564
|
+
if (computed && computed !== "generic" && ariaRoles.has(computed as never))
|
|
565
|
+
return { wire: computed, mojom: ARIA_TO_MOJOM[computed] ?? "Unknown" };
|
|
566
|
+
return none;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function roleInfoOf(el: Element): RoleInfo {
|
|
570
|
+
const override = (el as { __agentAX?: { role?: unknown } }).__agentAX?.role;
|
|
571
|
+
if (typeof override === "string" && override)
|
|
572
|
+
return { wire: override, mojom: ARIA_TO_MOJOM[override] ?? "Unknown" };
|
|
573
|
+
const explicit = explicitRole(el);
|
|
574
|
+
if (explicit && explicit !== "none" && explicit !== "presentation") {
|
|
575
|
+
const wire = explicit === "img" ? "image" : explicit;
|
|
576
|
+
return { wire, mojom: ARIA_TO_MOJOM[explicit] ?? "Unknown" };
|
|
577
|
+
}
|
|
578
|
+
return nativeRole(el);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
// Visibility / classification helpers
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
const SKIP_TAGS = new Set([
|
|
586
|
+
"head",
|
|
587
|
+
"style",
|
|
588
|
+
"script",
|
|
589
|
+
"noscript",
|
|
590
|
+
"template",
|
|
591
|
+
"meta",
|
|
592
|
+
"link",
|
|
593
|
+
"base",
|
|
594
|
+
"title",
|
|
595
|
+
"datalist",
|
|
596
|
+
"param",
|
|
597
|
+
"track",
|
|
598
|
+
"source",
|
|
599
|
+
"col",
|
|
600
|
+
"colgroup",
|
|
601
|
+
"br",
|
|
602
|
+
]);
|
|
603
|
+
|
|
604
|
+
function isSkipped(el: Element): boolean {
|
|
605
|
+
if (SKIP_TAGS.has(el.localName)) return true;
|
|
606
|
+
if (el instanceof HTMLInputElement && el.type === "hidden") return true;
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function isUnrendered(el: Element): boolean {
|
|
611
|
+
if ((el as HTMLElement).hidden) return true;
|
|
612
|
+
if (el.localName === "dialog" && !el.hasAttribute("open")) return true;
|
|
613
|
+
// children of a closed <details> (other than its summary) are not rendered
|
|
614
|
+
const parent = el.parentElement;
|
|
615
|
+
if (parent?.localName === "details" && !parent.hasAttribute("open") && el.localName !== "summary")
|
|
616
|
+
return true;
|
|
617
|
+
return getComputedStyle(el).display === "none";
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function isInvisible(el: Element): boolean {
|
|
621
|
+
const visibility = getComputedStyle(el).visibility;
|
|
622
|
+
return visibility === "hidden" || visibility === "collapse";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function isElHiddenForText(el: Element): boolean {
|
|
626
|
+
return (
|
|
627
|
+
(el as HTMLElement).hidden ||
|
|
628
|
+
el.getAttribute("aria-hidden") === "true" ||
|
|
629
|
+
getComputedStyle(el).display === "none" ||
|
|
630
|
+
isInvisible(el)
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function hasHiddenAncestorOrSelf(el: Element): boolean {
|
|
635
|
+
for (let cur: Element | null = el; cur; cur = cur.parentElement)
|
|
636
|
+
if (isElHiddenForText(cur)) return true;
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function isInlineLevel(el: Element): boolean {
|
|
641
|
+
const display = getComputedStyle(el).display;
|
|
642
|
+
if (display) return display.startsWith("inline") || display === "contents";
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function contentEditable(el: Element): boolean {
|
|
647
|
+
const value = el.getAttribute("contenteditable");
|
|
648
|
+
return value === "" || value === "true" || value === "plaintext-only";
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function nativeDisabled(el: Element): boolean {
|
|
652
|
+
return "disabled" in el && Boolean((el as { disabled?: boolean }).disabled);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function isFocusable(el: Element): boolean {
|
|
656
|
+
if (nativeDisabled(el)) return false; // aria-disabled does not remove focusability
|
|
657
|
+
const html = el as HTMLElement;
|
|
658
|
+
if (html.tabIndex >= 0) return true;
|
|
659
|
+
if (el.localName === "a" && el.hasAttribute("href")) return true;
|
|
660
|
+
if (el.localName === "dialog" && el.hasAttribute("open")) return true;
|
|
661
|
+
if (contentEditable(el)) return true;
|
|
662
|
+
return ["button", "input", "select", "textarea", "option"].includes(el.localName);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// An explicit role=none/presentation makes the element presentational unless it
|
|
666
|
+
// is focusable or carries a global aria-* label (conditional presentation).
|
|
667
|
+
function isPresentational(el: Element): boolean {
|
|
668
|
+
const attr = el.getAttribute("role");
|
|
669
|
+
if (!attr) return false;
|
|
670
|
+
let presentational = false;
|
|
671
|
+
for (const token of attr.trim().split(/\s+/)) {
|
|
672
|
+
if (token === "none" || token === "presentation") {
|
|
673
|
+
presentational = true;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
if (ariaRoles.has((token === "image" ? "img" : token) as never)) return false;
|
|
677
|
+
}
|
|
678
|
+
if (!presentational) return false;
|
|
679
|
+
if (el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby")) return false;
|
|
680
|
+
return !isFocusable(el);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Required-owned children inherit their parent's presentational role
|
|
684
|
+
// (a list's items, a table's rows/sections, a row's cells) — nothing else does.
|
|
685
|
+
const REQUIRED_OWNED: Record<string, string[]> = {
|
|
686
|
+
ul: ["li"],
|
|
687
|
+
ol: ["li"],
|
|
688
|
+
menu: ["li"],
|
|
689
|
+
table: ["caption", "thead", "tbody", "tfoot", "tr"],
|
|
690
|
+
thead: ["tr"],
|
|
691
|
+
tbody: ["tr"],
|
|
692
|
+
tfoot: ["tr"],
|
|
693
|
+
tr: ["td", "th"],
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
function inheritsPresentation(parent: Element, child: Element): boolean {
|
|
697
|
+
return REQUIRED_OWNED[parent.localName]?.includes(child.localName) ?? false;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function ignoredReason(name: AXPropertyName): AXProperty {
|
|
701
|
+
return { name, value: ax("boolean", true) };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** An ignored reason carrying a relatedNodes idref to the offending element
|
|
705
|
+
* (Chromium's CreateRelatedNodeListValue). */
|
|
706
|
+
function relatedReason(name: AXPropertyName, related: Element, registry: DomRegistry): AXProperty {
|
|
707
|
+
const node: AXRelatedNode = { backendDOMNodeId: registry.backendIdFor(related) };
|
|
708
|
+
const id = related.getAttribute("id");
|
|
709
|
+
if (id) node.idref = id;
|
|
710
|
+
return { name, value: { type: "idref", relatedNodes: [node] } };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Dialogs opened via showModal(): jsdom's selector engine cannot answer
|
|
714
|
+
// :modal, so track the calls directly (harmless in real browsers, where the
|
|
715
|
+
// :modal check below already works).
|
|
716
|
+
const modalDialogs = new WeakSet<Element>();
|
|
717
|
+
(() => {
|
|
718
|
+
const proto = (
|
|
719
|
+
globalThis as unknown as { HTMLDialogElement?: { prototype: Record<string, unknown> } }
|
|
720
|
+
).HTMLDialogElement?.prototype;
|
|
721
|
+
if (!proto) return;
|
|
722
|
+
const showModal = proto.showModal as ((...args: unknown[]) => unknown) | undefined;
|
|
723
|
+
if (showModal && (showModal as { __axPatched?: boolean }).__axPatched) return;
|
|
724
|
+
proto.showModal = function (this: Element, ...args: unknown[]) {
|
|
725
|
+
modalDialogs.add(this);
|
|
726
|
+
if (showModal) return showModal.apply(this, args);
|
|
727
|
+
this.setAttribute("open", ""); // jsdom lacks showModal entirely
|
|
728
|
+
return undefined;
|
|
729
|
+
};
|
|
730
|
+
(proto.showModal as { __axPatched?: boolean }).__axPatched = true;
|
|
731
|
+
const close = proto.close as ((...args: unknown[]) => unknown) | undefined;
|
|
732
|
+
proto.close = function (this: Element, ...args: unknown[]) {
|
|
733
|
+
modalDialogs.delete(this);
|
|
734
|
+
if (close) return close.apply(this, args);
|
|
735
|
+
this.removeAttribute("open");
|
|
736
|
+
return undefined;
|
|
737
|
+
};
|
|
738
|
+
})();
|
|
739
|
+
|
|
740
|
+
/** The open modal dialog blocking the rest of the document, if any. */
|
|
741
|
+
function openModalDialog(doc: Document): Element | null {
|
|
742
|
+
for (const dialog of Array.from(doc.querySelectorAll("dialog[open]"))) {
|
|
743
|
+
try {
|
|
744
|
+
if (dialog.matches(":modal")) return dialog;
|
|
745
|
+
} catch {
|
|
746
|
+
// selector engine without :modal — fall through to the tracked set
|
|
747
|
+
}
|
|
748
|
+
if (modalDialogs.has(dialog)) return dialog;
|
|
749
|
+
}
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** ids referenced by any label/description relation — hidden elements so
|
|
754
|
+
* referenced stay in the AX tree (Blink's IsUsedForLabelOrDescription). */
|
|
755
|
+
function labelReferencedIds(doc: Document): Set<string> {
|
|
756
|
+
const ids = new Set<string>();
|
|
757
|
+
const attrs = ["aria-labelledby", "aria-labeledby", "aria-describedby", "aria-owns"];
|
|
758
|
+
for (const attr of attrs) {
|
|
759
|
+
for (const el of Array.from(doc.querySelectorAll(`[${attr}]`))) {
|
|
760
|
+
for (const id of (el.getAttribute(attr) || "").trim().split(/\s+/)) if (id) ids.add(id);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return ids;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ---------------------------------------------------------------------------
|
|
767
|
+
// AX object tree
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
type AXObj = {
|
|
771
|
+
id: string;
|
|
772
|
+
node: Node;
|
|
773
|
+
/** wire role for unignored serialization / forced role on ignored nodes */
|
|
774
|
+
wireRole: string | null;
|
|
775
|
+
mojom: string;
|
|
776
|
+
ignored: boolean;
|
|
777
|
+
/** participates in parent childIds / full tree / query traversal */
|
|
778
|
+
included: boolean;
|
|
779
|
+
reasons?: AXProperty[];
|
|
780
|
+
/** suppress the computed name (aria-hidden / unrendered / presentational) */
|
|
781
|
+
nameSuppressed?: boolean;
|
|
782
|
+
parent?: AXObj;
|
|
783
|
+
children: AXObj[];
|
|
784
|
+
text?: string; // StaticText
|
|
785
|
+
isRoot?: boolean;
|
|
786
|
+
isPopup?: boolean; // synthetic MenuListPopup under a <select>
|
|
787
|
+
markerText?: string;
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
type AXTree = {
|
|
791
|
+
root: AXObj;
|
|
792
|
+
byNode: Map<Node, AXObj>;
|
|
793
|
+
byId: Map<string, AXObj>;
|
|
794
|
+
options: AXTreeOptions;
|
|
795
|
+
labelReferenced: Set<string>;
|
|
796
|
+
modal: Element | null;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
type WalkCtx = {
|
|
800
|
+
ariaHiddenBy?: Element;
|
|
801
|
+
inert?: boolean;
|
|
802
|
+
blockedByModal?: boolean;
|
|
803
|
+
presentational?: boolean;
|
|
804
|
+
presentationalParent?: Element;
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
function buildTree(options: AXTreeOptions): AXTree {
|
|
808
|
+
const tree: AXTree = {
|
|
809
|
+
root: undefined as unknown as AXObj,
|
|
810
|
+
byNode: new Map(),
|
|
811
|
+
byId: new Map(),
|
|
812
|
+
options,
|
|
813
|
+
labelReferenced: labelReferencedIds(options.document),
|
|
814
|
+
modal: openModalDialog(options.document),
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
const root: AXObj = {
|
|
818
|
+
id: axIdFor(options.document),
|
|
819
|
+
node: options.document,
|
|
820
|
+
wireRole: "RootWebArea",
|
|
821
|
+
mojom: "RootWebArea",
|
|
822
|
+
ignored: false,
|
|
823
|
+
included: true,
|
|
824
|
+
children: [],
|
|
825
|
+
isRoot: true,
|
|
826
|
+
};
|
|
827
|
+
tree.root = root;
|
|
828
|
+
register(tree, root);
|
|
829
|
+
const html = options.document.documentElement;
|
|
830
|
+
if (html) root.children = walkElement(tree, html, root, {});
|
|
831
|
+
return tree;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function register(tree: AXTree, obj: AXObj): void {
|
|
835
|
+
tree.byNode.set(obj.node, obj);
|
|
836
|
+
tree.byId.set(obj.id, obj);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function makeObj(
|
|
840
|
+
tree: AXTree,
|
|
841
|
+
node: Node,
|
|
842
|
+
partial: Omit<AXObj, "id" | "node" | "children">,
|
|
843
|
+
): AXObj {
|
|
844
|
+
const obj: AXObj = { id: axIdFor(node), node, children: [], ...partial };
|
|
845
|
+
register(tree, obj);
|
|
846
|
+
return obj;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Whether a hidden (unrendered / invisible) element stays in the AX tree. */
|
|
850
|
+
function hiddenButIncluded(tree: AXTree, el: Element): boolean {
|
|
851
|
+
const id = el.getAttribute("id");
|
|
852
|
+
if (id && tree.labelReferenced.has(id)) return true;
|
|
853
|
+
if (el.hasAttribute("lang")) return true;
|
|
854
|
+
if (el.localName === "label") return true;
|
|
855
|
+
return ["table", "tbody", "thead", "tfoot", "tr", "td", "th"].includes(el.localName);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Whether an ignored-but-rendered element stays in the AX tree. Excluded
|
|
859
|
+
* nodes hoist their children to the nearest included ancestor and remain
|
|
860
|
+
* reachable only by direct inspection (Blink's IsIgnoredButIncludedInTree). */
|
|
861
|
+
function renderedIgnoredIncluded(tree: AXTree, el: Element): boolean {
|
|
862
|
+
if (el.localName === "html" || el.localName === "body") return true;
|
|
863
|
+
const id = el.getAttribute("id");
|
|
864
|
+
if (id && tree.labelReferenced.has(id)) return true;
|
|
865
|
+
if (el.hasAttribute("lang")) return true;
|
|
866
|
+
if (["table", "tbody", "thead", "tfoot", "tr", "td", "th"].includes(el.localName)) return true;
|
|
867
|
+
if (el.localName === "label") return true;
|
|
868
|
+
// children of a <label> are kept for accname calculation — except spans
|
|
869
|
+
if (el.parentElement?.localName === "label" && el.localName !== "span") return true;
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function composedChildren(node: Node): Node[] {
|
|
874
|
+
if (isElement(node)) {
|
|
875
|
+
const shadow = (node as HTMLElement).shadowRoot;
|
|
876
|
+
if (shadow) return Array.from(shadow.childNodes);
|
|
877
|
+
if (node instanceof HTMLSlotElement) {
|
|
878
|
+
const assigned = node.assignedNodes({ flatten: true });
|
|
879
|
+
if (assigned.length) return assigned;
|
|
880
|
+
}
|
|
881
|
+
if (node.localName === "table") {
|
|
882
|
+
const children = Array.from(node.childNodes);
|
|
883
|
+
const sections = new Set(["thead", "tbody", "tfoot"]);
|
|
884
|
+
const section = (name: string) =>
|
|
885
|
+
children.filter((child) => isElement(child) && child.localName === name);
|
|
886
|
+
return [
|
|
887
|
+
...children.filter((child) => !isElement(child) || !sections.has(child.localName)),
|
|
888
|
+
...section("thead"),
|
|
889
|
+
...section("tbody"),
|
|
890
|
+
...section("tfoot"),
|
|
891
|
+
];
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return Array.from(node.childNodes);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function numberAttr(el: Element, name: string): number | undefined {
|
|
898
|
+
const raw = el.getAttribute(name);
|
|
899
|
+
if (raw == null || raw === "") return undefined;
|
|
900
|
+
const parsed = Number(raw);
|
|
901
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function listMarkerText(el: Element): string | undefined {
|
|
905
|
+
if (el.localName !== "li" || getComputedStyle(el).display !== "list-item") return undefined;
|
|
906
|
+
const list = el.parentElement;
|
|
907
|
+
if (list?.localName !== "ol") return undefined;
|
|
908
|
+
const siblings = Array.from(list.children).filter(
|
|
909
|
+
(child) => child.localName === "li" && getComputedStyle(child).display === "list-item",
|
|
910
|
+
);
|
|
911
|
+
const start = numberAttr(list, "start") ?? 1;
|
|
912
|
+
return `${start + Math.max(0, siblings.indexOf(el))}. `;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Boundary-preserving StaticText value: internal whitespace collapses; a
|
|
916
|
+
* leading/trailing space survives only next to rendered inline siblings
|
|
917
|
+
* (approximates Blink's layout-driven text trimming). */
|
|
918
|
+
function staticTextValue(node: Text): string {
|
|
919
|
+
const collapsed = (node.nodeValue || "").replace(/\s+/g, " ");
|
|
920
|
+
if (!collapsed) return "";
|
|
921
|
+
const inlineNeighbor = (dir: "previousSibling" | "nextSibling"): boolean => {
|
|
922
|
+
for (let cur = node[dir]; cur; cur = cur[dir]) {
|
|
923
|
+
if (isText(cur)) {
|
|
924
|
+
if (normalizeText(cur.nodeValue)) return true;
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
if (!isElement(cur)) continue;
|
|
928
|
+
if (isUnrendered(cur)) return false;
|
|
929
|
+
return isInlineLevel(cur) || ["img", "svg", "canvas"].includes(cur.localName);
|
|
930
|
+
}
|
|
931
|
+
return false;
|
|
932
|
+
};
|
|
933
|
+
let text = collapsed;
|
|
934
|
+
if (text.startsWith(" ") && !inlineNeighbor("previousSibling")) text = text.slice(1);
|
|
935
|
+
if (text.endsWith(" ") && !inlineNeighbor("nextSibling")) text = text.slice(0, -1);
|
|
936
|
+
return text === " " ? "" : text;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Walk one element; returns the AXObjs to splice into the parent's children. */
|
|
940
|
+
function walkElement(tree: AXTree, el: Element, parent: AXObj, ctx: WalkCtx): AXObj[] {
|
|
941
|
+
if (isSkipped(el)) return [];
|
|
942
|
+
|
|
943
|
+
// --- unrendered / invisible subtrees: objects exist (for direct inspection)
|
|
944
|
+
// but are mostly excluded from the tree, and never recurse text ---
|
|
945
|
+
if (isUnrendered(el)) return [hiddenSubtree(tree, el, parent, "notRendered")].filter(included);
|
|
946
|
+
if (isInvisible(el)) return [hiddenSubtree(tree, el, parent, "notVisible")].filter(included);
|
|
947
|
+
|
|
948
|
+
const registry = tree.options.registry;
|
|
949
|
+
const roleInfo = roleInfoOf(el);
|
|
950
|
+
|
|
951
|
+
// --- ignored states (rendered) ---
|
|
952
|
+
let reasons: AXProperty[] | undefined;
|
|
953
|
+
let childCtx: WalkCtx = ctx;
|
|
954
|
+
let nameSuppressed = false;
|
|
955
|
+
let presentationalInherited = false;
|
|
956
|
+
|
|
957
|
+
const blockedByModal =
|
|
958
|
+
ctx.blockedByModal ||
|
|
959
|
+
Boolean(tree.modal && !el.contains(tree.modal) && !tree.modal.contains(el));
|
|
960
|
+
if (blockedByModal && tree.modal) {
|
|
961
|
+
reasons = [relatedReason("activeModalDialog", tree.modal, registry)];
|
|
962
|
+
childCtx = { ...ctx, blockedByModal: true };
|
|
963
|
+
} else if (el.getAttribute("aria-hidden") === "true") {
|
|
964
|
+
reasons = [ignoredReason("ariaHiddenElement")];
|
|
965
|
+
childCtx = { ...ctx, ariaHiddenBy: el };
|
|
966
|
+
nameSuppressed = true;
|
|
967
|
+
} else if (ctx.ariaHiddenBy) {
|
|
968
|
+
reasons = [relatedReason("ariaHiddenSubtree", ctx.ariaHiddenBy, registry)];
|
|
969
|
+
childCtx = ctx;
|
|
970
|
+
nameSuppressed = true;
|
|
971
|
+
} else if (el.hasAttribute("inert") || ctx.inert) {
|
|
972
|
+
// inherited inertness also reports inertElement (plain boolean)
|
|
973
|
+
reasons = [ignoredReason("inertElement")];
|
|
974
|
+
childCtx = { ...ctx, inert: true };
|
|
975
|
+
} else if (
|
|
976
|
+
isPresentational(el) ||
|
|
977
|
+
(ctx.presentational &&
|
|
978
|
+
ctx.presentationalParent &&
|
|
979
|
+
inheritsPresentation(ctx.presentationalParent, el))
|
|
980
|
+
) {
|
|
981
|
+
reasons = [ignoredReason("presentationalRole")];
|
|
982
|
+
// inherited-presentational required-owned children (a list's items) stay
|
|
983
|
+
// included in the tree, attached to the nearest included ancestor
|
|
984
|
+
presentationalInherited = !isPresentational(el);
|
|
985
|
+
childCtx = { ...ctx, presentational: true, presentationalParent: el };
|
|
986
|
+
nameSuppressed = true;
|
|
987
|
+
// a presentational element's role IS none (Blink RoleValue() == kNone)
|
|
988
|
+
roleInfo.wire = null;
|
|
989
|
+
roleInfo.mojom = "None";
|
|
990
|
+
} else if (el.localName === "canvas" && !roleHasExplicit(el)) {
|
|
991
|
+
reasons = [ignoredReason("probablyPresentational")];
|
|
992
|
+
childCtx = ctx;
|
|
993
|
+
} else if (roleInfo.wire === null && !isInterestingGeneric(el)) {
|
|
994
|
+
reasons = [ignoredReason("uninteresting")];
|
|
995
|
+
childCtx = ctx;
|
|
996
|
+
} else {
|
|
997
|
+
childCtx = { ...ctx, presentational: false, presentationalParent: undefined };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const dropText = Boolean(childCtx.ariaHiddenBy || childCtx.inert || childCtx.blockedByModal);
|
|
1001
|
+
|
|
1002
|
+
// svg and iframe subtrees are leaves (vector content / other documents)
|
|
1003
|
+
const leaf = el.localName === "svg" || el.localName === "iframe" || el.localName === "frame";
|
|
1004
|
+
|
|
1005
|
+
if (reasons) {
|
|
1006
|
+
const obj = makeObj(tree, el, {
|
|
1007
|
+
wireRole: roleInfo.wire,
|
|
1008
|
+
mojom: roleInfo.mojom,
|
|
1009
|
+
ignored: true,
|
|
1010
|
+
included: presentationalInherited || renderedIgnoredIncluded(tree, el),
|
|
1011
|
+
reasons,
|
|
1012
|
+
nameSuppressed,
|
|
1013
|
+
});
|
|
1014
|
+
const children = leaf ? [] : walkChildren(tree, el, obj, childCtx, dropText);
|
|
1015
|
+
if (obj.included) {
|
|
1016
|
+
obj.children = children;
|
|
1017
|
+
for (const child of children) child.parent = obj;
|
|
1018
|
+
obj.parent = parent;
|
|
1019
|
+
return [obj];
|
|
1020
|
+
}
|
|
1021
|
+
// excluded: children hoist to the nearest included ancestor
|
|
1022
|
+
obj.parent = parent;
|
|
1023
|
+
for (const child of children) child.parent = parent;
|
|
1024
|
+
return children;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// --- unignored ---
|
|
1028
|
+
const obj = makeObj(tree, el, {
|
|
1029
|
+
wireRole: roleInfo.wire ?? "generic",
|
|
1030
|
+
mojom: roleInfo.wire ? roleInfo.mojom : "GenericContainer",
|
|
1031
|
+
ignored: false,
|
|
1032
|
+
included: true,
|
|
1033
|
+
});
|
|
1034
|
+
obj.parent = parent;
|
|
1035
|
+
obj.markerText = listMarkerText(el);
|
|
1036
|
+
if (el instanceof HTMLSelectElement && !el.multiple) {
|
|
1037
|
+
// a single-select exposes a synthetic MenuListPopup holding its options
|
|
1038
|
+
const popup: AXObj = {
|
|
1039
|
+
id: `${obj.id}:popup`,
|
|
1040
|
+
node: el,
|
|
1041
|
+
wireRole: "MenuListPopup",
|
|
1042
|
+
mojom: "MenuListPopup",
|
|
1043
|
+
ignored: false,
|
|
1044
|
+
included: true,
|
|
1045
|
+
children: [],
|
|
1046
|
+
isPopup: true,
|
|
1047
|
+
parent: obj,
|
|
1048
|
+
};
|
|
1049
|
+
tree.byId.set(popup.id, popup);
|
|
1050
|
+
popup.children = walkChildren(tree, el, popup, childCtx, true);
|
|
1051
|
+
for (const child of popup.children) {
|
|
1052
|
+
child.parent = popup;
|
|
1053
|
+
child.children = []; // options are leaves (their text is their name)
|
|
1054
|
+
}
|
|
1055
|
+
obj.children = [popup];
|
|
1056
|
+
return [obj];
|
|
1057
|
+
}
|
|
1058
|
+
obj.children = leaf ? [] : walkChildren(tree, el, obj, childCtx, dropText);
|
|
1059
|
+
for (const child of obj.children) child.parent = obj;
|
|
1060
|
+
return [obj];
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function included(obj: AXObj): boolean {
|
|
1064
|
+
return obj.included;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function roleHasExplicit(el: Element): boolean {
|
|
1068
|
+
return explicitRole(el) !== null;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** A role-less element is an exposed `generic` when it is block-level, named,
|
|
1072
|
+
* or focusable; inline/contents wrappers are ignored as uninteresting. */
|
|
1073
|
+
function isInterestingGeneric(el: Element): boolean {
|
|
1074
|
+
if (el.localName === "html" || el.localName === "body") return false;
|
|
1075
|
+
if (
|
|
1076
|
+
normalizeText(el.getAttribute("aria-label")) ||
|
|
1077
|
+
el.hasAttribute("aria-labelledby") ||
|
|
1078
|
+
el.hasAttribute("aria-labeledby")
|
|
1079
|
+
)
|
|
1080
|
+
return true;
|
|
1081
|
+
if (isFocusable(el)) return true;
|
|
1082
|
+
if (getComputedStyle(el).display === "contents") return false;
|
|
1083
|
+
return !isInlineLevel(el);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function walkChildren(
|
|
1087
|
+
tree: AXTree,
|
|
1088
|
+
el: Element,
|
|
1089
|
+
parent: AXObj,
|
|
1090
|
+
ctx: WalkCtx,
|
|
1091
|
+
dropText: boolean,
|
|
1092
|
+
): AXObj[] {
|
|
1093
|
+
const out: AXObj[] = [];
|
|
1094
|
+
for (const child of composedChildren(el)) {
|
|
1095
|
+
if (isText(child)) {
|
|
1096
|
+
if (dropText) continue;
|
|
1097
|
+
const text = staticTextValue(child);
|
|
1098
|
+
if (!text) continue;
|
|
1099
|
+
const obj = makeObj(tree, child, {
|
|
1100
|
+
wireRole: "StaticText",
|
|
1101
|
+
mojom: "StaticText",
|
|
1102
|
+
ignored: false,
|
|
1103
|
+
included: true,
|
|
1104
|
+
text,
|
|
1105
|
+
});
|
|
1106
|
+
obj.parent = parent;
|
|
1107
|
+
out.push(obj);
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
if (isElement(child)) out.push(...walkElement(tree, child, parent, ctx));
|
|
1111
|
+
}
|
|
1112
|
+
return out;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** Build the (excluded by default) object for an unrendered/invisible element
|
|
1116
|
+
* and side-register its element descendants for direct inspection. */
|
|
1117
|
+
function hiddenSubtree(
|
|
1118
|
+
tree: AXTree,
|
|
1119
|
+
el: Element,
|
|
1120
|
+
parent: AXObj,
|
|
1121
|
+
reason: "notRendered" | "notVisible",
|
|
1122
|
+
): AXObj {
|
|
1123
|
+
const roleInfo = roleInfoOf(el);
|
|
1124
|
+
const obj = makeObj(tree, el, {
|
|
1125
|
+
wireRole: roleInfo.wire,
|
|
1126
|
+
mojom: roleInfo.mojom,
|
|
1127
|
+
ignored: true,
|
|
1128
|
+
included: hiddenButIncluded(tree, el),
|
|
1129
|
+
reasons: [ignoredReason(reason)],
|
|
1130
|
+
nameSuppressed: true,
|
|
1131
|
+
});
|
|
1132
|
+
obj.parent = parent;
|
|
1133
|
+
const registerDescendants = (cur: Element): void => {
|
|
1134
|
+
for (const child of Array.from(cur.children)) {
|
|
1135
|
+
if (isSkipped(child) || tree.byNode.has(child)) continue;
|
|
1136
|
+
const info = roleInfoOf(child);
|
|
1137
|
+
const childObj = makeObj(tree, child, {
|
|
1138
|
+
wireRole: info.wire,
|
|
1139
|
+
mojom: info.mojom,
|
|
1140
|
+
ignored: true,
|
|
1141
|
+
included: false,
|
|
1142
|
+
reasons: [ignoredReason(reason)],
|
|
1143
|
+
nameSuppressed: true,
|
|
1144
|
+
});
|
|
1145
|
+
childObj.parent = parent;
|
|
1146
|
+
registerDescendants(child);
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
registerDescendants(el);
|
|
1150
|
+
return obj;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// ---------------------------------------------------------------------------
|
|
1154
|
+
// Text equivalents (AccName contents)
|
|
1155
|
+
// ---------------------------------------------------------------------------
|
|
1156
|
+
|
|
1157
|
+
/** Space-joined text equivalent of an element's contents. Skips hidden
|
|
1158
|
+
* descendants unless `unfiltered`; descendant aria-labels and img alts
|
|
1159
|
+
* substitute for their subtrees (Blink joins chunks with single spaces). */
|
|
1160
|
+
function textEquivalent(root: Element, unfiltered = false): string {
|
|
1161
|
+
const chunks: string[] = [];
|
|
1162
|
+
const visit = (node: Node): void => {
|
|
1163
|
+
if (isText(node)) {
|
|
1164
|
+
const text = normalizeText(node.nodeValue);
|
|
1165
|
+
if (text) chunks.push(text);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (!isElement(node)) return;
|
|
1169
|
+
if (isSkipped(node) && node.localName !== "title") return;
|
|
1170
|
+
if (!unfiltered && isElHiddenForText(node)) return;
|
|
1171
|
+
const ariaLabel = normalizeText(node.getAttribute("aria-label"));
|
|
1172
|
+
if (ariaLabel) {
|
|
1173
|
+
chunks.push(ariaLabel);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (node.localName === "img" || (node instanceof HTMLInputElement && node.type === "image")) {
|
|
1177
|
+
const alt = normalizeText(node.getAttribute("alt"));
|
|
1178
|
+
// a presentational img with an alt contributes an empty chunk (Blink
|
|
1179
|
+
// leaves a double space in the raw source text), a plain one its alt
|
|
1180
|
+
if (isPresentational(node)) {
|
|
1181
|
+
if (node.hasAttribute("alt")) chunks.push("");
|
|
1182
|
+
} else if (alt) {
|
|
1183
|
+
chunks.push(alt);
|
|
1184
|
+
}
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
if (node instanceof HTMLInputElement) {
|
|
1188
|
+
const value = normalizeText(node.value);
|
|
1189
|
+
if (value) chunks.push(value);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
for (const child of composedChildren(node)) visit(child);
|
|
1193
|
+
// aria-owns pulls the referenced elements into this node's contents
|
|
1194
|
+
for (const id of idRefs(node, "aria-owns")) {
|
|
1195
|
+
const owned = node.ownerDocument.getElementById(id);
|
|
1196
|
+
if (owned && !visited.has(owned)) {
|
|
1197
|
+
visited.add(owned);
|
|
1198
|
+
visit(owned);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
const visited = new Set<Element>();
|
|
1203
|
+
for (const child of composedChildren(root)) visit(child);
|
|
1204
|
+
for (const id of idRefs(root, "aria-owns")) {
|
|
1205
|
+
const owned = root.ownerDocument.getElementById(id);
|
|
1206
|
+
if (owned && !visited.has(owned)) {
|
|
1207
|
+
visited.add(owned);
|
|
1208
|
+
visit(owned);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return chunks.join(" ");
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Text contributed by one aria-labelledby target: its aria-label wins;
|
|
1215
|
+
* hidden targets contribute their full unfiltered subtree text; labelledby
|
|
1216
|
+
* chains are NOT followed (the reference is non-recursive). */
|
|
1217
|
+
function labelledbyTargetText(target: Element): string {
|
|
1218
|
+
const ariaLabel = normalizeText(target.getAttribute("aria-label"));
|
|
1219
|
+
if (ariaLabel) return ariaLabel;
|
|
1220
|
+
return textEquivalent(target, hasHiddenAncestorOrSelf(target));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function labelText(label: Element): string {
|
|
1224
|
+
if (hasHiddenAncestorOrSelf(label)) return "";
|
|
1225
|
+
return textEquivalent(label);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// ---------------------------------------------------------------------------
|
|
1229
|
+
// Name sources
|
|
1230
|
+
// ---------------------------------------------------------------------------
|
|
1231
|
+
|
|
1232
|
+
const NAME_FROM_CONTENTS_ROLES = new Set([
|
|
1233
|
+
"button",
|
|
1234
|
+
"DisclosureTriangle",
|
|
1235
|
+
"cell",
|
|
1236
|
+
"checkbox",
|
|
1237
|
+
"columnheader",
|
|
1238
|
+
"gridcell",
|
|
1239
|
+
"heading",
|
|
1240
|
+
"link",
|
|
1241
|
+
"menuitem",
|
|
1242
|
+
"menuitemcheckbox",
|
|
1243
|
+
"menuitemradio",
|
|
1244
|
+
"option",
|
|
1245
|
+
"radio",
|
|
1246
|
+
"row",
|
|
1247
|
+
"rowheader",
|
|
1248
|
+
"switch",
|
|
1249
|
+
"tab",
|
|
1250
|
+
"tooltip",
|
|
1251
|
+
"treeitem",
|
|
1252
|
+
]);
|
|
1253
|
+
|
|
1254
|
+
function idRefs(el: Element, ...names: string[]): string[] {
|
|
1255
|
+
for (const name of names) {
|
|
1256
|
+
const raw = el.getAttribute(name);
|
|
1257
|
+
if (raw != null) return raw.trim().split(/\s+/).filter(Boolean);
|
|
1258
|
+
}
|
|
1259
|
+
return [];
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
type Candidate = {
|
|
1263
|
+
source: AXValueSource;
|
|
1264
|
+
/** null = source has no text at all; "" = computed but empty */
|
|
1265
|
+
value: string | null;
|
|
1266
|
+
related?: AXRelatedNode[]; // feeds the labelledby property when this wins
|
|
1267
|
+
/** wins the name even when its computed text is empty (native labels) */
|
|
1268
|
+
terminal?: boolean;
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
function isLabelable(el: Element): boolean {
|
|
1272
|
+
return (
|
|
1273
|
+
el instanceof HTMLButtonElement ||
|
|
1274
|
+
el instanceof HTMLInputElement ||
|
|
1275
|
+
el instanceof HTMLTextAreaElement ||
|
|
1276
|
+
el instanceof HTMLSelectElement ||
|
|
1277
|
+
el instanceof HTMLOutputElement ||
|
|
1278
|
+
el instanceof HTMLMeterElement ||
|
|
1279
|
+
el instanceof HTMLProgressElement
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function nativeLabels(el: Element): Element[] {
|
|
1284
|
+
const labels = (el as { labels?: NodeListOf<HTMLLabelElement> | null }).labels;
|
|
1285
|
+
return labels ? Array.from(labels) : [];
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function labelledbyCandidate(tree: AXTree, el: Element): Candidate {
|
|
1289
|
+
const registry = tree.options.registry;
|
|
1290
|
+
const ids = idRefs(el, "aria-labelledby", "aria-labeledby");
|
|
1291
|
+
const source: AXValueSource = { type: "relatedElement", attribute: "aria-labelledby" };
|
|
1292
|
+
if (!ids.length) return { source, value: null };
|
|
1293
|
+
const targets = ids
|
|
1294
|
+
.map((id) => ({ id, el: el.ownerDocument.getElementById(id) }))
|
|
1295
|
+
.filter((t): t is { id: string; el: HTMLElement } => t.el != null);
|
|
1296
|
+
const texts = targets.map((t) => labelledbyTargetText(t.el));
|
|
1297
|
+
const joined = texts.filter(Boolean).join(" ");
|
|
1298
|
+
if (!joined) {
|
|
1299
|
+
// an unresolved or empty labelledby is invalid: the attribute value
|
|
1300
|
+
// degrades to a plain string and the name falls through to later sources
|
|
1301
|
+
source.attributeValue = { type: "string", value: el.getAttribute("aria-labelledby") ?? "" };
|
|
1302
|
+
source.invalid = true;
|
|
1303
|
+
return { source, value: null };
|
|
1304
|
+
}
|
|
1305
|
+
const related: AXRelatedNode[] = targets.map((t, i) => {
|
|
1306
|
+
const node: AXRelatedNode = { backendDOMNodeId: registry.backendIdFor(t.el), idref: t.id };
|
|
1307
|
+
if (texts[i]) node.text = texts[i];
|
|
1308
|
+
return node;
|
|
1309
|
+
});
|
|
1310
|
+
source.attributeValue = {
|
|
1311
|
+
type: "idrefList",
|
|
1312
|
+
value: ids.join(" "),
|
|
1313
|
+
...(related.length ? { relatedNodes: related } : {}),
|
|
1314
|
+
};
|
|
1315
|
+
const propertyRelated: AXRelatedNode[] = targets.map((t, i) => ({
|
|
1316
|
+
backendDOMNodeId: registry.backendIdFor(t.el),
|
|
1317
|
+
idref: t.id,
|
|
1318
|
+
text: texts[i] ?? "",
|
|
1319
|
+
}));
|
|
1320
|
+
return { source, value: texts.filter(Boolean).join(" "), related: propertyRelated };
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function attributeCandidate(
|
|
1324
|
+
el: Element,
|
|
1325
|
+
attribute: string,
|
|
1326
|
+
options: {
|
|
1327
|
+
sourceType?: AXValueSource["type"];
|
|
1328
|
+
defaultValue?: string;
|
|
1329
|
+
omitAttributeValue?: boolean;
|
|
1330
|
+
} = {},
|
|
1331
|
+
): Candidate {
|
|
1332
|
+
const raw = el.getAttribute(attribute);
|
|
1333
|
+
const source: AXValueSource = { type: options.sourceType ?? "attribute", attribute };
|
|
1334
|
+
if (raw != null && raw !== "" && !options.omitAttributeValue)
|
|
1335
|
+
source.attributeValue = { type: "string", value: raw };
|
|
1336
|
+
const text = normalizeText(raw);
|
|
1337
|
+
if (text) return { source, value: text };
|
|
1338
|
+
if (options.defaultValue !== undefined && raw !== null)
|
|
1339
|
+
return { source, value: options.defaultValue };
|
|
1340
|
+
return { source, value: raw == null ? null : "" };
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function nativeLabelCandidate(tree: AXTree, el: Element): Candidate {
|
|
1344
|
+
const registry = tree.options.registry;
|
|
1345
|
+
const labels = nativeLabels(el);
|
|
1346
|
+
const wrapped = labels.some((label) => label.contains(el));
|
|
1347
|
+
const nativeSource = labels.length ? (wrapped ? "labelwrapped" : "labelfor") : "label";
|
|
1348
|
+
const source: AXValueSource = { type: "relatedElement", nativeSource };
|
|
1349
|
+
if (!labels.length) return { source, value: null };
|
|
1350
|
+
const texts = labels.map((label) => labelText(label));
|
|
1351
|
+
source.nativeSourceValue = {
|
|
1352
|
+
type: "nodeList",
|
|
1353
|
+
relatedNodes: labels.map((label, i) => ({
|
|
1354
|
+
backendDOMNodeId: registry.backendIdFor(label),
|
|
1355
|
+
text: texts[i] ?? "",
|
|
1356
|
+
})),
|
|
1357
|
+
};
|
|
1358
|
+
const propertyRelated: AXRelatedNode[] = labels.map((label, i) => ({
|
|
1359
|
+
backendDOMNodeId: registry.backendIdFor(label),
|
|
1360
|
+
text: texts[i] ?? "",
|
|
1361
|
+
}));
|
|
1362
|
+
// an associated label terminates the name search even when its text is empty
|
|
1363
|
+
return {
|
|
1364
|
+
source,
|
|
1365
|
+
value: texts.filter(Boolean).join(" "),
|
|
1366
|
+
related: propertyRelated,
|
|
1367
|
+
terminal: true,
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function relatedElementCandidate(tree: AXTree, nativeSource: string, els: Element[]): Candidate {
|
|
1372
|
+
const registry = tree.options.registry;
|
|
1373
|
+
const source: AXValueSource = {
|
|
1374
|
+
type: "relatedElement",
|
|
1375
|
+
nativeSource: nativeSource as AXValueSource["nativeSource"],
|
|
1376
|
+
};
|
|
1377
|
+
if (!els.length) return { source, value: null };
|
|
1378
|
+
const texts = els.map((e) => textEquivalent(e));
|
|
1379
|
+
source.nativeSourceValue = {
|
|
1380
|
+
type: "nodeList",
|
|
1381
|
+
relatedNodes: els.map((e, i) => {
|
|
1382
|
+
const node: AXRelatedNode = { backendDOMNodeId: registry.backendIdFor(e) };
|
|
1383
|
+
if (texts[i]) node.text = texts[i];
|
|
1384
|
+
return node;
|
|
1385
|
+
}),
|
|
1386
|
+
};
|
|
1387
|
+
const propertyRelated: AXRelatedNode[] = els.map((e, i) => ({
|
|
1388
|
+
backendDOMNodeId: registry.backendIdFor(e),
|
|
1389
|
+
text: texts[i] ?? "",
|
|
1390
|
+
}));
|
|
1391
|
+
return { source, value: texts.filter(Boolean).join(" "), related: propertyRelated };
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function contentsCandidate(el: Element, defaultValue = ""): Candidate {
|
|
1395
|
+
// a self-referencing aria-labelledby already consumed this node's contents
|
|
1396
|
+
// (Blink's visited-set recursion guard), leaving the contents source empty
|
|
1397
|
+
const id = el.getAttribute("id");
|
|
1398
|
+
if (id && idRefs(el, "aria-labelledby", "aria-labeledby").includes(id))
|
|
1399
|
+
return { source: { type: "contents" }, value: null };
|
|
1400
|
+
const text = textEquivalent(el) || defaultValue;
|
|
1401
|
+
return { source: { type: "contents" }, value: text || null };
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function svgTitleCandidate(el: Element): Candidate {
|
|
1405
|
+
const title = Array.from(el.children).find((child) => child.localName === "title");
|
|
1406
|
+
const source: AXValueSource = { type: "relatedElement", nativeSource: "title" };
|
|
1407
|
+
if (!title) return { source, value: null };
|
|
1408
|
+
return { source, value: normalizeText(title.textContent) };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Ordered AccName candidates, element-specific (Chromium's NameSources). */
|
|
1412
|
+
function nameCandidates(tree: AXTree, el: Element, role: string): Candidate[] {
|
|
1413
|
+
const base = [labelledbyCandidate(tree, el), attributeCandidate(el, "aria-label")];
|
|
1414
|
+
if (NAME_PROHIBITED_ROLES.has(role)) return base;
|
|
1415
|
+
|
|
1416
|
+
const tag = el.localName;
|
|
1417
|
+
const title = () => attributeCandidate(el, "title");
|
|
1418
|
+
if (el instanceof HTMLInputElement) {
|
|
1419
|
+
const type = el.type;
|
|
1420
|
+
if (["button", "submit", "reset"].includes(type)) {
|
|
1421
|
+
const defaults: Record<string, string> = { submit: "Submit", reset: "Reset" };
|
|
1422
|
+
// the rendered label of a button input — its value attribute or the
|
|
1423
|
+
// type-specific default — doubles as the contents candidate
|
|
1424
|
+
const label = normalizeText(el.getAttribute("value")) || (defaults[type] ?? "");
|
|
1425
|
+
return [
|
|
1426
|
+
...base,
|
|
1427
|
+
nativeLabelCandidate(tree, el),
|
|
1428
|
+
attributeCandidate(el, "value", { omitAttributeValue: true }),
|
|
1429
|
+
{ source: { type: "contents" }, value: label || null },
|
|
1430
|
+
title(),
|
|
1431
|
+
];
|
|
1432
|
+
}
|
|
1433
|
+
if (type === "image") {
|
|
1434
|
+
const typeSource: AXValueSource = { type: "attribute", attribute: "type" };
|
|
1435
|
+
const rawType = el.getAttribute("type");
|
|
1436
|
+
if (rawType) typeSource.attributeValue = { type: "string", value: rawType };
|
|
1437
|
+
const label =
|
|
1438
|
+
normalizeText(el.getAttribute("alt")) ||
|
|
1439
|
+
normalizeText(el.getAttribute("value")) ||
|
|
1440
|
+
normalizeText(el.getAttribute("title")) ||
|
|
1441
|
+
"Submit";
|
|
1442
|
+
return [
|
|
1443
|
+
...base,
|
|
1444
|
+
nativeLabelCandidate(tree, el),
|
|
1445
|
+
attributeCandidate(el, "alt"),
|
|
1446
|
+
attributeCandidate(el, "value", { omitAttributeValue: true }),
|
|
1447
|
+
title(),
|
|
1448
|
+
{ source: typeSource, value: "Submit" },
|
|
1449
|
+
{ source: { type: "contents" }, value: label },
|
|
1450
|
+
title(),
|
|
1451
|
+
];
|
|
1452
|
+
}
|
|
1453
|
+
if (TEXT_ENTRY_INPUTS.has(type) || type === "number") {
|
|
1454
|
+
return [
|
|
1455
|
+
...base,
|
|
1456
|
+
nativeLabelCandidate(tree, el),
|
|
1457
|
+
title(),
|
|
1458
|
+
attributeCandidate(el, "placeholder", { sourceType: "placeholder" }),
|
|
1459
|
+
attributeCandidate(el, "aria-placeholder", { sourceType: "placeholder" }),
|
|
1460
|
+
title(),
|
|
1461
|
+
];
|
|
1462
|
+
}
|
|
1463
|
+
const slots = [...base, nativeLabelCandidate(tree, el)];
|
|
1464
|
+
if (NAME_FROM_CONTENTS_ROLES.has(role)) slots.push(contentsCandidate(el));
|
|
1465
|
+
return [...slots, title()];
|
|
1466
|
+
}
|
|
1467
|
+
if (tag === "textarea")
|
|
1468
|
+
return [
|
|
1469
|
+
...base,
|
|
1470
|
+
nativeLabelCandidate(tree, el),
|
|
1471
|
+
title(),
|
|
1472
|
+
attributeCandidate(el, "placeholder", { sourceType: "placeholder" }),
|
|
1473
|
+
attributeCandidate(el, "aria-placeholder", { sourceType: "placeholder" }),
|
|
1474
|
+
title(),
|
|
1475
|
+
];
|
|
1476
|
+
if (tag === "img") {
|
|
1477
|
+
// alt="" is an explicit "no name": the empty alt wins (terminal) with its
|
|
1478
|
+
// empty attributeValue serialized, and no title slot follows
|
|
1479
|
+
if (el.getAttribute("alt") === "") {
|
|
1480
|
+
const source: AXValueSource = {
|
|
1481
|
+
type: "attribute",
|
|
1482
|
+
attribute: "alt",
|
|
1483
|
+
attributeValue: { type: "string", value: "" },
|
|
1484
|
+
};
|
|
1485
|
+
return [...base, { source, value: "", terminal: true }];
|
|
1486
|
+
}
|
|
1487
|
+
return [...base, attributeCandidate(el, "alt"), title()];
|
|
1488
|
+
}
|
|
1489
|
+
if (tag === "svg") return [...base, svgTitleCandidate(el), title()];
|
|
1490
|
+
if (tag === "figure") return [...base, title()];
|
|
1491
|
+
if (tag === "fieldset")
|
|
1492
|
+
return [...base, relatedElementCandidate(tree, "legend", queryChildren(el, "legend")), title()];
|
|
1493
|
+
if (tag === "table")
|
|
1494
|
+
return [
|
|
1495
|
+
...base,
|
|
1496
|
+
relatedElementCandidate(tree, "tablecaption", queryChildren(el, "caption")),
|
|
1497
|
+
title(),
|
|
1498
|
+
];
|
|
1499
|
+
const slots = [...base];
|
|
1500
|
+
if (isLabelable(el)) slots.push(nativeLabelCandidate(tree, el));
|
|
1501
|
+
if (NAME_FROM_CONTENTS_ROLES.has(role)) slots.push(contentsCandidate(el));
|
|
1502
|
+
slots.push(title());
|
|
1503
|
+
return slots;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function queryChildren(el: Element, selector: string): Element[] {
|
|
1507
|
+
const match = el.querySelector(selector);
|
|
1508
|
+
return match ? [match] : [];
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
type NameInfo = {
|
|
1512
|
+
value: string;
|
|
1513
|
+
sources: AXValueSource[];
|
|
1514
|
+
/** related nodes of the effective labelledby-ish source (for the property) */
|
|
1515
|
+
labelledbyRelated?: AXRelatedNode[];
|
|
1516
|
+
/** the name came from author ARIA (aria-label / aria-labelledby) */
|
|
1517
|
+
fromAuthorAria?: boolean;
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
function computeName(tree: AXTree, el: Element, role: string): NameInfo {
|
|
1521
|
+
const candidates = nameCandidates(tree, el, role);
|
|
1522
|
+
const winner = candidates.findIndex(
|
|
1523
|
+
(candidate) => candidate.value || (candidate.terminal && candidate.value !== null),
|
|
1524
|
+
);
|
|
1525
|
+
const sources = candidates.map((candidate, index) => {
|
|
1526
|
+
const source: AXValueSource = { ...candidate.source };
|
|
1527
|
+
if (index === winner) {
|
|
1528
|
+
source.value = ax("computedString", candidate.value);
|
|
1529
|
+
} else if (winner >= 0 && index > winner) {
|
|
1530
|
+
source.superseded = true;
|
|
1531
|
+
if (candidate.value) source.value = ax("computedString", candidate.value);
|
|
1532
|
+
}
|
|
1533
|
+
return source;
|
|
1534
|
+
});
|
|
1535
|
+
// the labelledby property reflects the winning labelledby/native-label source
|
|
1536
|
+
// (Chromium: name_source.text non-null && !superseded && related_objects)
|
|
1537
|
+
const effective =
|
|
1538
|
+
winner >= 0
|
|
1539
|
+
? candidates[winner]
|
|
1540
|
+
: candidates.find((candidate) => candidate.value !== null && candidate.related?.length);
|
|
1541
|
+
// the wire name is whitespace-normalized; source values keep the raw join
|
|
1542
|
+
const value = winner >= 0 ? normalizeText(candidates[winner]?.value ?? "") : "";
|
|
1543
|
+
// the first two candidates are always aria-labelledby / aria-label
|
|
1544
|
+
return {
|
|
1545
|
+
value,
|
|
1546
|
+
sources,
|
|
1547
|
+
labelledbyRelated: effective?.related,
|
|
1548
|
+
fromAuthorAria: winner === 0 || winner === 1,
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** RootWebArea name sources: Chromium emits this fixed quirky list. */
|
|
1553
|
+
function rootNameSources(title: string): AXValueSource[] {
|
|
1554
|
+
const sources: AXValueSource[] = [
|
|
1555
|
+
{ type: "relatedElement", attribute: "aria-labelledby" },
|
|
1556
|
+
{ type: "attribute", attribute: "aria-label" },
|
|
1557
|
+
{ type: "attribute", attribute: "aria-label", superseded: true },
|
|
1558
|
+
];
|
|
1559
|
+
const native: AXValueSource = { type: "relatedElement", nativeSource: "title" };
|
|
1560
|
+
if (title) native.value = ax("computedString", title);
|
|
1561
|
+
sources.push(native);
|
|
1562
|
+
return sources;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// ---------------------------------------------------------------------------
|
|
1566
|
+
// Properties / value / description
|
|
1567
|
+
// ---------------------------------------------------------------------------
|
|
1568
|
+
|
|
1569
|
+
function boolAttr(el: Element, name: string): boolean | undefined {
|
|
1570
|
+
const value = el.getAttribute(name);
|
|
1571
|
+
if (value == null) return undefined;
|
|
1572
|
+
if (value === "" || value === "true") return true;
|
|
1573
|
+
if (value === "false") return false;
|
|
1574
|
+
return undefined;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function tristateAttr(el: Element, name: string): boolean | "mixed" | undefined {
|
|
1578
|
+
const value = el.getAttribute(name);
|
|
1579
|
+
if (value === "mixed") return "mixed";
|
|
1580
|
+
if (value === "true") return true;
|
|
1581
|
+
if (value === "false") return false;
|
|
1582
|
+
return undefined;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function tristateValue(
|
|
1586
|
+
value: boolean | "mixed" | undefined,
|
|
1587
|
+
): "true" | "false" | "mixed" | undefined {
|
|
1588
|
+
if (value === undefined) return undefined;
|
|
1589
|
+
if (value === "mixed") return "mixed";
|
|
1590
|
+
return value ? "true" : "false";
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function addProp(
|
|
1594
|
+
props: AXProperty[],
|
|
1595
|
+
name: AXPropertyName,
|
|
1596
|
+
type: AXValueType,
|
|
1597
|
+
value: unknown,
|
|
1598
|
+
options: { includeFalse?: boolean } = {},
|
|
1599
|
+
): void {
|
|
1600
|
+
if (
|
|
1601
|
+
value !== undefined &&
|
|
1602
|
+
value !== null &&
|
|
1603
|
+
value !== "" &&
|
|
1604
|
+
(options.includeFalse || value !== false)
|
|
1605
|
+
)
|
|
1606
|
+
props.push({ name, value: ax(type, value) });
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function isDisabled(el: Element): boolean {
|
|
1610
|
+
if (nativeDisabled(el) || boolAttr(el, "aria-disabled") === true) return true;
|
|
1611
|
+
// aria-disabled propagates to descendants (Chromium's GetRestriction).
|
|
1612
|
+
for (let parent = el.parentElement; parent; parent = parent.parentElement)
|
|
1613
|
+
if (parent.getAttribute("aria-disabled") === "true") return true;
|
|
1614
|
+
return false;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const READONLY_ROLES = new Set([
|
|
1618
|
+
"grid",
|
|
1619
|
+
"gridcell",
|
|
1620
|
+
"textbox",
|
|
1621
|
+
"searchbox",
|
|
1622
|
+
"columnheader",
|
|
1623
|
+
"rowheader",
|
|
1624
|
+
"treegrid",
|
|
1625
|
+
]);
|
|
1626
|
+
const REQUIRED_ROLES = new Set([
|
|
1627
|
+
"combobox",
|
|
1628
|
+
"gridcell",
|
|
1629
|
+
"listbox",
|
|
1630
|
+
"radiogroup",
|
|
1631
|
+
"spinbutton",
|
|
1632
|
+
"textbox",
|
|
1633
|
+
"searchbox",
|
|
1634
|
+
"tree",
|
|
1635
|
+
"columnheader",
|
|
1636
|
+
"rowheader",
|
|
1637
|
+
"treegrid",
|
|
1638
|
+
]);
|
|
1639
|
+
const MULTISELECTABLE_ROLES = new Set(["grid", "listbox", "tablist", "treegrid", "tree"]);
|
|
1640
|
+
|
|
1641
|
+
function readonlyState(el: Element): boolean {
|
|
1642
|
+
if ("readOnly" in el && Boolean((el as { readOnly?: boolean }).readOnly)) return true;
|
|
1643
|
+
return boolAttr(el, "aria-readonly") === true;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function requiredState(el: Element): boolean {
|
|
1647
|
+
if ("required" in el && Boolean((el as { required?: boolean }).required)) return true;
|
|
1648
|
+
return boolAttr(el, "aria-required") === true;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function multiselectableState(el: Element): boolean {
|
|
1652
|
+
if (el instanceof HTMLSelectElement && el.multiple) return true;
|
|
1653
|
+
return boolAttr(el, "aria-multiselectable") === true;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function headingLevel(el: Element): number | undefined {
|
|
1657
|
+
const aria = Number(el.getAttribute("aria-level"));
|
|
1658
|
+
if (Number.isFinite(aria) && aria > 0) return aria;
|
|
1659
|
+
const match = /^h([1-6])$/.exec(el.localName);
|
|
1660
|
+
return match?.[1] ? Number(match[1]) : undefined;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function nativeMin(el: Element, role: string): number | undefined {
|
|
1664
|
+
if (el instanceof HTMLInputElement) {
|
|
1665
|
+
if (el.type === "range") return numberAttr(el, "min") ?? 0;
|
|
1666
|
+
if (el.type === "number") return numberAttr(el, "min");
|
|
1667
|
+
}
|
|
1668
|
+
if (el instanceof HTMLProgressElement) return 0;
|
|
1669
|
+
if (el instanceof HTMLMeterElement) return el.min;
|
|
1670
|
+
if (role === "scrollbar") return 0;
|
|
1671
|
+
return undefined;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function nativeMax(el: Element, role: string): number | undefined {
|
|
1675
|
+
if (el instanceof HTMLInputElement) {
|
|
1676
|
+
if (el.type === "range") return numberAttr(el, "max") ?? 100;
|
|
1677
|
+
if (el.type === "number") return numberAttr(el, "max");
|
|
1678
|
+
}
|
|
1679
|
+
if (el instanceof HTMLProgressElement) return el.max;
|
|
1680
|
+
if (el instanceof HTMLMeterElement) return el.max;
|
|
1681
|
+
if (role === "scrollbar") return 100;
|
|
1682
|
+
return undefined;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const RANGE_ROLES = new Set([
|
|
1686
|
+
"slider",
|
|
1687
|
+
"scrollbar",
|
|
1688
|
+
"spinbutton",
|
|
1689
|
+
"progressbar",
|
|
1690
|
+
"meter",
|
|
1691
|
+
"separator",
|
|
1692
|
+
]);
|
|
1693
|
+
|
|
1694
|
+
/** The numeric value for a range-valued element, or undefined to omit it. */
|
|
1695
|
+
function rangeValue(el: Element, role: string): number | undefined {
|
|
1696
|
+
const min = numberAttr(el, "aria-valuemin") ?? nativeMin(el, role);
|
|
1697
|
+
const max = numberAttr(el, "aria-valuemax") ?? nativeMax(el, role);
|
|
1698
|
+
const clamp = (n: number) => Math.min(max ?? n, Math.max(min ?? n, n));
|
|
1699
|
+
const ariaNow = numberAttr(el, "aria-valuenow");
|
|
1700
|
+
if (ariaNow != null) return clamp(ariaNow);
|
|
1701
|
+
if (el instanceof HTMLInputElement && (el.type === "range" || el.type === "number"))
|
|
1702
|
+
return Number.isFinite(el.valueAsNumber) ? el.valueAsNumber : undefined;
|
|
1703
|
+
// An indeterminate <progress> (no value attribute) has no value.
|
|
1704
|
+
if (el instanceof HTMLProgressElement) return el.hasAttribute("value") ? el.value : undefined;
|
|
1705
|
+
if (el instanceof HTMLMeterElement) return el.value;
|
|
1706
|
+
// An author range role without aria-valuenow falls back to the ARIA default:
|
|
1707
|
+
// the midpoint for slider/scrollbar/separator, the minimum for spinbutton.
|
|
1708
|
+
const lo = min ?? 0;
|
|
1709
|
+
const hi = max ?? 100;
|
|
1710
|
+
if (role === "slider" || role === "scrollbar" || role === "separator") return (lo + hi) / 2;
|
|
1711
|
+
if (role === "spinbutton") return lo;
|
|
1712
|
+
return undefined;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function selectValue(el: HTMLSelectElement): AXValue | undefined {
|
|
1716
|
+
if (el.multiple) return undefined; // multi-select has no single value
|
|
1717
|
+
const option = el.selectedOptions[0];
|
|
1718
|
+
if (!option) return undefined;
|
|
1719
|
+
const text = normalizeText(option.getAttribute("aria-label") || option.textContent);
|
|
1720
|
+
return text ? ax("string", text) : undefined;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function isTextEntryControl(el: Element): boolean {
|
|
1724
|
+
if (el instanceof HTMLTextAreaElement) return true;
|
|
1725
|
+
if (el instanceof HTMLInputElement) return TEXT_ENTRY_INPUTS.has(el.type);
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function valueFor(el: Element, role: string): AXValue | undefined {
|
|
1730
|
+
const override = (el as { __agentAX?: { value?: unknown } }).__agentAX?.value;
|
|
1731
|
+
if (override !== undefined)
|
|
1732
|
+
return ax(typeof override === "number" ? "number" : "string", override as never);
|
|
1733
|
+
|
|
1734
|
+
if (
|
|
1735
|
+
RANGE_ROLES.has(role) ||
|
|
1736
|
+
el instanceof HTMLProgressElement ||
|
|
1737
|
+
el instanceof HTMLMeterElement
|
|
1738
|
+
) {
|
|
1739
|
+
const number = rangeValue(el, role);
|
|
1740
|
+
return number == null ? undefined : ax("number", number);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// <select> reports the displayed text of its selected option, not el.value.
|
|
1744
|
+
if (el instanceof HTMLSelectElement) return selectValue(el);
|
|
1745
|
+
|
|
1746
|
+
// only text-entry controls report their value (a checkbox's "on" or a
|
|
1747
|
+
// button input's label is not a value)
|
|
1748
|
+
if (isTextEntryControl(el)) {
|
|
1749
|
+
if (el instanceof HTMLInputElement && el.type === "password")
|
|
1750
|
+
return el.value ? ax("string", "•".repeat(el.value.length)) : undefined;
|
|
1751
|
+
const value = (el as HTMLInputElement | HTMLTextAreaElement).value;
|
|
1752
|
+
return value ? ax("string", value) : undefined;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// contenteditable elements report their text as the value.
|
|
1756
|
+
if (contentEditable(el)) {
|
|
1757
|
+
const text = normalizeText(el.textContent);
|
|
1758
|
+
return text ? ax("string", text) : undefined;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
return undefined;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function ariaToken(el: Element, name: string): string | boolean | undefined {
|
|
1765
|
+
const value = el.getAttribute(name);
|
|
1766
|
+
if (value == null || value === "" || value === "undefined") return undefined;
|
|
1767
|
+
if (value === "true") return true;
|
|
1768
|
+
if (value === "false") return false;
|
|
1769
|
+
return value;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function relatedNodesFor(registry: DomRegistry, el: Element, ids: string[]): AXRelatedNode[] {
|
|
1773
|
+
return ids.flatMap((idref) => {
|
|
1774
|
+
const related = el.ownerDocument.getElementById(idref);
|
|
1775
|
+
if (!related) return [];
|
|
1776
|
+
return [{ backendDOMNodeId: registry.backendIdFor(related), idref }];
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function addRelationProp(
|
|
1781
|
+
registry: DomRegistry,
|
|
1782
|
+
props: AXProperty[],
|
|
1783
|
+
el: Element,
|
|
1784
|
+
attr: string,
|
|
1785
|
+
name: AXPropertyName,
|
|
1786
|
+
type: "idref" | "idrefList",
|
|
1787
|
+
settings: { omitValue?: boolean } = {},
|
|
1788
|
+
): void {
|
|
1789
|
+
const ids = idRefs(el, attr);
|
|
1790
|
+
if (!ids.length) return;
|
|
1791
|
+
const value: AXValue = { type };
|
|
1792
|
+
if (!settings.omitValue && type === "idref") value.value = ids[0];
|
|
1793
|
+
if (!settings.omitValue && type === "idrefList") value.value = ids.join(" ");
|
|
1794
|
+
const relatedNodes = relatedNodesFor(registry, el, ids);
|
|
1795
|
+
if (relatedNodes.length) value.relatedNodes = relatedNodes;
|
|
1796
|
+
props.push({ name, value });
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function roleSupportsAria(role: string, attr: ARIAProperty): boolean {
|
|
1800
|
+
// "image" is our serialized name for the ARIA "img" role; look up img's definition.
|
|
1801
|
+
const definition = ariaRoles.get((role === "image" ? "img" : role) as never);
|
|
1802
|
+
return !definition || attr in definition.props || attr in definition.requiredProps;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function addAriaProp(
|
|
1806
|
+
props: AXProperty[],
|
|
1807
|
+
el: Element,
|
|
1808
|
+
role: string,
|
|
1809
|
+
attr: ARIAProperty,
|
|
1810
|
+
name: AXPropertyName,
|
|
1811
|
+
type: AXValueType,
|
|
1812
|
+
value: unknown,
|
|
1813
|
+
options: { includeFalse?: boolean } = {},
|
|
1814
|
+
): void {
|
|
1815
|
+
if (!el.hasAttribute(attr) || !roleSupportsAria(role, attr)) return;
|
|
1816
|
+
addProp(props, name, type, value, options);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
const CHECK_DEFAULT_FALSE_ROLES = new Set([
|
|
1820
|
+
"checkbox",
|
|
1821
|
+
"radio",
|
|
1822
|
+
"switch",
|
|
1823
|
+
"menuitemcheckbox",
|
|
1824
|
+
"menuitemradio",
|
|
1825
|
+
]);
|
|
1826
|
+
const SELECTABLE_ROLES = new Set([
|
|
1827
|
+
"option",
|
|
1828
|
+
"tab",
|
|
1829
|
+
"row",
|
|
1830
|
+
"gridcell",
|
|
1831
|
+
"treeitem",
|
|
1832
|
+
"columnheader",
|
|
1833
|
+
"rowheader",
|
|
1834
|
+
]);
|
|
1835
|
+
|
|
1836
|
+
function liveStatus(el: Element, role: string): string | undefined {
|
|
1837
|
+
const aria = el.getAttribute("aria-live");
|
|
1838
|
+
if (aria && aria !== "off") return aria;
|
|
1839
|
+
if (role === "alert") return "assertive";
|
|
1840
|
+
if (role === "status" || role === "log") return "polite";
|
|
1841
|
+
return undefined;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** The invalid token; native form controls always carry one (default false). */
|
|
1845
|
+
function invalidToken(el: Element): string | undefined {
|
|
1846
|
+
const aria = el.getAttribute("aria-invalid");
|
|
1847
|
+
if (aria === "grammar" || aria === "spelling") return aria;
|
|
1848
|
+
if (aria === "true" || aria === "") return "true";
|
|
1849
|
+
if (aria === "false") return "false";
|
|
1850
|
+
if (
|
|
1851
|
+
el instanceof HTMLInputElement ||
|
|
1852
|
+
el instanceof HTMLTextAreaElement ||
|
|
1853
|
+
el instanceof HTMLSelectElement ||
|
|
1854
|
+
el instanceof HTMLButtonElement
|
|
1855
|
+
)
|
|
1856
|
+
return "false";
|
|
1857
|
+
return undefined;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/** The editable token ("plaintext"/"richtext") for editable elements. */
|
|
1861
|
+
function editableToken(el: Element): string | undefined {
|
|
1862
|
+
if (contentEditable(el))
|
|
1863
|
+
return el.getAttribute("contenteditable") === "plaintext-only" ? "plaintext" : "richtext";
|
|
1864
|
+
if (el instanceof HTMLTextAreaElement) return "plaintext";
|
|
1865
|
+
if (el instanceof HTMLInputElement)
|
|
1866
|
+
return TEXT_ENTRY_INPUTS.has(el.type) ? "plaintext" : undefined;
|
|
1867
|
+
return undefined;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/** Only text-entry-like controls are settable (not checkboxes or buttons). */
|
|
1871
|
+
function isSettable(el: Element): boolean {
|
|
1872
|
+
if (isTextEntryControl(el) || contentEditable(el)) return true;
|
|
1873
|
+
if (el instanceof HTMLInputElement)
|
|
1874
|
+
return ["number", "range", "color", "date", "datetime-local", "month", "week", "time"].includes(
|
|
1875
|
+
el.type,
|
|
1876
|
+
);
|
|
1877
|
+
return false;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function checkedState(el: Element, role: string): boolean | "mixed" | undefined {
|
|
1881
|
+
if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio"))
|
|
1882
|
+
return el.indeterminate ? "mixed" : el.checked;
|
|
1883
|
+
const aria = tristateAttr(el, "aria-checked");
|
|
1884
|
+
if (aria !== undefined) return aria;
|
|
1885
|
+
return CHECK_DEFAULT_FALSE_ROLES.has(role) ? false : undefined;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function expandedState(el: Element): boolean | undefined {
|
|
1889
|
+
const aria = boolAttr(el, "aria-expanded");
|
|
1890
|
+
if (aria !== undefined) return aria;
|
|
1891
|
+
const details =
|
|
1892
|
+
el.localName === "details" ? el : el.localName === "summary" ? el.parentElement : null;
|
|
1893
|
+
if (details instanceof HTMLDetailsElement) return details.open;
|
|
1894
|
+
// a single-select <select> is a collapsed popup
|
|
1895
|
+
if (el instanceof HTMLSelectElement && !el.multiple) return false;
|
|
1896
|
+
return undefined;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function isModalDialog(tree: AXTree, el: Element): boolean {
|
|
1900
|
+
return tree.modal === el;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/** Where the dialog focusing steps put focus after showModal(): the first
|
|
1904
|
+
* focusable descendant, else the dialog itself. jsdom never runs these
|
|
1905
|
+
* steps, so emulate them when focus is still on the body. */
|
|
1906
|
+
function modalFocusTarget(tree: AXTree): Element | null {
|
|
1907
|
+
if (!tree.modal) return null;
|
|
1908
|
+
const active = tree.options.document.activeElement;
|
|
1909
|
+
if (active && active !== tree.options.document.body && active.localName !== "html") return null;
|
|
1910
|
+
for (const el of Array.from(tree.modal.querySelectorAll("*")))
|
|
1911
|
+
if (!isUnrendered(el) && isFocusable(el)) return el;
|
|
1912
|
+
return tree.modal;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// propertiesFor follows Chromium's Fill* phase order:
|
|
1916
|
+
// live-region -> global states -> widget properties -> widget states -> relations
|
|
1917
|
+
function propertiesFor(tree: AXTree, el: Element, role: string, name: NameInfo): AXProperty[] {
|
|
1918
|
+
const registry = tree.options.registry;
|
|
1919
|
+
const props: AXProperty[] = [];
|
|
1920
|
+
|
|
1921
|
+
// --- Live region (on the region root) ---
|
|
1922
|
+
const live = liveStatus(el, role);
|
|
1923
|
+
if (live) {
|
|
1924
|
+
addProp(props, "live", "token", live);
|
|
1925
|
+
addProp(props, "atomic", "boolean", boolAttr(el, "aria-atomic") === true, {
|
|
1926
|
+
includeFalse: true,
|
|
1927
|
+
});
|
|
1928
|
+
addProp(props, "relevant", "tokenList", el.getAttribute("aria-relevant") || "additions text");
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// --- Global states ---
|
|
1932
|
+
addProp(props, "disabled", "boolean", isDisabled(el));
|
|
1933
|
+
addProp(props, "invalid", "token", invalidToken(el));
|
|
1934
|
+
addProp(props, "focusable", "booleanOrUndefined", isFocusable(el));
|
|
1935
|
+
const focused = el === el.ownerDocument.activeElement || el === modalFocusTarget(tree);
|
|
1936
|
+
addProp(props, "focused", "booleanOrUndefined", focused);
|
|
1937
|
+
addProp(props, "editable", "token", editableToken(el));
|
|
1938
|
+
if (isSettable(el)) addProp(props, "settable", "booleanOrUndefined", true);
|
|
1939
|
+
|
|
1940
|
+
// --- Widget properties ---
|
|
1941
|
+
addProp(props, "autocomplete", "token", ariaToken(el, "aria-autocomplete"));
|
|
1942
|
+
const haspopup = el.getAttribute("aria-haspopup");
|
|
1943
|
+
if (haspopup && haspopup !== "false") addProp(props, "hasPopup", "token", haspopup);
|
|
1944
|
+
else if (el instanceof HTMLSelectElement && !el.multiple)
|
|
1945
|
+
addProp(props, "hasPopup", "token", "menu");
|
|
1946
|
+
addProp(props, "level", "integer", headingLevel(el));
|
|
1947
|
+
if (MULTISELECTABLE_ROLES.has(role))
|
|
1948
|
+
addProp(props, "multiselectable", "boolean", multiselectableState(el), { includeFalse: true });
|
|
1949
|
+
addProp(props, "orientation", "token", ariaToken(el, "aria-orientation"));
|
|
1950
|
+
if (role === "textbox" || role === "searchbox")
|
|
1951
|
+
addProp(
|
|
1952
|
+
props,
|
|
1953
|
+
"multiline",
|
|
1954
|
+
"boolean",
|
|
1955
|
+
el.localName === "textarea" || boolAttr(el, "aria-multiline") === true,
|
|
1956
|
+
{ includeFalse: true },
|
|
1957
|
+
);
|
|
1958
|
+
if (READONLY_ROLES.has(role))
|
|
1959
|
+
addProp(props, "readonly", "boolean", readonlyState(el), { includeFalse: true });
|
|
1960
|
+
// native <select> (kComboBoxSelect) does not expose required, ARIA comboboxes do
|
|
1961
|
+
if (REQUIRED_ROLES.has(role) && !(el instanceof HTMLSelectElement))
|
|
1962
|
+
addProp(props, "required", "boolean", requiredState(el), { includeFalse: true });
|
|
1963
|
+
if (RANGE_ROLES.has(role)) {
|
|
1964
|
+
addProp(props, "valuemin", "number", numberAttr(el, "aria-valuemin") ?? nativeMin(el, role));
|
|
1965
|
+
addProp(props, "valuemax", "number", numberAttr(el, "aria-valuemax") ?? nativeMax(el, role));
|
|
1966
|
+
addProp(props, "valuetext", "string", el.getAttribute("aria-valuetext"));
|
|
1967
|
+
}
|
|
1968
|
+
if (role === "link" && el instanceof HTMLAnchorElement) addProp(props, "url", "string", el.href);
|
|
1969
|
+
if (role === "image" && el instanceof HTMLImageElement) addProp(props, "url", "string", el.src);
|
|
1970
|
+
if (el instanceof HTMLInputElement && el.type === "image")
|
|
1971
|
+
addProp(props, "url", "string", el.src);
|
|
1972
|
+
|
|
1973
|
+
// --- Widget states ---
|
|
1974
|
+
const pressed = tristateAttr(el, "aria-pressed");
|
|
1975
|
+
if (pressed !== undefined) {
|
|
1976
|
+
addProp(props, "pressed", "tristate", tristateValue(pressed), { includeFalse: true });
|
|
1977
|
+
} else {
|
|
1978
|
+
const checked = checkedState(el, role);
|
|
1979
|
+
if (checked !== undefined)
|
|
1980
|
+
addProp(props, "checked", "tristate", tristateValue(checked), { includeFalse: true });
|
|
1981
|
+
}
|
|
1982
|
+
const expanded = expandedState(el);
|
|
1983
|
+
if (expanded !== undefined)
|
|
1984
|
+
addProp(props, "expanded", "booleanOrUndefined", expanded, { includeFalse: true });
|
|
1985
|
+
if (SELECTABLE_ROLES.has(role)) {
|
|
1986
|
+
const selected = el instanceof HTMLOptionElement ? el.selected : boolAttr(el, "aria-selected");
|
|
1987
|
+
if (selected !== undefined)
|
|
1988
|
+
addProp(props, "selected", "booleanOrUndefined", selected, { includeFalse: true });
|
|
1989
|
+
}
|
|
1990
|
+
if (el.localName === "dialog") {
|
|
1991
|
+
// native dialogs always expose their modal state
|
|
1992
|
+
addProp(props, "modal", "boolean", isModalDialog(tree, el), { includeFalse: true });
|
|
1993
|
+
} else {
|
|
1994
|
+
addAriaProp(props, el, role, "aria-modal", "modal", "boolean", boolAttr(el, "aria-modal"), {
|
|
1995
|
+
includeFalse: true,
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// --- Relationships (FillRelationships then FillSparseAttributes order) ---
|
|
2000
|
+
addRelationProp(registry, props, el, "aria-describedby", "describedby", "idrefList");
|
|
2001
|
+
addRelationProp(registry, props, el, "aria-owns", "owns", "idrefList");
|
|
2002
|
+
addAriaProp(props, el, role, "aria-busy", "busy", "boolean", boolAttr(el, "aria-busy"));
|
|
2003
|
+
addAriaProp(
|
|
2004
|
+
props,
|
|
2005
|
+
el,
|
|
2006
|
+
role,
|
|
2007
|
+
"aria-keyshortcuts",
|
|
2008
|
+
"keyshortcuts",
|
|
2009
|
+
"string",
|
|
2010
|
+
el.getAttribute("aria-keyshortcuts"),
|
|
2011
|
+
);
|
|
2012
|
+
addAriaProp(
|
|
2013
|
+
props,
|
|
2014
|
+
el,
|
|
2015
|
+
role,
|
|
2016
|
+
"aria-roledescription",
|
|
2017
|
+
"roledescription",
|
|
2018
|
+
"string",
|
|
2019
|
+
el.getAttribute("aria-roledescription"),
|
|
2020
|
+
);
|
|
2021
|
+
addRelationProp(registry, props, el, "aria-activedescendant", "activedescendant", "idref", {
|
|
2022
|
+
omitValue: true,
|
|
2023
|
+
});
|
|
2024
|
+
addRelationProp(registry, props, el, "aria-errormessage", "errormessage", "idrefList");
|
|
2025
|
+
addRelationProp(registry, props, el, "aria-controls", "controls", "idrefList");
|
|
2026
|
+
addRelationProp(registry, props, el, "aria-details", "details", "idrefList");
|
|
2027
|
+
addRelationProp(registry, props, el, "aria-flowto", "flowto", "idrefList");
|
|
2028
|
+
// labelledby reflects the WINNING name source (aria-labelledby or a native
|
|
2029
|
+
// label), not the mere presence of the attribute.
|
|
2030
|
+
if (name.labelledbyRelated?.length)
|
|
2031
|
+
props.push({
|
|
2032
|
+
name: "labelledby",
|
|
2033
|
+
value: { type: "nodeList", relatedNodes: name.labelledbyRelated },
|
|
2034
|
+
});
|
|
2035
|
+
return props;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// ---------------------------------------------------------------------------
|
|
2039
|
+
// Wire serialization (Chromium's BuildProtocolAXNodeFor*)
|
|
2040
|
+
// ---------------------------------------------------------------------------
|
|
2041
|
+
|
|
2042
|
+
function childWireIds(tree: AXTree, obj: AXObj): string[] {
|
|
2043
|
+
const ids: string[] = [];
|
|
2044
|
+
if (obj.markerText) ids.push(`${obj.id}:marker`);
|
|
2045
|
+
for (const child of obj.children) ids.push(child.id);
|
|
2046
|
+
return ids;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function markerNodes(tree: AXTree, obj: AXObj): AXNode[] {
|
|
2050
|
+
if (!obj.markerText) return [];
|
|
2051
|
+
const registry = tree.options.registry;
|
|
2052
|
+
const markerId = `${obj.id}:marker`;
|
|
2053
|
+
const textId = `${markerId}:text`;
|
|
2054
|
+
return [
|
|
2055
|
+
{
|
|
2056
|
+
nodeId: markerId,
|
|
2057
|
+
ignored: false,
|
|
2058
|
+
role: roleNameValue("ListMarker"),
|
|
2059
|
+
chromeRole: chromeRoleValue("ListMarker"),
|
|
2060
|
+
name: ax("computedString", obj.markerText),
|
|
2061
|
+
parentId: obj.id,
|
|
2062
|
+
backendDOMNodeId: registry.backendIdFor(obj.node),
|
|
2063
|
+
childIds: [textId],
|
|
2064
|
+
},
|
|
2065
|
+
{
|
|
2066
|
+
nodeId: textId,
|
|
2067
|
+
ignored: false,
|
|
2068
|
+
role: roleNameValue("StaticText"),
|
|
2069
|
+
chromeRole: chromeRoleValue("StaticText"),
|
|
2070
|
+
name: ax("computedString", obj.markerText),
|
|
2071
|
+
parentId: markerId,
|
|
2072
|
+
backendDOMNodeId: registry.backendIdFor(obj.node),
|
|
2073
|
+
childIds: [],
|
|
2074
|
+
},
|
|
2075
|
+
];
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
function forcedRoleOf(obj: AXObj): { role: AXValue; chrome: AXValue } {
|
|
2079
|
+
if (obj.wireRole == null) {
|
|
2080
|
+
// presentational / role-less ignored nodes force to none (Blink RoleValue kNone)
|
|
2081
|
+
const generic = obj.reasons?.some(
|
|
2082
|
+
(reason) =>
|
|
2083
|
+
reason.name === "uninteresting" ||
|
|
2084
|
+
reason.name === "notRendered" ||
|
|
2085
|
+
reason.name === "notVisible" ||
|
|
2086
|
+
reason.name === "ariaHiddenElement" ||
|
|
2087
|
+
reason.name === "ariaHiddenSubtree" ||
|
|
2088
|
+
reason.name === "inertElement" ||
|
|
2089
|
+
reason.name === "activeModalDialog",
|
|
2090
|
+
);
|
|
2091
|
+
if (generic && !obj.reasons?.some((reason) => reason.name === "presentationalRole"))
|
|
2092
|
+
return { role: ax("role", "generic"), chrome: chromeRoleValue("GenericContainer") };
|
|
2093
|
+
return { role: ax("role", "none"), chrome: chromeRoleValue("None") };
|
|
2094
|
+
}
|
|
2095
|
+
return { role: roleNameValue(obj.wireRole), chrome: chromeRoleValue(obj.mojom) };
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function nameOf(tree: AXTree, obj: AXObj): NameInfo {
|
|
2099
|
+
if (obj.isRoot) {
|
|
2100
|
+
const title = tree.options.document.title || "";
|
|
2101
|
+
return { value: title, sources: rootNameSources(title) };
|
|
2102
|
+
}
|
|
2103
|
+
if (obj.text !== undefined) {
|
|
2104
|
+
return {
|
|
2105
|
+
value: obj.text,
|
|
2106
|
+
sources: [{ type: "contents", value: ax("computedString", obj.text) }],
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
if (obj.isPopup) return { value: "", sources: [] };
|
|
2110
|
+
const el = obj.node as Element;
|
|
2111
|
+
return computeName(tree, el, obj.wireRole ?? "generic");
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
/** The name used for queryAXTree matching: suppressed (empty) for hidden /
|
|
2115
|
+
* presentational nodes, mirroring Blink's ComputedName(). */
|
|
2116
|
+
function matchName(tree: AXTree, obj: AXObj): string {
|
|
2117
|
+
if (obj.ignored && obj.nameSuppressed) return "";
|
|
2118
|
+
return nameOf(tree, obj).value;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function wireNode(tree: AXTree, obj: AXObj, force = false): AXNode {
|
|
2122
|
+
const registry = tree.options.registry;
|
|
2123
|
+
const node: AXNode = {
|
|
2124
|
+
nodeId: obj.id,
|
|
2125
|
+
ignored: obj.ignored,
|
|
2126
|
+
};
|
|
2127
|
+
|
|
2128
|
+
if (obj.ignored && !force) {
|
|
2129
|
+
node.role = ax("role", "none");
|
|
2130
|
+
node.chromeRole = chromeRoleValue("None");
|
|
2131
|
+
node.ignoredReasons = obj.reasons ?? [];
|
|
2132
|
+
} else if (obj.ignored && force) {
|
|
2133
|
+
const forced = forcedRoleOf(obj);
|
|
2134
|
+
node.role = forced.role;
|
|
2135
|
+
node.chromeRole = forced.chrome;
|
|
2136
|
+
node.name = ax("computedString", matchName(tree, obj));
|
|
2137
|
+
node.ignoredReasons = obj.reasons ?? [];
|
|
2138
|
+
} else {
|
|
2139
|
+
node.role = roleNameValue(obj.wireRole ?? "generic");
|
|
2140
|
+
node.chromeRole = chromeRoleValue(obj.mojom);
|
|
2141
|
+
const name = nameOf(tree, obj);
|
|
2142
|
+
const nameValue = ax("computedString", name.value);
|
|
2143
|
+
if (name.sources.length) nameValue.sources = name.sources;
|
|
2144
|
+
node.name = nameValue;
|
|
2145
|
+
if (obj.isRoot) {
|
|
2146
|
+
node.properties = [
|
|
2147
|
+
{ name: "focusable", value: ax("booleanOrUndefined", true) },
|
|
2148
|
+
{ name: "url", value: ax("string", tree.options.document.URL) },
|
|
2149
|
+
];
|
|
2150
|
+
} else if (obj.text !== undefined || obj.isPopup) {
|
|
2151
|
+
node.properties = [];
|
|
2152
|
+
} else {
|
|
2153
|
+
const el = obj.node as Element;
|
|
2154
|
+
const role = obj.wireRole ?? "generic";
|
|
2155
|
+
const description = descriptionFor(el, role, name);
|
|
2156
|
+
if (description) node.description = ax("computedString", description);
|
|
2157
|
+
const value = valueFor(el, role);
|
|
2158
|
+
if (value) node.value = value;
|
|
2159
|
+
node.properties = propertiesFor(tree, el, role, name);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
node.childIds = childWireIds(tree, obj);
|
|
2164
|
+
// the synthetic MenuListPopup has no backing DOM node
|
|
2165
|
+
if (!obj.isPopup) node.backendDOMNodeId = registry.backendIdFor(obj.node);
|
|
2166
|
+
if (obj.parent) node.parentId = obj.parent.id;
|
|
2167
|
+
else if (obj.isRoot) node.frameId = tree.options.frameId;
|
|
2168
|
+
return node;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function descriptionFor(el: Element, role: string, nameInfo: NameInfo): string | undefined {
|
|
2172
|
+
const name = nameInfo.value;
|
|
2173
|
+
let description = normalizeText(computeAccessibleDescription(el));
|
|
2174
|
+
// a <summary> named by author ARIA gets its contents as the description,
|
|
2175
|
+
// taking priority over title (Blink's disclosure-triangle description)
|
|
2176
|
+
if (nameInfo.fromAuthorAria && el.localName === "summary") {
|
|
2177
|
+
const contents = textEquivalent(el);
|
|
2178
|
+
if (contents && contents !== name) return contents;
|
|
2179
|
+
}
|
|
2180
|
+
// a <summary> outside <details> is generic; its contents become its description
|
|
2181
|
+
if (!description && el.localName === "summary" && el.parentElement?.localName !== "details")
|
|
2182
|
+
description = textEquivalent(el);
|
|
2183
|
+
// a button input's value attribute describes it when the name came from elsewhere
|
|
2184
|
+
if (
|
|
2185
|
+
(!description || description === name) &&
|
|
2186
|
+
el instanceof HTMLInputElement &&
|
|
2187
|
+
["button", "submit", "reset"].includes(el.type)
|
|
2188
|
+
) {
|
|
2189
|
+
const value = normalizeText(el.getAttribute("value"));
|
|
2190
|
+
if (value && value !== name) description = value;
|
|
2191
|
+
}
|
|
2192
|
+
if (!description || description === name) return undefined;
|
|
2193
|
+
return description;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/** Chromium's BuildProtocolAXNodeForDOMNodeWithNoAXNode: returned when the
|
|
2197
|
+
* inspected DOM node has no AX object at all (head, script, …). */
|
|
2198
|
+
function noAXNodeFor(registry: DomRegistry, domNode: Node): AXNode {
|
|
2199
|
+
return {
|
|
2200
|
+
nodeId: "0",
|
|
2201
|
+
ignored: true,
|
|
2202
|
+
role: ax("role", "none"),
|
|
2203
|
+
chromeRole: chromeRoleValue("None"),
|
|
2204
|
+
ignoredReasons: [ignoredReason("notRendered")],
|
|
2205
|
+
backendDOMNodeId: registry.backendIdFor(domNode),
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// ---------------------------------------------------------------------------
|
|
2210
|
+
// Traversals (Chromium's AddChildren / AddAncestors / WalkAXNodesToDepth)
|
|
2211
|
+
// ---------------------------------------------------------------------------
|
|
2212
|
+
|
|
2213
|
+
/** Emit all included children; ignored children contribute another layer
|
|
2214
|
+
* (follow_ignored), depth-first like Chromium's AddChildren. Returns the
|
|
2215
|
+
* emitted objs and the unignored ones among them (the next BFS level). */
|
|
2216
|
+
function addChildren(obj: AXObj): { emitted: AXObj[]; unignored: AXObj[] } {
|
|
2217
|
+
const emitted: AXObj[] = [];
|
|
2218
|
+
const unignored: AXObj[] = [];
|
|
2219
|
+
const reachable = [...obj.children];
|
|
2220
|
+
while (reachable.length) {
|
|
2221
|
+
const descendant = reachable.shift();
|
|
2222
|
+
if (!descendant) continue;
|
|
2223
|
+
if (descendant.ignored) reachable.unshift(...descendant.children);
|
|
2224
|
+
else unignored.push(descendant);
|
|
2225
|
+
emitted.push(descendant);
|
|
2226
|
+
}
|
|
2227
|
+
return { emitted, unignored };
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
function nearestObj(tree: AXTree, domNode: Node): AXObj | undefined {
|
|
2231
|
+
return tree.byNode.get(domNode);
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function includedAncestor(obj: AXObj): AXObj | undefined {
|
|
2235
|
+
for (let cur = obj.parent; cur; cur = cur.parent) if (cur.included) return cur;
|
|
2236
|
+
return undefined;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
// ---------------------------------------------------------------------------
|
|
2240
|
+
// CDP methods
|
|
2241
|
+
// ---------------------------------------------------------------------------
|
|
2242
|
+
|
|
2243
|
+
export function getFullAXTree(
|
|
2244
|
+
options: AXTreeOptions,
|
|
2245
|
+
depth?: number,
|
|
2246
|
+
): Protocol.Accessibility.GetFullAXTreeResponse {
|
|
2247
|
+
const tree = buildTree(options);
|
|
2248
|
+
const maxDepth = depth == null || depth < 0 ? -1 : depth;
|
|
2249
|
+
const nodes: AXNode[] = [wireNode(tree, tree.root)];
|
|
2250
|
+
const queue: Array<{ obj: AXObj; depth: number }> = [{ obj: tree.root, depth: 1 }];
|
|
2251
|
+
while (queue.length) {
|
|
2252
|
+
const item = queue.shift();
|
|
2253
|
+
if (!item) continue;
|
|
2254
|
+
const { emitted, unignored } = addChildren(item.obj);
|
|
2255
|
+
for (const child of emitted) {
|
|
2256
|
+
nodes.push(wireNode(tree, child));
|
|
2257
|
+
nodes.push(...markerNodes(tree, child));
|
|
2258
|
+
}
|
|
2259
|
+
for (const child of unignored) {
|
|
2260
|
+
if (maxDepth === -1 || item.depth < maxDepth)
|
|
2261
|
+
queue.push({ obj: child, depth: item.depth + 1 });
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
return { nodes };
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* Fetch the AX node for a DOM node, optionally with its children and ancestor
|
|
2269
|
+
* chain (Chromium's getPartialAXTree). With no target, returns the whole tree
|
|
2270
|
+
* (back-compat with earlier clients).
|
|
2271
|
+
*/
|
|
2272
|
+
export function getPartialAXTree(
|
|
2273
|
+
options: AXTreeOptions,
|
|
2274
|
+
target?: Protocol.DOM.BackendNodeId,
|
|
2275
|
+
fetchRelatives = true,
|
|
2276
|
+
): Protocol.Accessibility.GetPartialAXTreeResponse {
|
|
2277
|
+
if (target == null) return getFullAXTree(options);
|
|
2278
|
+
const tree = buildTree(options);
|
|
2279
|
+
const domNode = options.registry.nodeForBackendId(target);
|
|
2280
|
+
if (!domNode) return { nodes: [] };
|
|
2281
|
+
const obj = nearestObj(tree, domNode);
|
|
2282
|
+
|
|
2283
|
+
const nodes: AXNode[] = [];
|
|
2284
|
+
if (obj) nodes.push(wireNode(tree, obj));
|
|
2285
|
+
else nodes.push(noAXNodeFor(options.registry, domNode));
|
|
2286
|
+
if (!fetchRelatives) return { nodes };
|
|
2287
|
+
|
|
2288
|
+
if (obj && !obj.ignored)
|
|
2289
|
+
for (const child of addChildren(obj).emitted) nodes.push(wireNode(tree, child));
|
|
2290
|
+
|
|
2291
|
+
let parent: AXObj | undefined;
|
|
2292
|
+
if (obj) parent = obj.included ? obj.parent : (includedAncestor(obj) ?? obj.parent);
|
|
2293
|
+
else {
|
|
2294
|
+
// walk up the DOM until a node with an AX object is found
|
|
2295
|
+
for (let cur = domNode.parentNode; cur; cur = cur.parentNode) {
|
|
2296
|
+
parent = nearestObj(tree, cur);
|
|
2297
|
+
if (parent) break;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
if (!parent) return { nodes };
|
|
2301
|
+
|
|
2302
|
+
// Since an ignored/no-AX inspected node may be missing from its ancestor's
|
|
2303
|
+
// childIds, prepend it to maintain the tree structure (Chromium AddAncestors).
|
|
2304
|
+
const firstAncestor = wireNode(tree, parent);
|
|
2305
|
+
if (!obj || obj.ignored)
|
|
2306
|
+
firstAncestor.childIds = [obj ? obj.id : "0", ...(firstAncestor.childIds ?? [])];
|
|
2307
|
+
nodes.push(firstAncestor);
|
|
2308
|
+
for (let cur = parent.parent; cur; cur = cur.parent)
|
|
2309
|
+
if (cur.included) nodes.push(wireNode(tree, cur));
|
|
2310
|
+
return { nodes };
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/** The root (RootWebArea) AX node of the document. */
|
|
2314
|
+
export function getRootAXNode(
|
|
2315
|
+
options: AXTreeOptions,
|
|
2316
|
+
): Protocol.Accessibility.GetRootAXNodeResponse {
|
|
2317
|
+
const tree = buildTree(options);
|
|
2318
|
+
return { node: wireNode(tree, tree.root) };
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/** The children of the AX node with the given AX id (follow-ignored layering). */
|
|
2322
|
+
export function getChildAXNodes(
|
|
2323
|
+
options: AXTreeOptions,
|
|
2324
|
+
id: string,
|
|
2325
|
+
): Protocol.Accessibility.GetChildAXNodesResponse {
|
|
2326
|
+
const tree = buildTree(options);
|
|
2327
|
+
const obj = tree.byId.get(id);
|
|
2328
|
+
if (!obj) throw new Error("Invalid ID");
|
|
2329
|
+
const nodes: AXNode[] = [];
|
|
2330
|
+
for (const child of addChildren(obj).emitted) {
|
|
2331
|
+
nodes.push(wireNode(tree, child));
|
|
2332
|
+
nodes.push(...markerNodes(tree, child));
|
|
2333
|
+
}
|
|
2334
|
+
return { nodes };
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/** The AX node for a DOM node together with every ancestor up to the root. */
|
|
2338
|
+
export function getAXNodeAndAncestors(
|
|
2339
|
+
options: AXTreeOptions,
|
|
2340
|
+
target: Protocol.DOM.BackendNodeId,
|
|
2341
|
+
): Protocol.Accessibility.GetAXNodeAndAncestorsResponse {
|
|
2342
|
+
const tree = buildTree(options);
|
|
2343
|
+
const domNode = options.registry.nodeForBackendId(target);
|
|
2344
|
+
if (!domNode) return { nodes: [] };
|
|
2345
|
+
const obj = nearestObj(tree, domNode);
|
|
2346
|
+
if (!obj) return { nodes: [noAXNodeFor(options.registry, domNode)] };
|
|
2347
|
+
const nodes: AXNode[] = [wireNode(tree, obj)];
|
|
2348
|
+
for (let cur = obj.parent; cur; cur = cur.parent)
|
|
2349
|
+
if (cur.included) nodes.push(wireNode(tree, cur));
|
|
2350
|
+
return { nodes };
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/** All AX nodes in a subtree matching the given accessible name and/or role. */
|
|
2354
|
+
export function queryAXTree(
|
|
2355
|
+
options: AXTreeOptions,
|
|
2356
|
+
query: { target?: Protocol.DOM.BackendNodeId; accessibleName?: string; role?: string },
|
|
2357
|
+
): Protocol.Accessibility.QueryAXTreeResponse {
|
|
2358
|
+
const tree = buildTree(options);
|
|
2359
|
+
let root: AXObj | undefined;
|
|
2360
|
+
if (query.target == null) {
|
|
2361
|
+
root = tree.root;
|
|
2362
|
+
} else {
|
|
2363
|
+
let domNode = options.registry.nodeForBackendId(query.target);
|
|
2364
|
+
// shadow roots are missing from the AX tree; search from the host instead
|
|
2365
|
+
if (domNode && domNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE)
|
|
2366
|
+
domNode = (domNode as ShadowRoot).host ?? domNode;
|
|
2367
|
+
if (domNode?.nodeType === Node.DOCUMENT_NODE) root = tree.root;
|
|
2368
|
+
else if (domNode) root = nearestObj(tree, domNode);
|
|
2369
|
+
}
|
|
2370
|
+
if (!root) return { nodes: [] };
|
|
2371
|
+
|
|
2372
|
+
const matches: AXNode[] = [];
|
|
2373
|
+
const stack: AXObj[] = [root];
|
|
2374
|
+
while (stack.length) {
|
|
2375
|
+
const obj = stack.pop();
|
|
2376
|
+
if (!obj) continue;
|
|
2377
|
+
for (let i = obj.children.length - 1; i >= 0; i--) {
|
|
2378
|
+
const child = obj.children[i];
|
|
2379
|
+
if (child) stack.push(child);
|
|
2380
|
+
}
|
|
2381
|
+
if (!obj.included) continue;
|
|
2382
|
+
if (query.role != null) {
|
|
2383
|
+
const role = obj.ignored ? forcedRoleOf(obj).role.value : obj.wireRole;
|
|
2384
|
+
if (role !== query.role) continue;
|
|
2385
|
+
}
|
|
2386
|
+
if (query.accessibleName != null) {
|
|
2387
|
+
const name = obj.ignored ? matchName(tree, obj) : nameOf(tree, obj).value;
|
|
2388
|
+
if (name !== query.accessibleName) continue;
|
|
2389
|
+
}
|
|
2390
|
+
matches.push(wireNode(tree, obj, true));
|
|
2391
|
+
}
|
|
2392
|
+
return { nodes: matches };
|
|
2393
|
+
}
|