runline 0.22.0 → 0.24.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/dist/plugins/shiftOcr/src/index.js +13 -0
- package/dist/plugins/shiftPages/src/pages.js +72 -15
- package/dist/plugins/shiftPages/src/shared.js +5 -0
- package/dist/plugins/shiftTranscription/src/index.js +1 -1
- package/dist/plugins/shiftTranscription/src/transcription.js +21 -5
- package/dist/plugins/shiftWork/src/issues.js +119 -33
- package/dist/plugins/steel/src/browser.js +11 -79
- package/dist/plugins/steel/src/index.js +2 -0
- package/dist/plugins/steel/src/page/bundle.generated.js +11 -0
- package/dist/plugins/steel/src/page/cdp.js +232 -0
- package/dist/plugins/steel/src/page/driver.js +286 -0
- package/dist/plugins/steel/src/page/entry.js +91 -0
- package/dist/plugins/steel/src/page/install.js +3 -0
- package/dist/plugins/steel/src/page/page-snapshot.js +330 -0
- package/dist/plugins/steel/src/page/tools.js +649 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshot.js +411 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshotDistiller.js +207 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaTypes.js +62 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/cssTokenizer.js +949 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/domUtils.js +157 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/roleUtils.js +1193 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/stringUtils.js +194 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/yaml.js +80 -0
- package/dist/plugins/steel/src/page/wait-for.js +30 -0
- package/dist/plugins/steel/src/sessions.js +2 -7
- package/dist/plugins/steel/src/shared.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import * as css from './cssTokenizer.js';
|
|
17
|
+
import { beginDOMCaches, closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, endDOMCaches, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils.js';
|
|
18
|
+
function hasExplicitAccessibleName(e) {
|
|
19
|
+
return e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby');
|
|
20
|
+
}
|
|
21
|
+
// https://www.w3.org/TR/wai-aria-practices/examples/landmarks/HTML5.html
|
|
22
|
+
const kAncestorPreventingLandmark = 'article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]';
|
|
23
|
+
// https://www.w3.org/TR/wai-aria-1.2/#global_states
|
|
24
|
+
const kGlobalAriaAttributes = [
|
|
25
|
+
['aria-atomic', undefined],
|
|
26
|
+
['aria-busy', undefined],
|
|
27
|
+
['aria-controls', undefined],
|
|
28
|
+
['aria-current', undefined],
|
|
29
|
+
['aria-describedby', undefined],
|
|
30
|
+
['aria-details', undefined],
|
|
31
|
+
// Global use deprecated in ARIA 1.2
|
|
32
|
+
// ['aria-disabled', undefined],
|
|
33
|
+
['aria-dropeffect', undefined],
|
|
34
|
+
// Global use deprecated in ARIA 1.2
|
|
35
|
+
// ['aria-errormessage', undefined],
|
|
36
|
+
['aria-flowto', undefined],
|
|
37
|
+
['aria-grabbed', undefined],
|
|
38
|
+
// Global use deprecated in ARIA 1.2
|
|
39
|
+
// ['aria-haspopup', undefined],
|
|
40
|
+
['aria-hidden', undefined],
|
|
41
|
+
// Global use deprecated in ARIA 1.2
|
|
42
|
+
// ['aria-invalid', undefined],
|
|
43
|
+
['aria-keyshortcuts', undefined],
|
|
44
|
+
['aria-label', ['caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion', 'paragraph', 'presentation', 'strong', 'subscript', 'superscript']],
|
|
45
|
+
['aria-labelledby', ['caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion', 'paragraph', 'presentation', 'strong', 'subscript', 'superscript']],
|
|
46
|
+
['aria-live', undefined],
|
|
47
|
+
['aria-owns', undefined],
|
|
48
|
+
['aria-relevant', undefined],
|
|
49
|
+
['aria-roledescription', ['generic']],
|
|
50
|
+
];
|
|
51
|
+
function hasGlobalAriaAttribute(element, forRole) {
|
|
52
|
+
return kGlobalAriaAttributes.some(([attr, prohibited]) => {
|
|
53
|
+
return !prohibited?.includes(forRole || '') && element.hasAttribute(attr);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function hasTabIndex(element) {
|
|
57
|
+
return !Number.isNaN(Number(String(element.getAttribute('tabindex'))));
|
|
58
|
+
}
|
|
59
|
+
function isFocusable(element) {
|
|
60
|
+
// TODO:
|
|
61
|
+
// - "inert" attribute makes the whole substree not focusable
|
|
62
|
+
// - when dialog is open on the page - everything but the dialog is not focusable
|
|
63
|
+
return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element));
|
|
64
|
+
}
|
|
65
|
+
function isNativelyFocusable(element) {
|
|
66
|
+
const tagName = elementSafeTagName(element);
|
|
67
|
+
if (['BUTTON', 'DETAILS', 'SELECT', 'TEXTAREA'].includes(tagName))
|
|
68
|
+
return true;
|
|
69
|
+
if (tagName === 'A' || tagName === 'AREA')
|
|
70
|
+
return element.hasAttribute('href');
|
|
71
|
+
if (tagName === 'INPUT')
|
|
72
|
+
return !element.hidden;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
// https://w3c.github.io/html-aam/#html-element-role-mappings
|
|
76
|
+
// https://www.w3.org/TR/html-aria/#docconformance
|
|
77
|
+
const kImplicitRoleByTagName = {
|
|
78
|
+
'A': (e) => {
|
|
79
|
+
return e.hasAttribute('href') ? 'link' : null;
|
|
80
|
+
},
|
|
81
|
+
'AREA': (e) => {
|
|
82
|
+
return e.hasAttribute('href') ? 'link' : null;
|
|
83
|
+
},
|
|
84
|
+
'ARTICLE': () => 'article',
|
|
85
|
+
'ASIDE': () => 'complementary',
|
|
86
|
+
'BLOCKQUOTE': () => 'blockquote',
|
|
87
|
+
'BUTTON': () => 'button',
|
|
88
|
+
'CAPTION': () => 'caption',
|
|
89
|
+
'CODE': () => 'code',
|
|
90
|
+
'DATALIST': () => 'listbox',
|
|
91
|
+
'DD': () => 'definition',
|
|
92
|
+
'DEL': () => 'deletion',
|
|
93
|
+
'DETAILS': () => 'group',
|
|
94
|
+
'DFN': () => 'term',
|
|
95
|
+
'DIALOG': () => 'dialog',
|
|
96
|
+
'DT': () => 'term',
|
|
97
|
+
'EM': () => 'emphasis',
|
|
98
|
+
'FIELDSET': () => 'group',
|
|
99
|
+
'FIGURE': () => 'figure',
|
|
100
|
+
'FOOTER': (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : 'contentinfo',
|
|
101
|
+
'FORM': (e) => hasExplicitAccessibleName(e) ? 'form' : null,
|
|
102
|
+
'H1': () => 'heading',
|
|
103
|
+
'H2': () => 'heading',
|
|
104
|
+
'H3': () => 'heading',
|
|
105
|
+
'H4': () => 'heading',
|
|
106
|
+
'H5': () => 'heading',
|
|
107
|
+
'H6': () => 'heading',
|
|
108
|
+
'HEADER': (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : 'banner',
|
|
109
|
+
'HR': () => 'separator',
|
|
110
|
+
'HTML': () => 'document',
|
|
111
|
+
'IMG': (e) => (e.getAttribute('alt') === '') && !e.getAttribute('title') && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? 'presentation' : 'img',
|
|
112
|
+
'INPUT': (e) => {
|
|
113
|
+
const type = e.type.toLowerCase();
|
|
114
|
+
if (type === 'search')
|
|
115
|
+
return e.hasAttribute('list') ? 'combobox' : 'searchbox';
|
|
116
|
+
if (['email', 'tel', 'text', 'url', ''].includes(type)) {
|
|
117
|
+
// https://html.spec.whatwg.org/multipage/input.html#concept-input-list
|
|
118
|
+
const list = getIdRefs(e, e.getAttribute('list'))[0];
|
|
119
|
+
return (list && elementSafeTagName(list) === 'DATALIST') ? 'combobox' : 'textbox';
|
|
120
|
+
}
|
|
121
|
+
if (type === 'hidden')
|
|
122
|
+
return null;
|
|
123
|
+
// File inputs do not have a role by the spec: https://www.w3.org/TR/html-aam-1.0/#el-input-file.
|
|
124
|
+
// However, there are open issues about fixing it: https://github.com/w3c/aria/issues/1926.
|
|
125
|
+
// All browsers report it as a button, and it is rendered as a button, so we do "button".
|
|
126
|
+
if (type === 'file')
|
|
127
|
+
return 'button';
|
|
128
|
+
return inputTypeToRole[type] || 'textbox';
|
|
129
|
+
},
|
|
130
|
+
'INS': () => 'insertion',
|
|
131
|
+
'LI': () => 'listitem',
|
|
132
|
+
'MAIN': () => 'main',
|
|
133
|
+
'MARK': () => 'mark',
|
|
134
|
+
'MATH': () => 'math',
|
|
135
|
+
'MENU': () => 'list',
|
|
136
|
+
'METER': () => 'meter',
|
|
137
|
+
'NAV': () => 'navigation',
|
|
138
|
+
'OL': () => 'list',
|
|
139
|
+
'OPTGROUP': () => 'group',
|
|
140
|
+
'OPTION': () => 'option',
|
|
141
|
+
'OUTPUT': () => 'status',
|
|
142
|
+
'P': () => 'paragraph',
|
|
143
|
+
'PROGRESS': () => 'progressbar',
|
|
144
|
+
'SEARCH': () => 'search',
|
|
145
|
+
'SECTION': (e) => hasExplicitAccessibleName(e) ? 'region' : null,
|
|
146
|
+
'SELECT': (e) => e.hasAttribute('multiple') || e.size > 1 ? 'listbox' : 'combobox',
|
|
147
|
+
'STRONG': () => 'strong',
|
|
148
|
+
'SUB': () => 'subscript',
|
|
149
|
+
'SUP': () => 'superscript',
|
|
150
|
+
// For <svg> we default to Chrome behavior:
|
|
151
|
+
// - Chrome reports 'img'.
|
|
152
|
+
// - Firefox reports 'diagram' that is not in official ARIA spec yet.
|
|
153
|
+
// - Safari reports 'no role', but still computes accessible name.
|
|
154
|
+
'SVG': () => 'img',
|
|
155
|
+
'TABLE': () => 'table',
|
|
156
|
+
'TBODY': () => 'rowgroup',
|
|
157
|
+
'TD': (e) => {
|
|
158
|
+
const table = closestCrossShadow(e, 'table');
|
|
159
|
+
const role = table ? getExplicitAriaRole(table) : '';
|
|
160
|
+
return (role === 'grid' || role === 'treegrid') ? 'gridcell' : 'cell';
|
|
161
|
+
},
|
|
162
|
+
'TEXTAREA': () => 'textbox',
|
|
163
|
+
'TFOOT': () => 'rowgroup',
|
|
164
|
+
'TH': (e) => {
|
|
165
|
+
const scope = e.getAttribute('scope');
|
|
166
|
+
if (scope === 'col' || scope === 'colgroup')
|
|
167
|
+
return 'columnheader';
|
|
168
|
+
if (scope === 'row' || scope === 'rowgroup')
|
|
169
|
+
return 'rowheader';
|
|
170
|
+
const nextSibling = e.nextElementSibling;
|
|
171
|
+
const prevSibling = e.previousElementSibling;
|
|
172
|
+
const row = !!e.parentElement && elementSafeTagName(e.parentElement) === 'TR' ? e.parentElement : undefined;
|
|
173
|
+
// Chromium/Safari: A TH that is the only cell in a table is not labeling any content, thus it's technically not a header. Do not assign a role.
|
|
174
|
+
// Firefox: Follows the spec and assigns `columnheader`. We prioritize Chrome/Safari semantics.
|
|
175
|
+
if (!nextSibling && !prevSibling) {
|
|
176
|
+
if (row) {
|
|
177
|
+
const table = closestCrossShadow(row, 'table');
|
|
178
|
+
// If there's only one row in the table, this TH has no column to head
|
|
179
|
+
if (table && table.rows.length <= 1)
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return 'columnheader';
|
|
183
|
+
}
|
|
184
|
+
// Tables are built up incrementally by iterating over them in a particular pattern. In order to emulate this,
|
|
185
|
+
// we check only immediate siblings and occasionally the parent row
|
|
186
|
+
// This doesn't seem to directly follow the spec, but matches Chromium behavior
|
|
187
|
+
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/accessibility/ax_node_object.cc;l=1585-1623
|
|
188
|
+
if (isHeaderCell(nextSibling) && isHeaderCell(prevSibling))
|
|
189
|
+
return 'columnheader';
|
|
190
|
+
if (isNonEmptyDataCell(nextSibling) || isNonEmptyDataCell(prevSibling))
|
|
191
|
+
return 'rowheader';
|
|
192
|
+
// As long as we didn't exclude it above, it's still a TH, so default to columnheader
|
|
193
|
+
return 'columnheader';
|
|
194
|
+
},
|
|
195
|
+
'THEAD': () => 'rowgroup',
|
|
196
|
+
'TIME': () => 'time',
|
|
197
|
+
'TR': () => 'row',
|
|
198
|
+
'UL': () => 'list',
|
|
199
|
+
};
|
|
200
|
+
function isHeaderCell(element) {
|
|
201
|
+
return !!element && elementSafeTagName(element) === 'TH';
|
|
202
|
+
}
|
|
203
|
+
function isNonEmptyDataCell(element) {
|
|
204
|
+
if (!element || elementSafeTagName(element) !== 'TD')
|
|
205
|
+
return false;
|
|
206
|
+
return !!(element.textContent?.trim() || element.children.length > 0);
|
|
207
|
+
}
|
|
208
|
+
const kPresentationInheritanceParents = {
|
|
209
|
+
'DD': ['DL', 'DIV'],
|
|
210
|
+
'DIV': ['DL'],
|
|
211
|
+
'DT': ['DL', 'DIV'],
|
|
212
|
+
'LI': ['OL', 'UL'],
|
|
213
|
+
'TBODY': ['TABLE'],
|
|
214
|
+
'TD': ['TR'],
|
|
215
|
+
'TFOOT': ['TABLE'],
|
|
216
|
+
'TH': ['TR'],
|
|
217
|
+
'THEAD': ['TABLE'],
|
|
218
|
+
'TR': ['THEAD', 'TBODY', 'TFOOT', 'TABLE'],
|
|
219
|
+
};
|
|
220
|
+
function getImplicitAriaRole(element) {
|
|
221
|
+
const implicitRole = kImplicitRoleByTagName[elementSafeTagName(element)]?.(element) || '';
|
|
222
|
+
if (!implicitRole)
|
|
223
|
+
return null;
|
|
224
|
+
// Inherit presentation role when required.
|
|
225
|
+
// https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none
|
|
226
|
+
let ancestor = element;
|
|
227
|
+
while (ancestor) {
|
|
228
|
+
const parent = parentElementOrShadowHost(ancestor);
|
|
229
|
+
const parents = kPresentationInheritanceParents[elementSafeTagName(ancestor)];
|
|
230
|
+
if (!parents || !parent || !parents.includes(elementSafeTagName(parent)))
|
|
231
|
+
break;
|
|
232
|
+
const parentExplicitRole = getExplicitAriaRole(parent);
|
|
233
|
+
if ((parentExplicitRole === 'none' || parentExplicitRole === 'presentation') && !hasPresentationConflictResolution(parent, parentExplicitRole))
|
|
234
|
+
return parentExplicitRole;
|
|
235
|
+
ancestor = parent;
|
|
236
|
+
}
|
|
237
|
+
return implicitRole;
|
|
238
|
+
}
|
|
239
|
+
const validRoles = ['alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox',
|
|
240
|
+
'complementary', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid',
|
|
241
|
+
'gridcell', 'group', 'heading', 'img', 'insertion', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'mark', 'marquee', 'math', 'meter', 'menu',
|
|
242
|
+
'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup',
|
|
243
|
+
'region', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'separator', 'slider',
|
|
244
|
+
'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer',
|
|
245
|
+
'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem'];
|
|
246
|
+
function getExplicitAriaRole(element) {
|
|
247
|
+
// https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
|
|
248
|
+
const roles = (element.getAttribute('role') || '').split(' ').map(role => role.trim());
|
|
249
|
+
return roles.find(role => validRoles.includes(role)) || null;
|
|
250
|
+
}
|
|
251
|
+
function hasPresentationConflictResolution(element, role) {
|
|
252
|
+
// https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none
|
|
253
|
+
return hasGlobalAriaAttribute(element, role) || isFocusable(element);
|
|
254
|
+
}
|
|
255
|
+
export function getAriaRole(element) {
|
|
256
|
+
const explicitRole = getExplicitAriaRole(element);
|
|
257
|
+
if (!explicitRole)
|
|
258
|
+
return getImplicitAriaRole(element);
|
|
259
|
+
if (explicitRole === 'none' || explicitRole === 'presentation') {
|
|
260
|
+
const implicitRole = getImplicitAriaRole(element);
|
|
261
|
+
if (hasPresentationConflictResolution(element, implicitRole))
|
|
262
|
+
return implicitRole;
|
|
263
|
+
}
|
|
264
|
+
return explicitRole;
|
|
265
|
+
}
|
|
266
|
+
function getAriaBoolean(attr) {
|
|
267
|
+
return attr === null ? undefined : attr.toLowerCase() === 'true';
|
|
268
|
+
}
|
|
269
|
+
export function isElementIgnoredForAria(element) {
|
|
270
|
+
return ['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(elementSafeTagName(element));
|
|
271
|
+
}
|
|
272
|
+
// https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion, but including "none" and "presentation" roles
|
|
273
|
+
// Not implemented:
|
|
274
|
+
// `Any descendants of elements that have the characteristic "Children Presentational: True"`
|
|
275
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-hidden
|
|
276
|
+
export function isElementHiddenForAria(element) {
|
|
277
|
+
if (isElementIgnoredForAria(element))
|
|
278
|
+
return true;
|
|
279
|
+
const style = getElementComputedStyle(element);
|
|
280
|
+
const isSlot = element.nodeName === 'SLOT';
|
|
281
|
+
if (style?.display === 'contents' && !isSlot) {
|
|
282
|
+
// display:contents is not rendered itself, but its child nodes are.
|
|
283
|
+
for (let child = element.firstChild; child; child = child.nextSibling) {
|
|
284
|
+
if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && !isElementHiddenForAria(child))
|
|
285
|
+
return false;
|
|
286
|
+
if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child))
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
// Note: <option> inside <select> are not affected by visibility or content-visibility.
|
|
292
|
+
// Same goes for <slot>.
|
|
293
|
+
const isOptionInsideSelect = element.nodeName === 'OPTION' && !!element.closest('select');
|
|
294
|
+
if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style))
|
|
295
|
+
return true;
|
|
296
|
+
return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element);
|
|
297
|
+
}
|
|
298
|
+
function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element) {
|
|
299
|
+
let hidden = cacheIsHidden?.get(element);
|
|
300
|
+
if (hidden === undefined) {
|
|
301
|
+
hidden = false;
|
|
302
|
+
// When parent has a shadow root, all light dom children must be assigned to a slot,
|
|
303
|
+
// otherwise they are not rendered and considered hidden for aria.
|
|
304
|
+
// Note: we can remove this logic once WebKit supports `Element.checkVisibility`.
|
|
305
|
+
if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot)
|
|
306
|
+
hidden = true;
|
|
307
|
+
// display:none and aria-hidden=true are considered hidden for aria.
|
|
308
|
+
if (!hidden) {
|
|
309
|
+
const style = getElementComputedStyle(element);
|
|
310
|
+
hidden = !style || style.display === 'none' || getAriaBoolean(element.getAttribute('aria-hidden')) === true;
|
|
311
|
+
}
|
|
312
|
+
// Check recursively.
|
|
313
|
+
if (!hidden) {
|
|
314
|
+
const parent = parentElementOrShadowHost(element);
|
|
315
|
+
if (parent)
|
|
316
|
+
hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent);
|
|
317
|
+
}
|
|
318
|
+
cacheIsHidden?.set(element, hidden);
|
|
319
|
+
}
|
|
320
|
+
return hidden;
|
|
321
|
+
}
|
|
322
|
+
function getIdRefs(element, ref) {
|
|
323
|
+
if (!ref)
|
|
324
|
+
return [];
|
|
325
|
+
const root = enclosingShadowRootOrDocument(element);
|
|
326
|
+
if (!root)
|
|
327
|
+
return [];
|
|
328
|
+
try {
|
|
329
|
+
const ids = ref.split(' ').filter(id => !!id);
|
|
330
|
+
const result = [];
|
|
331
|
+
for (const id of ids) {
|
|
332
|
+
// https://www.w3.org/TR/wai-aria-1.2/#mapping_additional_relations_error_processing
|
|
333
|
+
// "If more than one element has the same ID, the user agent SHOULD use the first element found with the given ID"
|
|
334
|
+
const firstElement = root.querySelector('#' + CSS.escape(id));
|
|
335
|
+
if (firstElement && !result.includes(firstElement))
|
|
336
|
+
result.push(firstElement);
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function trimFlatString(s) {
|
|
345
|
+
// "Flat string" at https://w3c.github.io/accname/#terminology
|
|
346
|
+
return s.trim();
|
|
347
|
+
}
|
|
348
|
+
function asFlatString(s) {
|
|
349
|
+
// "Flat string" at https://w3c.github.io/accname/#terminology
|
|
350
|
+
// Note that non-breaking spaces are preserved.
|
|
351
|
+
return s.split('\u00A0').map(chunk => chunk.replace(/\r\n/g, '\n').replace(/[\u200b\u00ad]/g, '').replace(/\s\s*/g, ' ')).join('\u00A0').trim();
|
|
352
|
+
}
|
|
353
|
+
function queryInAriaOwned(element, selector) {
|
|
354
|
+
const result = [...element.querySelectorAll(selector)];
|
|
355
|
+
for (const owned of getIdRefs(element, element.getAttribute('aria-owns'))) {
|
|
356
|
+
if (owned.matches(selector))
|
|
357
|
+
result.push(owned);
|
|
358
|
+
result.push(...owned.querySelectorAll(selector));
|
|
359
|
+
}
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
export function getCSSContent(element, pseudo) {
|
|
363
|
+
// Relevant spec: 2.6.2 from https://w3c.github.io/accname/#computation-steps.
|
|
364
|
+
// Additional considerations: https://github.com/w3c/accname/issues/204.
|
|
365
|
+
const cache = pseudo === '::before' ? cachePseudoContentBefore : (pseudo === '::after' ? cachePseudoContentAfter : cachePseudoContent);
|
|
366
|
+
if (cache?.has(element))
|
|
367
|
+
return cache?.get(element);
|
|
368
|
+
const style = getElementComputedStyle(element, pseudo);
|
|
369
|
+
let content;
|
|
370
|
+
if (style) {
|
|
371
|
+
const contentValue = style.content;
|
|
372
|
+
if (contentValue && contentValue !== 'none' && contentValue !== 'normal') {
|
|
373
|
+
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
374
|
+
// Note: all browsers ignore display:none and visibility:hidden pseudos.
|
|
375
|
+
content = parseCSSContentPropertyAsString(element, contentValue, !!pseudo);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (pseudo && content !== undefined) {
|
|
380
|
+
// SPEC DIFFERENCE.
|
|
381
|
+
// Spec says "CSS textual content, without a space", but we account for display
|
|
382
|
+
// to pass "name_file-label-inline-block-styles-manual.html"
|
|
383
|
+
const display = style?.display || 'inline';
|
|
384
|
+
if (display !== 'inline')
|
|
385
|
+
content = ' ' + content + ' ';
|
|
386
|
+
}
|
|
387
|
+
if (cache)
|
|
388
|
+
cache.set(element, content);
|
|
389
|
+
return content;
|
|
390
|
+
}
|
|
391
|
+
function parseCSSContentPropertyAsString(element, content, isPseudo) {
|
|
392
|
+
// Welcome to the mini CSS parser!
|
|
393
|
+
// It aims to support the following syntax and any subset of it:
|
|
394
|
+
// content: "one" attr(...) "two" "three" / "alt" attr(...) "more alt"
|
|
395
|
+
// See https://developer.mozilla.org/en-US/docs/Web/CSS/content for more details.
|
|
396
|
+
if (!content || content === 'none' || content === 'normal') {
|
|
397
|
+
// Common fast path.
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
let tokens = css.tokenize(content).filter(token => !(token instanceof css.WhitespaceToken));
|
|
402
|
+
const delimIndex = tokens.findIndex(token => token instanceof css.DelimToken && token.value === '/');
|
|
403
|
+
if (delimIndex !== -1) {
|
|
404
|
+
// Use the alternative text part when exists.
|
|
405
|
+
// content: ... / "alternative text"
|
|
406
|
+
tokens = tokens.slice(delimIndex + 1);
|
|
407
|
+
}
|
|
408
|
+
else if (!isPseudo) {
|
|
409
|
+
// For non-pseudo elements, the only valid content is a url() or various gradients.
|
|
410
|
+
// Therefore, we follow Chrome and only consider the alternative text.
|
|
411
|
+
// Firefox, on the other hand, calculates accessible name to be empty.
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const accumulated = [];
|
|
415
|
+
let index = 0;
|
|
416
|
+
while (index < tokens.length) {
|
|
417
|
+
if (tokens[index] instanceof css.StringToken) {
|
|
418
|
+
// content: "some text"
|
|
419
|
+
accumulated.push(tokens[index].value);
|
|
420
|
+
index++;
|
|
421
|
+
}
|
|
422
|
+
else if (index + 2 < tokens.length && tokens[index] instanceof css.FunctionToken && tokens[index].value === 'attr' && tokens[index + 1] instanceof css.IdentToken && tokens[index + 2] instanceof css.CloseParenToken) {
|
|
423
|
+
// content: attr(...)
|
|
424
|
+
// Firefox does not resolve attribute accessors in content, so we do it manually.
|
|
425
|
+
const attrName = tokens[index + 1].value;
|
|
426
|
+
accumulated.push(element.getAttribute(attrName) || '');
|
|
427
|
+
index += 3;
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
// Failed to parse the content, so ignore it.
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return accumulated.join('');
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
export function getAriaLabelledByElements(element) {
|
|
440
|
+
const ref = element.getAttribute('aria-labelledby');
|
|
441
|
+
if (ref === null)
|
|
442
|
+
return null;
|
|
443
|
+
const refs = getIdRefs(element, ref);
|
|
444
|
+
// step 2b:
|
|
445
|
+
// "if the current node has an aria-labelledby attribute that contains at least one valid IDREF"
|
|
446
|
+
// Therefore, if none of the refs match an element, we consider aria-labelledby to be missing.
|
|
447
|
+
return refs.length ? refs : null;
|
|
448
|
+
}
|
|
449
|
+
function allowsNameFromContent(role, targetDescendant) {
|
|
450
|
+
// SPEC: https://w3c.github.io/aria/#namefromcontent
|
|
451
|
+
//
|
|
452
|
+
// Note: there is a spec proposal https://github.com/w3c/aria/issues/1821 that
|
|
453
|
+
// is roughly aligned with what Chrome/Firefox do, and we follow that.
|
|
454
|
+
//
|
|
455
|
+
// See chromium implementation here:
|
|
456
|
+
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/accessibility/ax_object.cc;l=6338;drc=3decef66bc4c08b142a19db9628e9efe68973e64;bpv=0;bpt=1
|
|
457
|
+
const alwaysAllowsNameFromContent = ['button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row', 'rowheader', 'switch', 'tab', 'tooltip', 'treeitem'].includes(role);
|
|
458
|
+
const descendantAllowsNameFromContent = targetDescendant && ['', 'caption', 'code', 'contentinfo', 'definition', 'deletion', 'emphasis', 'insertion', 'list', 'listitem', 'mark', 'none', 'paragraph', 'presentation', 'region', 'row', 'rowgroup', 'section', 'strong', 'subscript', 'superscript', 'table', 'term', 'time'].includes(role);
|
|
459
|
+
return alwaysAllowsNameFromContent || descendantAllowsNameFromContent;
|
|
460
|
+
}
|
|
461
|
+
function computeAccessibleNameComposite(element, includeHidden, collectElements) {
|
|
462
|
+
// https://w3c.github.io/accname/#computation-steps
|
|
463
|
+
// step 1.
|
|
464
|
+
// https://w3c.github.io/aria/#namefromprohibited
|
|
465
|
+
const elementProhibitsNaming = ['caption', 'code', 'definition', 'deletion', 'emphasis', 'generic', 'insertion', 'mark', 'paragraph', 'presentation', 'strong', 'subscript', 'suggestion', 'superscript', 'term', 'time'].includes(getAriaRole(element) || '');
|
|
466
|
+
if (elementProhibitsNaming)
|
|
467
|
+
return emptyCompositeString();
|
|
468
|
+
// step 2.
|
|
469
|
+
const result = getTextAlternativeInternal(element, {
|
|
470
|
+
includeHidden,
|
|
471
|
+
collectElements,
|
|
472
|
+
visitedElements: new Set(),
|
|
473
|
+
embeddedInTargetElement: 'self',
|
|
474
|
+
});
|
|
475
|
+
return { text: asFlatString(result.text), elements: result.elements };
|
|
476
|
+
}
|
|
477
|
+
export function getElementAccessibleName(element, includeHidden) {
|
|
478
|
+
const cache = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName);
|
|
479
|
+
let accessibleName = cache?.get(element);
|
|
480
|
+
if (accessibleName === undefined) {
|
|
481
|
+
accessibleName = computeAccessibleNameComposite(element, includeHidden, true /* collectElements */);
|
|
482
|
+
cache?.set(element, accessibleName);
|
|
483
|
+
}
|
|
484
|
+
return accessibleName;
|
|
485
|
+
}
|
|
486
|
+
export function getElementAccessibleNameText(element, includeHidden) {
|
|
487
|
+
// Reuse the element-collecting composite if it happens to be cached, otherwise compute text only.
|
|
488
|
+
const composite = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName)?.get(element);
|
|
489
|
+
if (composite !== undefined)
|
|
490
|
+
return composite.text;
|
|
491
|
+
const cache = (includeHidden ? cacheAccessibleNameTextHidden : cacheAccessibleNameText);
|
|
492
|
+
let text = cache?.get(element);
|
|
493
|
+
if (text === undefined) {
|
|
494
|
+
text = computeAccessibleNameComposite(element, includeHidden, false /* collectElements */).text;
|
|
495
|
+
cache?.set(element, text);
|
|
496
|
+
}
|
|
497
|
+
return text;
|
|
498
|
+
}
|
|
499
|
+
export function getElementAccessibleDescription(element, includeHidden) {
|
|
500
|
+
const cache = (includeHidden ? cacheAccessibleDescriptionHidden : cacheAccessibleDescription);
|
|
501
|
+
let accessibleDescription = cache?.get(element);
|
|
502
|
+
if (accessibleDescription === undefined) {
|
|
503
|
+
// https://w3c.github.io/accname/#mapping_additional_nd_description
|
|
504
|
+
// https://www.w3.org/TR/html-aam-1.0/#accdesc-computation
|
|
505
|
+
accessibleDescription = '';
|
|
506
|
+
if (element.hasAttribute('aria-describedby')) {
|
|
507
|
+
// precedence 1
|
|
508
|
+
const describedBy = getIdRefs(element, element.getAttribute('aria-describedby'));
|
|
509
|
+
accessibleDescription = asFlatString(describedBy.map(ref => getTextAlternativeInternal(ref, {
|
|
510
|
+
includeHidden,
|
|
511
|
+
visitedElements: new Set(),
|
|
512
|
+
embeddedInDescribedBy: { element: ref, hidden: isElementHiddenForAria(ref) },
|
|
513
|
+
}).text).join(' '));
|
|
514
|
+
}
|
|
515
|
+
else if (element.hasAttribute('aria-description')) {
|
|
516
|
+
// precedence 2
|
|
517
|
+
accessibleDescription = asFlatString(element.getAttribute('aria-description') || '');
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
// TODO: handle precedence 3 - html-aam-specific cases like table>caption.
|
|
521
|
+
// https://www.w3.org/TR/html-aam-1.0/#accdesc-computation
|
|
522
|
+
// precedence 4
|
|
523
|
+
accessibleDescription = asFlatString(element.getAttribute('title') || '');
|
|
524
|
+
}
|
|
525
|
+
cache?.set(element, accessibleDescription);
|
|
526
|
+
}
|
|
527
|
+
return accessibleDescription;
|
|
528
|
+
}
|
|
529
|
+
// Roles where `aria-invalid` is conceptually meaningful per WAI-ARIA 1.2.
|
|
530
|
+
// See https://www.w3.org/TR/wai-aria-1.2/#aria-invalid for the supported list.
|
|
531
|
+
export const kAriaInvalidRoles = [
|
|
532
|
+
'application',
|
|
533
|
+
'checkbox',
|
|
534
|
+
'columnheader',
|
|
535
|
+
'combobox',
|
|
536
|
+
'gridcell',
|
|
537
|
+
'listbox',
|
|
538
|
+
'radiogroup',
|
|
539
|
+
'rowheader',
|
|
540
|
+
'searchbox',
|
|
541
|
+
'slider',
|
|
542
|
+
'spinbutton',
|
|
543
|
+
'switch',
|
|
544
|
+
'textbox',
|
|
545
|
+
'tree',
|
|
546
|
+
];
|
|
547
|
+
export function getAriaInvalid(element) {
|
|
548
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
|
|
549
|
+
// This state is being deprecated as a global state in ARIA 1.2.
|
|
550
|
+
// In future versions it will only be allowed on roles where it is specifically supported.
|
|
551
|
+
const ariaInvalid = element.getAttribute('aria-invalid');
|
|
552
|
+
if (!ariaInvalid || ariaInvalid.trim() === '' || ariaInvalid.toLocaleLowerCase() === 'false')
|
|
553
|
+
return 'false';
|
|
554
|
+
if (ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling')
|
|
555
|
+
return ariaInvalid;
|
|
556
|
+
return 'true';
|
|
557
|
+
}
|
|
558
|
+
function getValidityInvalid(element) {
|
|
559
|
+
if ('validity' in element) {
|
|
560
|
+
const validity = element.validity;
|
|
561
|
+
return validity?.valid === false;
|
|
562
|
+
}
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
export function getElementAccessibleErrorMessage(element) {
|
|
566
|
+
// SPEC: https://w3c.github.io/aria/#aria-errormessage
|
|
567
|
+
//
|
|
568
|
+
// TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage
|
|
569
|
+
const cache = cacheAccessibleErrorMessage;
|
|
570
|
+
let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element);
|
|
571
|
+
if (accessibleErrorMessage === undefined) {
|
|
572
|
+
accessibleErrorMessage = '';
|
|
573
|
+
const isAriaInvalid = getAriaInvalid(element) !== 'false';
|
|
574
|
+
const isValidityInvalid = getValidityInvalid(element);
|
|
575
|
+
if (isAriaInvalid || isValidityInvalid) {
|
|
576
|
+
const errorMessageId = element.getAttribute('aria-errormessage');
|
|
577
|
+
const errorMessages = getIdRefs(element, errorMessageId);
|
|
578
|
+
// Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules.
|
|
579
|
+
// Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage.
|
|
580
|
+
const parts = errorMessages.map(errorMessage => asFlatString(getTextAlternativeInternal(errorMessage, {
|
|
581
|
+
visitedElements: new Set(),
|
|
582
|
+
embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) },
|
|
583
|
+
}).text));
|
|
584
|
+
accessibleErrorMessage = parts.join(' ').trim();
|
|
585
|
+
}
|
|
586
|
+
cache?.set(element, accessibleErrorMessage);
|
|
587
|
+
}
|
|
588
|
+
return accessibleErrorMessage;
|
|
589
|
+
}
|
|
590
|
+
function getTextAlternativeInternal(element, options) {
|
|
591
|
+
if (options.visitedElements.has(element))
|
|
592
|
+
return emptyCompositeString();
|
|
593
|
+
const childOptions = {
|
|
594
|
+
...options,
|
|
595
|
+
embeddedInTargetElement: options.embeddedInTargetElement === 'self' ? 'descendant' : options.embeddedInTargetElement,
|
|
596
|
+
};
|
|
597
|
+
// step 2a. Hidden Not Referenced: If the current node is hidden and is:
|
|
598
|
+
// Not part of an aria-labelledby or aria-describedby traversal, where the node directly referenced by that relation was hidden.
|
|
599
|
+
// Nor part of a native host language text alternative element (e.g. label in HTML) or attribute traversal, where the root of that traversal was hidden.
|
|
600
|
+
if (!options.includeHidden) {
|
|
601
|
+
const isEmbeddedInHiddenReferenceTraversal = !!options.embeddedInLabelledBy?.hidden ||
|
|
602
|
+
!!options.embeddedInDescribedBy?.hidden ||
|
|
603
|
+
!!options.embeddedInNativeTextAlternative?.hidden ||
|
|
604
|
+
!!options.embeddedInLabel?.hidden;
|
|
605
|
+
if (isElementIgnoredForAria(element) ||
|
|
606
|
+
(!isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element))) {
|
|
607
|
+
options.visitedElements.add(element);
|
|
608
|
+
return emptyCompositeString();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const labelledBy = getAriaLabelledByElements(element);
|
|
612
|
+
// step 2b. LabelledBy:
|
|
613
|
+
// Otherwise, if the current node has an aria-labelledby attribute that contains
|
|
614
|
+
// at least one valid IDREF, and the current node is not already part of an ongoing
|
|
615
|
+
// aria-labelledby or aria-describedby traversal, process its IDREFs in the order they occur...
|
|
616
|
+
if (!options.embeddedInLabelledBy) {
|
|
617
|
+
const accessibleName = joinCompositeString((labelledBy || []).map(ref => getTextAlternativeInternal(ref, {
|
|
618
|
+
...options,
|
|
619
|
+
embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) },
|
|
620
|
+
embeddedInDescribedBy: undefined,
|
|
621
|
+
embeddedInTargetElement: undefined,
|
|
622
|
+
embeddedInLabel: undefined,
|
|
623
|
+
embeddedInNativeTextAlternative: undefined,
|
|
624
|
+
})), ' ', options.collectElements);
|
|
625
|
+
if (accessibleName.text)
|
|
626
|
+
return accessibleName;
|
|
627
|
+
}
|
|
628
|
+
const role = getAriaRole(element) || '';
|
|
629
|
+
const tagName = elementSafeTagName(element);
|
|
630
|
+
// step 2c:
|
|
631
|
+
// if the current node is a control embedded within the label (e.g. any element directly referenced by aria-labelledby) for another widget...
|
|
632
|
+
//
|
|
633
|
+
// also step 2d "skip to rule Embedded Control" section:
|
|
634
|
+
// If traversal of the current node is due to recursion and the current node is an embedded control...
|
|
635
|
+
// Note this is not strictly by the spec, because spec only applies this logic when "aria-label" is present.
|
|
636
|
+
// However, browsers and and wpt test name_heading-combobox-focusable-alternative-manual.html follow this behavior,
|
|
637
|
+
// and there is an issue filed for this: https://github.com/w3c/accname/issues/64
|
|
638
|
+
if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy || options.embeddedInTargetElement === 'descendant') {
|
|
639
|
+
const isOwnLabel = [...element.labels || []].includes(element);
|
|
640
|
+
const isOwnLabelledBy = (labelledBy || []).includes(element);
|
|
641
|
+
if (!isOwnLabel && !isOwnLabelledBy) {
|
|
642
|
+
if (role === 'textbox') {
|
|
643
|
+
options.visitedElements.add(element);
|
|
644
|
+
if (tagName === 'INPUT' || tagName === 'TEXTAREA')
|
|
645
|
+
return compositeString(element.value, element, options.collectElements);
|
|
646
|
+
return compositeString(element.textContent, element, options.collectElements);
|
|
647
|
+
}
|
|
648
|
+
if (['combobox', 'listbox'].includes(role)) {
|
|
649
|
+
options.visitedElements.add(element);
|
|
650
|
+
let selectedOptions;
|
|
651
|
+
if (tagName === 'SELECT') {
|
|
652
|
+
selectedOptions = [...element.selectedOptions];
|
|
653
|
+
if (!selectedOptions.length && element.options.length)
|
|
654
|
+
selectedOptions.push(element.options[0]);
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
const listbox = role === 'combobox' ? queryInAriaOwned(element, '*').find(e => getAriaRole(e) === 'listbox') : element;
|
|
658
|
+
selectedOptions = listbox ? queryInAriaOwned(listbox, '[aria-selected="true"]').filter(e => getAriaRole(e) === 'option') : [];
|
|
659
|
+
}
|
|
660
|
+
if (!selectedOptions.length && tagName === 'INPUT') {
|
|
661
|
+
// SPEC DIFFERENCE:
|
|
662
|
+
// This fallback is not explicitly mentioned in the spec, but all browsers and
|
|
663
|
+
// wpt test name_heading-combobox-focusable-alternative-manual.html do this.
|
|
664
|
+
return compositeString(element.value, element, options.collectElements);
|
|
665
|
+
}
|
|
666
|
+
return joinCompositeString(selectedOptions.map(option => getTextAlternativeInternal(option, childOptions)), ' ', options.collectElements);
|
|
667
|
+
}
|
|
668
|
+
if (['progressbar', 'scrollbar', 'slider', 'spinbutton', 'meter'].includes(role)) {
|
|
669
|
+
options.visitedElements.add(element);
|
|
670
|
+
if (element.hasAttribute('aria-valuetext'))
|
|
671
|
+
return compositeString(element.getAttribute('aria-valuetext'), element, options.collectElements);
|
|
672
|
+
if (element.hasAttribute('aria-valuenow'))
|
|
673
|
+
return compositeString(element.getAttribute('aria-valuenow'), element, options.collectElements);
|
|
674
|
+
return compositeString(element.getAttribute('value'), element, options.collectElements);
|
|
675
|
+
}
|
|
676
|
+
if (['menu'].includes(role)) {
|
|
677
|
+
// https://github.com/w3c/accname/issues/67#issuecomment-553196887
|
|
678
|
+
options.visitedElements.add(element);
|
|
679
|
+
return emptyCompositeString();
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// step 2d.
|
|
684
|
+
const ariaLabel = element.getAttribute('aria-label') || '';
|
|
685
|
+
if (trimFlatString(ariaLabel)) {
|
|
686
|
+
options.visitedElements.add(element);
|
|
687
|
+
return compositeString(ariaLabel, element, options.collectElements);
|
|
688
|
+
}
|
|
689
|
+
// step 2e.
|
|
690
|
+
if (!['presentation', 'none'].includes(role)) {
|
|
691
|
+
// https://w3c.github.io/html-aam/#input-type-button-input-type-submit-and-input-type-reset-accessible-name-computation
|
|
692
|
+
//
|
|
693
|
+
// SPEC DIFFERENCE.
|
|
694
|
+
// Spec says to ignore this when aria-labelledby is defined.
|
|
695
|
+
// WebKit follows the spec, while Chromium and Firefox do not.
|
|
696
|
+
// We align with Chromium and Firefox here.
|
|
697
|
+
if (tagName === 'INPUT' && ['button', 'submit', 'reset'].includes(element.type)) {
|
|
698
|
+
options.visitedElements.add(element);
|
|
699
|
+
const value = element.value || '';
|
|
700
|
+
if (trimFlatString(value))
|
|
701
|
+
return compositeString(value, element, options.collectElements);
|
|
702
|
+
if (element.type === 'submit')
|
|
703
|
+
return compositeString('Submit', element, options.collectElements);
|
|
704
|
+
if (element.type === 'reset')
|
|
705
|
+
return compositeString('Reset', element, options.collectElements);
|
|
706
|
+
const title = element.getAttribute('title') || '';
|
|
707
|
+
return compositeString(title, element, options.collectElements);
|
|
708
|
+
}
|
|
709
|
+
// SPEC DIFFERENCE.
|
|
710
|
+
// There is no spec for this, but Chromium/WebKit do "Choose File" so we follow that.
|
|
711
|
+
// All browsers respect labels, aria-labelledby and aria-label.
|
|
712
|
+
// No browsers respect the title attribute, although w3c accname tests disagree. We follow browsers.
|
|
713
|
+
if (tagName === 'INPUT' && element.type === 'file') {
|
|
714
|
+
options.visitedElements.add(element);
|
|
715
|
+
const labels = element.labels || [];
|
|
716
|
+
if (labels.length && !options.embeddedInLabelledBy)
|
|
717
|
+
return getAccessibleNameFromAssociatedLabels(labels, options);
|
|
718
|
+
return compositeString('Choose File', element, options.collectElements);
|
|
719
|
+
}
|
|
720
|
+
// https://w3c.github.io/html-aam/#input-type-image-accessible-name-computation
|
|
721
|
+
//
|
|
722
|
+
// SPEC DIFFERENCE.
|
|
723
|
+
// Spec says to ignore this when aria-labelledby is defined, but all browsers take it into account.
|
|
724
|
+
if (tagName === 'INPUT' && element.type === 'image') {
|
|
725
|
+
options.visitedElements.add(element);
|
|
726
|
+
const labels = element.labels || [];
|
|
727
|
+
if (labels.length && !options.embeddedInLabelledBy)
|
|
728
|
+
return getAccessibleNameFromAssociatedLabels(labels, options);
|
|
729
|
+
const alt = element.getAttribute('alt') || '';
|
|
730
|
+
if (trimFlatString(alt))
|
|
731
|
+
return compositeString(alt, element, options.collectElements);
|
|
732
|
+
const title = element.getAttribute('title') || '';
|
|
733
|
+
if (trimFlatString(title))
|
|
734
|
+
return compositeString(title, element, options.collectElements);
|
|
735
|
+
// SPEC DIFFERENCE.
|
|
736
|
+
// Spec says return localized "Submit Query", but browsers and axe-core insist on "Submit".
|
|
737
|
+
return compositeString('Submit', element, options.collectElements);
|
|
738
|
+
}
|
|
739
|
+
// https://w3c.github.io/html-aam/#button-element-accessible-name-computation
|
|
740
|
+
if (!labelledBy && tagName === 'BUTTON') {
|
|
741
|
+
options.visitedElements.add(element);
|
|
742
|
+
const labels = element.labels || [];
|
|
743
|
+
if (labels.length)
|
|
744
|
+
return getAccessibleNameFromAssociatedLabels(labels, options);
|
|
745
|
+
// From here, fallthrough to step 2f.
|
|
746
|
+
}
|
|
747
|
+
// https://w3c.github.io/html-aam/#output-element-accessible-name-computation
|
|
748
|
+
if (!labelledBy && tagName === 'OUTPUT') {
|
|
749
|
+
options.visitedElements.add(element);
|
|
750
|
+
const labels = element.labels || [];
|
|
751
|
+
if (labels.length)
|
|
752
|
+
return getAccessibleNameFromAssociatedLabels(labels, options);
|
|
753
|
+
return compositeString(element.getAttribute('title') || '', element, options.collectElements);
|
|
754
|
+
}
|
|
755
|
+
// https://w3c.github.io/html-aam/#input-type-text-input-type-password-input-type-number-input-type-search-input-type-tel-input-type-email-input-type-url-and-textarea-element-accessible-name-computation
|
|
756
|
+
// https://w3c.github.io/html-aam/#other-form-elements-accessible-name-computation
|
|
757
|
+
// For "other form elements", we count select and any other input.
|
|
758
|
+
//
|
|
759
|
+
// Note: WebKit does not follow the spec and uses placeholder when aria-labelledby is present.
|
|
760
|
+
if (!labelledBy && (tagName === 'TEXTAREA' || tagName === 'SELECT' || tagName === 'INPUT')) {
|
|
761
|
+
options.visitedElements.add(element);
|
|
762
|
+
const labels = element.labels || [];
|
|
763
|
+
if (labels.length)
|
|
764
|
+
return getAccessibleNameFromAssociatedLabels(labels, options);
|
|
765
|
+
const usePlaceholder = (tagName === 'INPUT' && ['text', 'password', 'number', 'search', 'tel', 'email', 'url'].includes(element.type)) || tagName === 'TEXTAREA';
|
|
766
|
+
const placeholder = element.getAttribute('placeholder') || '';
|
|
767
|
+
const title = element.getAttribute('title') || '';
|
|
768
|
+
if (!usePlaceholder || title)
|
|
769
|
+
return compositeString(title, element, options.collectElements);
|
|
770
|
+
return compositeString(placeholder, element, options.collectElements);
|
|
771
|
+
}
|
|
772
|
+
// https://w3c.github.io/html-aam/#fieldset-and-legend-elements
|
|
773
|
+
if (!labelledBy && tagName === 'FIELDSET') {
|
|
774
|
+
options.visitedElements.add(element);
|
|
775
|
+
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
|
|
776
|
+
if (elementSafeTagName(child) === 'LEGEND') {
|
|
777
|
+
return getTextAlternativeInternal(child, {
|
|
778
|
+
...childOptions,
|
|
779
|
+
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const title = element.getAttribute('title') || '';
|
|
784
|
+
return compositeString(title, element, options.collectElements);
|
|
785
|
+
}
|
|
786
|
+
// https://w3c.github.io/html-aam/#figure-and-figcaption-elements
|
|
787
|
+
if (!labelledBy && tagName === 'FIGURE') {
|
|
788
|
+
options.visitedElements.add(element);
|
|
789
|
+
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
|
|
790
|
+
if (elementSafeTagName(child) === 'FIGCAPTION') {
|
|
791
|
+
return getTextAlternativeInternal(child, {
|
|
792
|
+
...childOptions,
|
|
793
|
+
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const title = element.getAttribute('title') || '';
|
|
798
|
+
return compositeString(title, element, options.collectElements);
|
|
799
|
+
}
|
|
800
|
+
// https://w3c.github.io/html-aam/#img-element
|
|
801
|
+
//
|
|
802
|
+
// SPEC DIFFERENCE.
|
|
803
|
+
// Spec says to ignore this when aria-labelledby is defined, but all browsers take it into account.
|
|
804
|
+
if (tagName === 'IMG') {
|
|
805
|
+
options.visitedElements.add(element);
|
|
806
|
+
const alt = element.getAttribute('alt') || '';
|
|
807
|
+
if (trimFlatString(alt))
|
|
808
|
+
return compositeString(alt, element, options.collectElements);
|
|
809
|
+
const title = element.getAttribute('title') || '';
|
|
810
|
+
return compositeString(title, element, options.collectElements);
|
|
811
|
+
}
|
|
812
|
+
// https://w3c.github.io/html-aam/#table-element
|
|
813
|
+
if (tagName === 'TABLE') {
|
|
814
|
+
options.visitedElements.add(element);
|
|
815
|
+
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
|
|
816
|
+
if (elementSafeTagName(child) === 'CAPTION') {
|
|
817
|
+
return getTextAlternativeInternal(child, {
|
|
818
|
+
...childOptions,
|
|
819
|
+
embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) },
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
// SPEC DIFFERENCE.
|
|
824
|
+
// Spec does not say a word about <table summary="...">, but all browsers actually support it.
|
|
825
|
+
const summary = element.getAttribute('summary') || '';
|
|
826
|
+
if (summary)
|
|
827
|
+
return compositeString(summary, element, options.collectElements);
|
|
828
|
+
// SPEC DIFFERENCE.
|
|
829
|
+
// Spec says "if the table element has a title attribute, then use that attribute".
|
|
830
|
+
// We ignore title to pass "name_from_content-manual.html".
|
|
831
|
+
}
|
|
832
|
+
// https://w3c.github.io/html-aam/#area-element
|
|
833
|
+
if (tagName === 'AREA') {
|
|
834
|
+
options.visitedElements.add(element);
|
|
835
|
+
const alt = element.getAttribute('alt') || '';
|
|
836
|
+
if (trimFlatString(alt))
|
|
837
|
+
return compositeString(alt, element, options.collectElements);
|
|
838
|
+
const title = element.getAttribute('title') || '';
|
|
839
|
+
return compositeString(title, element, options.collectElements);
|
|
840
|
+
}
|
|
841
|
+
// https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd
|
|
842
|
+
if (tagName === 'SVG' || element.ownerSVGElement) {
|
|
843
|
+
options.visitedElements.add(element);
|
|
844
|
+
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
|
|
845
|
+
if (elementSafeTagName(child) === 'TITLE' && child.ownerSVGElement) {
|
|
846
|
+
return getTextAlternativeInternal(child, {
|
|
847
|
+
...childOptions,
|
|
848
|
+
embeddedInLabelledBy: { element: child, hidden: isElementHiddenForAria(child) },
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (element.ownerSVGElement && tagName === 'A') {
|
|
854
|
+
const title = element.getAttribute('xlink:title') || '';
|
|
855
|
+
if (trimFlatString(title)) {
|
|
856
|
+
options.visitedElements.add(element);
|
|
857
|
+
return compositeString(title, element, options.collectElements);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
// See https://w3c.github.io/html-aam/#summary-element-accessible-name-computation for "summary"-specific check.
|
|
862
|
+
const shouldNameFromContentForSummary = tagName === 'SUMMARY' && !['presentation', 'none'].includes(role);
|
|
863
|
+
// step 2f + step 2h.
|
|
864
|
+
if (allowsNameFromContent(role, options.embeddedInTargetElement === 'descendant') ||
|
|
865
|
+
shouldNameFromContentForSummary ||
|
|
866
|
+
!!options.embeddedInLabelledBy || !!options.embeddedInDescribedBy ||
|
|
867
|
+
!!options.embeddedInLabel || !!options.embeddedInNativeTextAlternative) {
|
|
868
|
+
options.visitedElements.add(element);
|
|
869
|
+
const accessibleName = innerAccumulatedElementText(element, childOptions);
|
|
870
|
+
// Spec says "Return the accumulated text if it is not the empty string". However, that is not really
|
|
871
|
+
// compatible with the real browser behavior and wpt tests, where an element with empty contents will fallback to the title.
|
|
872
|
+
// So we follow the spec everywhere except for the target element itself. This can probably be improved.
|
|
873
|
+
const maybeTrimmedAccessibleName = options.embeddedInTargetElement === 'self' ? trimFlatString(accessibleName.text) : accessibleName.text;
|
|
874
|
+
if (maybeTrimmedAccessibleName) {
|
|
875
|
+
// This element owns the accumulated content - record it alongside the descendants it was computed from.
|
|
876
|
+
accessibleName.elements?.add(element);
|
|
877
|
+
return accessibleName;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
// step 2i.
|
|
881
|
+
if (!['presentation', 'none'].includes(role) || tagName === 'IFRAME') {
|
|
882
|
+
options.visitedElements.add(element);
|
|
883
|
+
const title = element.getAttribute('title') || '';
|
|
884
|
+
if (trimFlatString(title))
|
|
885
|
+
return compositeString(title, element, options.collectElements);
|
|
886
|
+
}
|
|
887
|
+
options.visitedElements.add(element);
|
|
888
|
+
return emptyCompositeString();
|
|
889
|
+
}
|
|
890
|
+
function innerAccumulatedElementText(element, options) {
|
|
891
|
+
const tokens = [];
|
|
892
|
+
const elements = options.collectElements ? new Set() : undefined;
|
|
893
|
+
const visit = (node, skipSlotted) => {
|
|
894
|
+
if (skipSlotted && node.assignedSlot)
|
|
895
|
+
return;
|
|
896
|
+
if (node.nodeType === 1 /* Node.ELEMENT_NODE */) {
|
|
897
|
+
const display = getElementComputedStyle(node)?.display || 'inline';
|
|
898
|
+
const childComposite = getTextAlternativeInternal(node, options);
|
|
899
|
+
let token = childComposite.text;
|
|
900
|
+
for (const contributor of childComposite.elements || [])
|
|
901
|
+
elements?.add(contributor);
|
|
902
|
+
// SPEC DIFFERENCE.
|
|
903
|
+
// Spec says "append the result to the accumulated text", assuming "with space".
|
|
904
|
+
// However, multiple tests insist that inline elements do not add a space.
|
|
905
|
+
// Additionally, <br> insists on a space anyway, see "name_file-label-inline-block-elements-manual.html"
|
|
906
|
+
if (display !== 'inline' || node.nodeName === 'BR')
|
|
907
|
+
token = ' ' + token + ' ';
|
|
908
|
+
tokens.push(token);
|
|
909
|
+
}
|
|
910
|
+
else if (node.nodeType === 3 /* Node.TEXT_NODE */) {
|
|
911
|
+
// step 2g.
|
|
912
|
+
tokens.push(node.textContent || '');
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
tokens.push(getCSSContent(element, '::before') || '');
|
|
916
|
+
const content = getCSSContent(element);
|
|
917
|
+
if (content !== undefined) {
|
|
918
|
+
// `content` CSS property replaces everything inside the element.
|
|
919
|
+
// I was not able to find any spec or description on how this interacts with accname,
|
|
920
|
+
// so this is a guess based on what browsers do.
|
|
921
|
+
tokens.push(content);
|
|
922
|
+
}
|
|
923
|
+
else {
|
|
924
|
+
// step 2h.
|
|
925
|
+
const assignedNodes = element.nodeName === 'SLOT' ? element.assignedNodes() : [];
|
|
926
|
+
if (assignedNodes.length) {
|
|
927
|
+
for (const child of assignedNodes)
|
|
928
|
+
visit(child, false);
|
|
929
|
+
}
|
|
930
|
+
else {
|
|
931
|
+
for (let child = element.firstChild; child; child = child.nextSibling)
|
|
932
|
+
visit(child, true);
|
|
933
|
+
if (element.shadowRoot) {
|
|
934
|
+
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
|
|
935
|
+
visit(child, true);
|
|
936
|
+
}
|
|
937
|
+
for (const owned of getIdRefs(element, element.getAttribute('aria-owns')))
|
|
938
|
+
visit(owned, true);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
tokens.push(getCSSContent(element, '::after') || '');
|
|
942
|
+
return { text: tokens.join(''), elements };
|
|
943
|
+
}
|
|
944
|
+
export const kAriaSelectedRoles = ['gridcell', 'option', 'row', 'tab', 'rowheader', 'columnheader', 'treeitem'];
|
|
945
|
+
export function getAriaSelected(element) {
|
|
946
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-selected
|
|
947
|
+
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
|
|
948
|
+
if (elementSafeTagName(element) === 'OPTION')
|
|
949
|
+
return element.selected;
|
|
950
|
+
if (kAriaSelectedRoles.includes(getAriaRole(element) || ''))
|
|
951
|
+
return getAriaBoolean(element.getAttribute('aria-selected')) === true;
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
export const kAriaCheckedRoles = ['checkbox', 'menuitemcheckbox', 'option', 'radio', 'switch', 'menuitemradio', 'treeitem'];
|
|
955
|
+
export function getAriaChecked(element) {
|
|
956
|
+
const result = getChecked(element, true);
|
|
957
|
+
return result === 'error' ? false : result;
|
|
958
|
+
}
|
|
959
|
+
export function getCheckedAllowMixed(element) {
|
|
960
|
+
return getChecked(element, true);
|
|
961
|
+
}
|
|
962
|
+
export function getCheckedWithoutMixed(element) {
|
|
963
|
+
const result = getChecked(element, false);
|
|
964
|
+
return result;
|
|
965
|
+
}
|
|
966
|
+
function getChecked(element, allowMixed) {
|
|
967
|
+
const tagName = elementSafeTagName(element);
|
|
968
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-checked
|
|
969
|
+
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
|
|
970
|
+
if (allowMixed && tagName === 'INPUT' && element.indeterminate)
|
|
971
|
+
return 'mixed';
|
|
972
|
+
if (tagName === 'INPUT' && ['checkbox', 'radio'].includes(element.type))
|
|
973
|
+
return element.checked;
|
|
974
|
+
if (kAriaCheckedRoles.includes(getAriaRole(element) || '')) {
|
|
975
|
+
const checked = element.getAttribute('aria-checked');
|
|
976
|
+
if (checked === 'true')
|
|
977
|
+
return true;
|
|
978
|
+
if (allowMixed && checked === 'mixed')
|
|
979
|
+
return 'mixed';
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
return 'error';
|
|
983
|
+
}
|
|
984
|
+
// https://w3c.github.io/aria/#aria-readonly
|
|
985
|
+
const kAriaReadonlyRoles = ['checkbox', 'combobox', 'grid', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid'];
|
|
986
|
+
export function getReadonly(element) {
|
|
987
|
+
const tagName = elementSafeTagName(element);
|
|
988
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-checked
|
|
989
|
+
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
|
|
990
|
+
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tagName))
|
|
991
|
+
return element.hasAttribute('readonly');
|
|
992
|
+
if (kAriaReadonlyRoles.includes(getAriaRole(element) || ''))
|
|
993
|
+
return element.getAttribute('aria-readonly') === 'true';
|
|
994
|
+
if (element.isContentEditable)
|
|
995
|
+
return false;
|
|
996
|
+
return 'error';
|
|
997
|
+
}
|
|
998
|
+
export const kAriaPressedRoles = ['button'];
|
|
999
|
+
export function getAriaPressed(element) {
|
|
1000
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-pressed
|
|
1001
|
+
if (kAriaPressedRoles.includes(getAriaRole(element) || '')) {
|
|
1002
|
+
const pressed = element.getAttribute('aria-pressed');
|
|
1003
|
+
if (pressed === 'true')
|
|
1004
|
+
return true;
|
|
1005
|
+
if (pressed === 'mixed')
|
|
1006
|
+
return 'mixed';
|
|
1007
|
+
}
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
export const kAriaExpandedRoles = ['application', 'button', 'checkbox', 'combobox', 'gridcell', 'link', 'listbox', 'menuitem', 'row', 'rowheader', 'tab', 'treeitem', 'columnheader', 'menuitemcheckbox', 'menuitemradio', 'rowheader', 'switch'];
|
|
1011
|
+
export function getAriaExpanded(element) {
|
|
1012
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-expanded
|
|
1013
|
+
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
|
|
1014
|
+
if (elementSafeTagName(element) === 'DETAILS')
|
|
1015
|
+
return element.open;
|
|
1016
|
+
if (kAriaExpandedRoles.includes(getAriaRole(element) || '')) {
|
|
1017
|
+
const expanded = element.getAttribute('aria-expanded');
|
|
1018
|
+
if (expanded === null)
|
|
1019
|
+
return undefined;
|
|
1020
|
+
if (expanded === 'true')
|
|
1021
|
+
return true;
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
return undefined;
|
|
1025
|
+
}
|
|
1026
|
+
export const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem'];
|
|
1027
|
+
export function getAriaLevel(element) {
|
|
1028
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-level
|
|
1029
|
+
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
|
|
1030
|
+
const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[elementSafeTagName(element)];
|
|
1031
|
+
if (native)
|
|
1032
|
+
return native;
|
|
1033
|
+
if (kAriaLevelRoles.includes(getAriaRole(element) || '')) {
|
|
1034
|
+
const attr = element.getAttribute('aria-level');
|
|
1035
|
+
const value = attr === null ? Number.NaN : Number(attr);
|
|
1036
|
+
if (Number.isInteger(value) && value >= 1)
|
|
1037
|
+
return value;
|
|
1038
|
+
}
|
|
1039
|
+
return 0;
|
|
1040
|
+
}
|
|
1041
|
+
export const kAriaDisabledRoles = ['application', 'button', 'composite', 'gridcell', 'group', 'input', 'link', 'menuitem', 'scrollbar', 'separator', 'tab', 'checkbox', 'columnheader', 'combobox', 'grid', 'listbox', 'menu', 'menubar', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'radiogroup', 'row', 'rowheader', 'searchbox', 'select', 'slider', 'spinbutton', 'switch', 'tablist', 'textbox', 'toolbar', 'tree', 'treegrid', 'treeitem'];
|
|
1042
|
+
export function getAriaDisabled(element) {
|
|
1043
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-disabled
|
|
1044
|
+
// Note that aria-disabled applies to all descendants, so we look up the hierarchy.
|
|
1045
|
+
return isNativelyDisabled(element) || hasExplicitAriaDisabled(element);
|
|
1046
|
+
}
|
|
1047
|
+
function isNativelyDisabled(element) {
|
|
1048
|
+
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
|
|
1049
|
+
const isNativeFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(elementSafeTagName(element));
|
|
1050
|
+
return isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledOptGroup(element) || belongsToDisabledFieldSet(element));
|
|
1051
|
+
}
|
|
1052
|
+
function belongsToDisabledOptGroup(element) {
|
|
1053
|
+
return elementSafeTagName(element) === 'OPTION' && !!element.closest('OPTGROUP[DISABLED]');
|
|
1054
|
+
}
|
|
1055
|
+
function belongsToDisabledFieldSet(element) {
|
|
1056
|
+
const fieldSetElement = element?.closest('FIELDSET[DISABLED]');
|
|
1057
|
+
if (!fieldSetElement)
|
|
1058
|
+
return false;
|
|
1059
|
+
const legendElement = fieldSetElement.querySelector(':scope > LEGEND');
|
|
1060
|
+
return !legendElement || !legendElement.contains(element);
|
|
1061
|
+
}
|
|
1062
|
+
function hasExplicitAriaDisabled(element, isAncestor = false) {
|
|
1063
|
+
if (!element)
|
|
1064
|
+
return false;
|
|
1065
|
+
if (isAncestor || kAriaDisabledRoles.includes(getAriaRole(element) || '')) {
|
|
1066
|
+
const attribute = (element.getAttribute('aria-disabled') || '').toLowerCase();
|
|
1067
|
+
if (attribute === 'true')
|
|
1068
|
+
return true;
|
|
1069
|
+
if (attribute === 'false')
|
|
1070
|
+
return false;
|
|
1071
|
+
// aria-disabled works across shadow boundaries.
|
|
1072
|
+
return hasExplicitAriaDisabled(parentElementOrShadowHost(element), true);
|
|
1073
|
+
}
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
export function getAriaBusy(element) {
|
|
1077
|
+
// https://www.w3.org/TR/wai-aria-1.2/#aria-busy
|
|
1078
|
+
// aria-busy is a global state with a default value of "false".
|
|
1079
|
+
return getAriaBoolean(element.getAttribute('aria-busy')) === true;
|
|
1080
|
+
}
|
|
1081
|
+
function getAccessibleNameFromAssociatedLabels(labels, options) {
|
|
1082
|
+
return joinCompositeString([...labels].map(label => getTextAlternativeInternal(label, {
|
|
1083
|
+
...options,
|
|
1084
|
+
embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) },
|
|
1085
|
+
embeddedInNativeTextAlternative: undefined,
|
|
1086
|
+
embeddedInLabelledBy: undefined,
|
|
1087
|
+
embeddedInDescribedBy: undefined,
|
|
1088
|
+
embeddedInTargetElement: undefined,
|
|
1089
|
+
})).filter(accessibleName => !!accessibleName.text), ' ', options.collectElements);
|
|
1090
|
+
}
|
|
1091
|
+
export function receivesPointerEvents(element) {
|
|
1092
|
+
const cache = cachePointerEvents;
|
|
1093
|
+
let e = element;
|
|
1094
|
+
let result;
|
|
1095
|
+
const parents = [];
|
|
1096
|
+
for (; e; e = parentElementOrShadowHost(e)) {
|
|
1097
|
+
const cached = cache.get(e);
|
|
1098
|
+
if (cached !== undefined) {
|
|
1099
|
+
result = cached;
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
parents.push(e);
|
|
1103
|
+
const style = getElementComputedStyle(e);
|
|
1104
|
+
if (!style) {
|
|
1105
|
+
result = true;
|
|
1106
|
+
break;
|
|
1107
|
+
}
|
|
1108
|
+
const value = style.pointerEvents;
|
|
1109
|
+
if (value) {
|
|
1110
|
+
result = value !== 'none';
|
|
1111
|
+
break;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (result === undefined)
|
|
1115
|
+
result = true;
|
|
1116
|
+
for (const parent of parents)
|
|
1117
|
+
cache.set(parent, result);
|
|
1118
|
+
return result;
|
|
1119
|
+
}
|
|
1120
|
+
let cacheAccessibleName;
|
|
1121
|
+
let cacheAccessibleNameHidden;
|
|
1122
|
+
let cacheAccessibleNameText;
|
|
1123
|
+
let cacheAccessibleNameTextHidden;
|
|
1124
|
+
let cacheAccessibleDescription;
|
|
1125
|
+
let cacheAccessibleDescriptionHidden;
|
|
1126
|
+
let cacheAccessibleErrorMessage;
|
|
1127
|
+
let cacheIsHidden;
|
|
1128
|
+
let cachePseudoContent;
|
|
1129
|
+
let cachePseudoContentBefore;
|
|
1130
|
+
let cachePseudoContentAfter;
|
|
1131
|
+
let cachePointerEvents;
|
|
1132
|
+
let cachesCounter = 0;
|
|
1133
|
+
export function beginAriaCaches() {
|
|
1134
|
+
beginDOMCaches();
|
|
1135
|
+
++cachesCounter;
|
|
1136
|
+
cacheAccessibleName ??= new Map();
|
|
1137
|
+
cacheAccessibleNameHidden ??= new Map();
|
|
1138
|
+
cacheAccessibleNameText ??= new Map();
|
|
1139
|
+
cacheAccessibleNameTextHidden ??= new Map();
|
|
1140
|
+
cacheAccessibleDescription ??= new Map();
|
|
1141
|
+
cacheAccessibleDescriptionHidden ??= new Map();
|
|
1142
|
+
cacheAccessibleErrorMessage ??= new Map();
|
|
1143
|
+
cacheIsHidden ??= new Map();
|
|
1144
|
+
cachePseudoContent ??= new Map();
|
|
1145
|
+
cachePseudoContentBefore ??= new Map();
|
|
1146
|
+
cachePseudoContentAfter ??= new Map();
|
|
1147
|
+
cachePointerEvents ??= new Map();
|
|
1148
|
+
}
|
|
1149
|
+
export function endAriaCaches() {
|
|
1150
|
+
if (!--cachesCounter) {
|
|
1151
|
+
cacheAccessibleName = undefined;
|
|
1152
|
+
cacheAccessibleNameHidden = undefined;
|
|
1153
|
+
cacheAccessibleNameText = undefined;
|
|
1154
|
+
cacheAccessibleNameTextHidden = undefined;
|
|
1155
|
+
cacheAccessibleDescription = undefined;
|
|
1156
|
+
cacheAccessibleDescriptionHidden = undefined;
|
|
1157
|
+
cacheAccessibleErrorMessage = undefined;
|
|
1158
|
+
cacheIsHidden = undefined;
|
|
1159
|
+
cachePseudoContent = undefined;
|
|
1160
|
+
cachePseudoContentBefore = undefined;
|
|
1161
|
+
cachePseudoContentAfter = undefined;
|
|
1162
|
+
cachePointerEvents = undefined;
|
|
1163
|
+
}
|
|
1164
|
+
endDOMCaches();
|
|
1165
|
+
}
|
|
1166
|
+
const inputTypeToRole = {
|
|
1167
|
+
'button': 'button',
|
|
1168
|
+
'checkbox': 'checkbox',
|
|
1169
|
+
'image': 'button',
|
|
1170
|
+
'number': 'spinbutton',
|
|
1171
|
+
'radio': 'radio',
|
|
1172
|
+
'range': 'slider',
|
|
1173
|
+
'reset': 'button',
|
|
1174
|
+
'submit': 'button',
|
|
1175
|
+
};
|
|
1176
|
+
function emptyCompositeString() {
|
|
1177
|
+
return { text: '' };
|
|
1178
|
+
}
|
|
1179
|
+
function compositeString(text, element, collectElements) {
|
|
1180
|
+
const elements = text && collectElements ? new Set([element]) : undefined;
|
|
1181
|
+
return { text: text || '', elements };
|
|
1182
|
+
}
|
|
1183
|
+
function joinCompositeString(parts, separator, collectElements) {
|
|
1184
|
+
let elements;
|
|
1185
|
+
if (collectElements) {
|
|
1186
|
+
elements = new Set();
|
|
1187
|
+
for (const part of parts) {
|
|
1188
|
+
for (const element of part.elements || [])
|
|
1189
|
+
elements.add(element);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return { text: parts.map(part => part.text).join(separator), elements };
|
|
1193
|
+
}
|