@markuplint/ml-spec 4.10.2 → 5.0.0-alpha.1
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/ARCHITECTURE.ja.md +24 -10
- package/ARCHITECTURE.md +24 -10
- package/CHANGELOG.md +34 -0
- package/README.md +2 -2
- package/docs/aria-algorithms.ja.md +180 -33
- package/docs/aria-algorithms.md +183 -34
- package/docs/html-algorithms.ja.md +2 -2
- package/docs/html-algorithms.md +2 -2
- package/docs/maintenance.ja.md +13 -22
- package/docs/maintenance.md +13 -22
- package/docs/spec-resolution.ja.md +44 -9
- package/docs/spec-resolution.md +53 -16
- package/docs/type-definitions.ja.md +8 -4
- package/docs/type-definitions.md +8 -4
- package/lib/algorithm/aria/accname/__tests__/test-helpers.d.ts +45 -0
- package/lib/algorithm/aria/accname/__tests__/test-helpers.js +120 -0
- package/lib/algorithm/aria/accname/aria-steps.d.ts +51 -0
- package/lib/algorithm/aria/accname/aria-steps.js +104 -0
- package/lib/algorithm/aria/accname/compute.d.ts +51 -0
- package/lib/algorithm/aria/accname/compute.js +101 -0
- package/lib/algorithm/aria/accname/element-names.d.ts +36 -0
- package/lib/algorithm/aria/accname/element-names.js +342 -0
- package/lib/algorithm/aria/accname/helpers.d.ts +98 -0
- package/lib/algorithm/aria/accname/helpers.js +330 -0
- package/lib/algorithm/aria/accname/index.d.ts +4 -0
- package/lib/algorithm/aria/accname/index.js +3 -0
- package/lib/algorithm/aria/accname/label-steps.d.ts +25 -0
- package/lib/algorithm/aria/accname/label-steps.js +74 -0
- package/lib/algorithm/aria/accname/svg-helpers.d.ts +17 -0
- package/lib/algorithm/aria/accname/svg-helpers.js +30 -0
- package/lib/algorithm/aria/accname/types.d.ts +70 -0
- package/lib/algorithm/aria/accname/types.js +2 -0
- package/lib/algorithm/aria/accname-computation.d.ts +14 -3
- package/lib/algorithm/aria/accname-computation.js +131 -8
- package/lib/algorithm/aria/aria-specs.d.ts +1 -0
- package/lib/algorithm/aria/get-aria.js +30 -4
- package/lib/algorithm/aria/get-computed-role.js +106 -26
- package/lib/algorithm/aria/get-explicit-role.d.ts +12 -0
- package/lib/algorithm/aria/get-explicit-role.js +12 -0
- package/lib/algorithm/aria/get-non-presentational-ancestor.d.ts +12 -0
- package/lib/algorithm/aria/get-non-presentational-ancestor.js +14 -2
- package/lib/algorithm/aria/get-permitted-roles-spec.d.ts +8 -4
- package/lib/algorithm/aria/get-permitted-roles-spec.js +19 -6
- package/lib/algorithm/aria/get-role-spec.js +10 -3
- package/lib/algorithm/aria/has-required-owned-elements.d.ts +2 -1
- package/lib/algorithm/aria/has-required-owned-elements.js +18 -15
- package/lib/algorithm/aria/is-presentational.d.ts +24 -0
- package/lib/algorithm/aria/is-presentational.js +31 -0
- package/lib/algorithm/aria/matches-context-role.d.ts +12 -4
- package/lib/algorithm/aria/matches-context-role.js +39 -6
- package/lib/algorithm/html/content-model-category-to-tag-names.js +1 -1
- package/lib/algorithm/html/get-content-model.d.ts +4 -2
- package/lib/algorithm/html/get-content-model.js +6 -7
- package/lib/const/accname.d.ts +29 -0
- package/lib/const/accname.js +76 -0
- package/lib/const/dom.d.ts +8 -0
- package/lib/const/dom.js +8 -0
- package/lib/const/index.d.ts +2 -0
- package/lib/const/index.js +2 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +4 -0
- package/lib/types/aria.d.ts +3 -3
- package/lib/types/index.d.ts +65 -0
- package/lib/utils/aria-version.d.ts +1 -1
- package/lib/utils/aria-version.js +1 -1
- package/lib/utils/directive-resolver.d.ts +23 -0
- package/lib/utils/directive-resolver.js +50 -0
- package/lib/utils/get-attr-specs-spec.js +4 -3
- package/lib/utils/get-ns.d.ts +7 -0
- package/lib/utils/get-ns.js +7 -0
- package/lib/utils/get-spec-by-tag-name.d.ts +1 -1
- package/lib/utils/merge-array.d.ts +10 -0
- package/lib/utils/merge-array.js +10 -0
- package/lib/utils/resolve-version.d.ts +11 -0
- package/lib/utils/resolve-version.js +13 -1
- package/lib/utils/schema-to-spec.d.ts +2 -0
- package/lib/utils/schema-to-spec.js +15 -4
- package/package.json +9 -7
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types -- AccnameElement wraps mutable DOM types */
|
|
2
|
+
import { ELEMENT_NODE, SVG_NAMESPACE, TEXT_NODE, TEXT_LIKE_INPUT_TYPES } from '../../../const/index.js';
|
|
3
|
+
export { EMBEDDED_CONTROL_ROLES } from '../../../const/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Creates an AccnameResult with a trimmed, whitespace-collapsed name.
|
|
6
|
+
*
|
|
7
|
+
* @param name - The raw name string to normalize
|
|
8
|
+
* @param source - The source that provided the name, or null if the name is empty
|
|
9
|
+
* @returns A result with the collapsed name and its source (source is null when name is empty)
|
|
10
|
+
*/
|
|
11
|
+
export function makeResult(name, source) {
|
|
12
|
+
const trimmed = flattenText(name);
|
|
13
|
+
return {
|
|
14
|
+
name: trimmed,
|
|
15
|
+
source: trimmed ? source : null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Collapses internal whitespace and trims a string.
|
|
20
|
+
*
|
|
21
|
+
* @param text - The string to normalize
|
|
22
|
+
* @returns The string with collapsed whitespace and trimmed
|
|
23
|
+
*/
|
|
24
|
+
export function flattenText(text) {
|
|
25
|
+
return text.replaceAll(/\s+/g, ' ').trim();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Collects text content from child nodes recursively for name-from-content computation.
|
|
29
|
+
*
|
|
30
|
+
* Implements AccName 1.2 §4.3.2 Steps 2F and 2C:
|
|
31
|
+
*
|
|
32
|
+
* - **Step 2F**: "If the current node's role allows name from content [...],
|
|
33
|
+
* return the accumulated text of the current node's descendant nodes."
|
|
34
|
+
* - **Step 2C (Embedded Controls)**: "If the current node is a descendant of
|
|
35
|
+
* an `aria-labelledby` or `aria-label` reference AND the current node is
|
|
36
|
+
* an embedded control, return the embedded control's value."
|
|
37
|
+
*
|
|
38
|
+
* Control flow for each child node:
|
|
39
|
+
* 1. **Text node** → contribute text directly.
|
|
40
|
+
* 2. **Embedded control** (textbox, combobox, listbox, slider, spinbutton, searchbox)
|
|
41
|
+
* → use value via `getEmbeddedControlValue`. Per spec, `aria-label` is ignored
|
|
42
|
+
* for embedded controls during name-from-content traversal.
|
|
43
|
+
* 3. **Other element** → first try its full accessible name (Steps 2B–2I via `computeFn`).
|
|
44
|
+
* If no name, fall through to `collectTextContent` for transparent traversal.
|
|
45
|
+
*
|
|
46
|
+
* Parts are joined with a space separator per AccName 1.2 §4.3.2 Step 2C.
|
|
47
|
+
*
|
|
48
|
+
* **Limitation:** CSS-generated content (`::before`/`::after` with the `content`
|
|
49
|
+
* property) is not included. AccName 1.2 §4.3.2 Step 2G specifies that CSS
|
|
50
|
+
* generated textual content should be part of the accumulated text, but markuplint
|
|
51
|
+
* performs static HTML analysis without CSS processing, so this content is
|
|
52
|
+
* unavailable at lint time.
|
|
53
|
+
*
|
|
54
|
+
* @param el - The element whose child nodes to collect text from
|
|
55
|
+
* @param resolver - Environment-dependent resolver for DOM traversal and role queries
|
|
56
|
+
* @param visited - Set of element IDs already visited (cycle prevention)
|
|
57
|
+
* @param computeFn - The recursive accessible name computation function
|
|
58
|
+
* @param inLabelledbyTraversal - Whether this computation is part of an aria-labelledby traversal
|
|
59
|
+
* @returns The concatenated text content from child nodes
|
|
60
|
+
* @see https://www.w3.org/TR/accname-1.2/#computation-steps — AccName 1.2 §4.3.2 Step 2F
|
|
61
|
+
* @see https://www.w3.org/TR/accname-1.2/#comp_embedded_control — AccName 1.2 §4.3.2 Step 2C
|
|
62
|
+
*/
|
|
63
|
+
export function resolveNameFromContent(el, resolver, visited, computeFn, inLabelledbyTraversal) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
for (const child of el.childNodes) {
|
|
66
|
+
if (child.nodeType === TEXT_NODE) {
|
|
67
|
+
parts.push(child.textContent ?? '');
|
|
68
|
+
}
|
|
69
|
+
else if (child.nodeType === ELEMENT_NODE) {
|
|
70
|
+
const childEl = child;
|
|
71
|
+
if (resolver.isEmbeddedControl(childEl)) {
|
|
72
|
+
// Step 2C: Embedded controls — use value directly, aria-label is ignored
|
|
73
|
+
parts.push(getEmbeddedControlValue(childEl));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const result = computeFn(childEl, resolver, inLabelledbyTraversal, visited);
|
|
77
|
+
if (result.name) {
|
|
78
|
+
parts.push(result.name);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
// When name-from-content traversal reaches an element with no accessible name,
|
|
82
|
+
// recursively collect its text content (transparent traversal)
|
|
83
|
+
parts.push(collectTextContent(childEl, resolver, visited, computeFn, inLabelledbyTraversal));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return parts.join(' ');
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* [Implementation-specific extension] Recursively collects text content
|
|
92
|
+
* from an element and its descendants during name-from-content traversal.
|
|
93
|
+
*
|
|
94
|
+
* The AccName spec (§4.3.2 Step 2F) only formally defines name-from-content
|
|
95
|
+
* for elements whose role's `nameFrom` includes `"content"`. However,
|
|
96
|
+
* intermediate wrapper elements (e.g., `<span>` inside `<button>`) may
|
|
97
|
+
* have roles that do NOT include `nameFrom: ["content"]`, yet their text
|
|
98
|
+
* must still be collected for the parent's name computation to work.
|
|
99
|
+
*
|
|
100
|
+
* This function provides that "transparent traversal": when `computeFn`
|
|
101
|
+
* returns no name for a child element, this function recursively collects
|
|
102
|
+
* the child's text content regardless of its role settings.
|
|
103
|
+
*
|
|
104
|
+
* Hidden elements (per `resolver.isHidden`) are excluded.
|
|
105
|
+
*/
|
|
106
|
+
function collectTextContent(el, resolver, visited, computeFn, inLabelledbyTraversal) {
|
|
107
|
+
if (resolver.isHidden(el)) {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
const parts = [];
|
|
111
|
+
for (const child of el.childNodes) {
|
|
112
|
+
if (child.nodeType === TEXT_NODE) {
|
|
113
|
+
parts.push(child.textContent ?? '');
|
|
114
|
+
}
|
|
115
|
+
else if (child.nodeType === ELEMENT_NODE) {
|
|
116
|
+
const childEl = child;
|
|
117
|
+
if (resolver.isEmbeddedControl(childEl)) {
|
|
118
|
+
parts.push(getEmbeddedControlValue(childEl));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const result = computeFn(childEl, resolver, inLabelledbyTraversal, visited);
|
|
122
|
+
if (result.name) {
|
|
123
|
+
parts.push(result.name);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
parts.push(collectTextContent(childEl, resolver, visited, computeFn, inLabelledbyTraversal));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return parts.join(' ');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Gets the value of an embedded control for name-from-content computation.
|
|
135
|
+
*
|
|
136
|
+
* Implements AccName 1.2 §4.3.2 Step 2C embedded control value resolution:
|
|
137
|
+
* - **Range controls** (slider/spinbutton/input[range]):
|
|
138
|
+
* aria-valuetext → aria-valuenow → value attr → textContent
|
|
139
|
+
* - **Textbox/combobox/searchbox** (input[text-like]/textarea):
|
|
140
|
+
* value attr → textContent
|
|
141
|
+
* - **Listbox** (select): selected option text (static analysis approximation)
|
|
142
|
+
*
|
|
143
|
+
* @see https://www.w3.org/TR/accname-1.2/#comp_embedded_control — AccName 1.2 §4.3.2 Step 2C
|
|
144
|
+
*/
|
|
145
|
+
function getEmbeddedControlValue(el) {
|
|
146
|
+
const role = el.getAttribute('role')?.trim().split(/\s+/)[0];
|
|
147
|
+
// Range controls: slider, spinbutton, input[type=range]
|
|
148
|
+
if (role === 'slider' || role === 'spinbutton' || (el.localName === 'input' && getInputType(el) === 'range')) {
|
|
149
|
+
const valuetext = el.getAttribute('aria-valuetext');
|
|
150
|
+
if (valuetext?.trim()) {
|
|
151
|
+
return valuetext;
|
|
152
|
+
}
|
|
153
|
+
const valuenow = el.getAttribute('aria-valuenow');
|
|
154
|
+
if (valuenow?.trim()) {
|
|
155
|
+
return valuenow;
|
|
156
|
+
}
|
|
157
|
+
const value = el.getAttribute('value');
|
|
158
|
+
if (value?.trim()) {
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
return el.textContent ?? '';
|
|
162
|
+
}
|
|
163
|
+
// Textbox/combobox: value attr -> textContent
|
|
164
|
+
if (role === 'textbox' ||
|
|
165
|
+
role === 'combobox' ||
|
|
166
|
+
role === 'searchbox' ||
|
|
167
|
+
el.localName === 'textarea' ||
|
|
168
|
+
(el.localName === 'input' && isTextLikeInput(el))) {
|
|
169
|
+
const value = el.getAttribute('value');
|
|
170
|
+
if (value != null) {
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
return el.textContent ?? '';
|
|
174
|
+
}
|
|
175
|
+
// Listbox/select: selected option text
|
|
176
|
+
if (el.localName === 'select') {
|
|
177
|
+
return getSelectedOptionText(el);
|
|
178
|
+
}
|
|
179
|
+
return el.textContent ?? '';
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Gets the text content of the selected `<option>` elements within a `<select>`.
|
|
183
|
+
*
|
|
184
|
+
* AccName 1.2 §4.3.2 Step 2C specifies that a listbox should return the
|
|
185
|
+
* text of the **selected** option(s). Since this is static analysis, we
|
|
186
|
+
* approximate by checking the `selected` attribute on `<option>` elements.
|
|
187
|
+
* If no option has `selected`, the first non-disabled option is used
|
|
188
|
+
* (matching the HTML spec's default selection behavior for non-`multiple` selects).
|
|
189
|
+
*
|
|
190
|
+
* For `<select multiple>` with multiple `selected` options, texts are
|
|
191
|
+
* joined with a space separator.
|
|
192
|
+
*
|
|
193
|
+
* **Future work (#2069 — customizable `<select>` / `<selectedcontent>`):**
|
|
194
|
+
*
|
|
195
|
+
* When customizable `<select>` is supported:
|
|
196
|
+
* 1. `collectOptions` must skip non-`<option>` children that the new content
|
|
197
|
+
* model allows (e.g., `<button>`, `<datalist>`, `<selectedcontent>`, `<div>`).
|
|
198
|
+
* 2. Consider whether `<selectedcontent>` (which clones the selected option's
|
|
199
|
+
* content into the button area) affects name computation, or whether we
|
|
200
|
+
* should continue to resolve from `<option>` elements directly.
|
|
201
|
+
* 3. Parse5 must support the new `<select>` content model first.
|
|
202
|
+
*
|
|
203
|
+
* @see https://github.com/markuplint/markuplint/issues/2069 — `<select>` and `<selectedcontent>` support
|
|
204
|
+
*/
|
|
205
|
+
function getSelectedOptionText(el) {
|
|
206
|
+
const options = collectOptions(el);
|
|
207
|
+
const selected = options.filter(opt => opt.hasAttribute('selected'));
|
|
208
|
+
if (selected.length > 0) {
|
|
209
|
+
return selected.map(opt => opt.textContent?.trim() ?? '').join(' ');
|
|
210
|
+
}
|
|
211
|
+
// No explicit selected attr: HTML spec says first non-disabled option is selected
|
|
212
|
+
// (only for non-multiple selects, but we approximate for all)
|
|
213
|
+
const first = options.find(opt => !opt.hasAttribute('disabled'));
|
|
214
|
+
return first?.textContent?.trim() ?? '';
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Collects all `<option>` descendant elements from a `<select>`,
|
|
218
|
+
* traversing through `<optgroup>` containers.
|
|
219
|
+
*
|
|
220
|
+
* **Note (#2069):** Customizable `<select>` allows non-option children
|
|
221
|
+
* (e.g., `<button>`, `<datalist>`, `<div>`). This function currently only
|
|
222
|
+
* recognizes `<option>` and `<optgroup>` — it will need updating when the
|
|
223
|
+
* new content model is supported.
|
|
224
|
+
*
|
|
225
|
+
* @see https://github.com/markuplint/markuplint/issues/2069
|
|
226
|
+
*/
|
|
227
|
+
function collectOptions(el) {
|
|
228
|
+
const result = [];
|
|
229
|
+
for (const child of el.children) {
|
|
230
|
+
if (child.localName === 'option') {
|
|
231
|
+
result.push(child);
|
|
232
|
+
}
|
|
233
|
+
else if (child.localName === 'optgroup') {
|
|
234
|
+
for (const grandchild of child.children) {
|
|
235
|
+
if (grandchild.localName === 'option') {
|
|
236
|
+
result.push(grandchild);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Checks if an input element is a text-like type (text, search, tel, url, email, etc.).
|
|
245
|
+
*/
|
|
246
|
+
function isTextLikeInput(el) {
|
|
247
|
+
return TEXT_LIKE_INPUT_TYPES.has(getInputType(el));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Finds the first child element with a matching localName.
|
|
251
|
+
*
|
|
252
|
+
* @param el - The parent element to search within
|
|
253
|
+
* @param localName - The local tag name to match
|
|
254
|
+
* @returns The first matching child element, or null if none found
|
|
255
|
+
*/
|
|
256
|
+
export function findChildByLocalName(el, localName) {
|
|
257
|
+
for (const child of el.children) {
|
|
258
|
+
if (child.localName === localName) {
|
|
259
|
+
return child;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Finds the nearest ancestor label element (implicit label association).
|
|
266
|
+
*
|
|
267
|
+
* @param el - The element to search from
|
|
268
|
+
* @returns The nearest ancestor label element, or null if none found
|
|
269
|
+
*/
|
|
270
|
+
export function findAncestorLabel(el) {
|
|
271
|
+
let current = el.parentElement;
|
|
272
|
+
while (current) {
|
|
273
|
+
if (current.localName === 'label') {
|
|
274
|
+
return current;
|
|
275
|
+
}
|
|
276
|
+
current = current.parentElement;
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Resolves a label for an element via explicit (for=id) or implicit (ancestor) association.
|
|
282
|
+
*
|
|
283
|
+
* @param el - The element to find labels for
|
|
284
|
+
* @param resolver - Environment-dependent resolver for label lookups
|
|
285
|
+
* @returns An array of label elements associated with the element
|
|
286
|
+
*/
|
|
287
|
+
export function resolveLabel(el, resolver) {
|
|
288
|
+
const id = el.id;
|
|
289
|
+
if (id) {
|
|
290
|
+
const explicitLabels = resolver.getLabelsForId(id);
|
|
291
|
+
if (explicitLabels.length > 0) {
|
|
292
|
+
return explicitLabels;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const implicitLabel = findAncestorLabel(el);
|
|
296
|
+
if (implicitLabel) {
|
|
297
|
+
return [implicitLabel];
|
|
298
|
+
}
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Gets the input type, defaulting to 'text' for inputs without a type attribute.
|
|
303
|
+
*
|
|
304
|
+
* @param el - The element to get the input type for
|
|
305
|
+
* @returns The lowercase input type, or an empty string if not an input element
|
|
306
|
+
*/
|
|
307
|
+
export function getInputType(el) {
|
|
308
|
+
if (el.localName !== 'input') {
|
|
309
|
+
return '';
|
|
310
|
+
}
|
|
311
|
+
return (el.getAttribute('type') ?? 'text').toLowerCase();
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Checks if an element is in the SVG namespace.
|
|
315
|
+
*
|
|
316
|
+
* @param el - The element to check
|
|
317
|
+
* @returns True if the element is in the SVG namespace
|
|
318
|
+
*/
|
|
319
|
+
export function isSvgElement(el) {
|
|
320
|
+
return el.namespaceURI === SVG_NAMESPACE;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Escapes a string for safe use inside a CSS selector.
|
|
324
|
+
*
|
|
325
|
+
* @param value - The raw string value (typically an element ID)
|
|
326
|
+
* @returns The escaped string with CSS special characters backslash-escaped
|
|
327
|
+
*/
|
|
328
|
+
export function escapeCSS(value) {
|
|
329
|
+
return value.replaceAll(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
|
330
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { computeAccessibleName } from './compute.js';
|
|
2
|
+
export { escapeCSS } from './helpers.js';
|
|
3
|
+
export { hasSvgAccessibleNameSource } from './svg-helpers.js';
|
|
4
|
+
export type { AccnameElement, AccnameNode, AccnameResolver, AccnameResult, AccnameSource } from './types.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AccnameElement, AccnameResolver, AccnameResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves accessible name from `<label>` element association.
|
|
4
|
+
*
|
|
5
|
+
* Part of AccName 1.2 §4.3.2 Step 2E — for labelable elements, checks
|
|
6
|
+
* the HTML label association before other element-specific rules.
|
|
7
|
+
*
|
|
8
|
+
* Control flow:
|
|
9
|
+
* 1. Find labels via `resolveLabel`: explicit `<label for="id">` first,
|
|
10
|
+
* then implicit ancestor `<label>`.
|
|
11
|
+
* 2. For each label, collect its text content via `collectLabelText`,
|
|
12
|
+
* which walks the label's children but **excludes** the labeled
|
|
13
|
+
* element itself (prevents circular inclusion).
|
|
14
|
+
* 3. Join all label texts with a space separator and flatten whitespace.
|
|
15
|
+
*
|
|
16
|
+
* @param el - The labelable element to resolve label text for
|
|
17
|
+
* @param resolver - Environment-dependent resolver for label lookups
|
|
18
|
+
* @param visited - Set of element IDs already visited (cycle prevention)
|
|
19
|
+
* @param computeFn - The recursive accessible name computation function
|
|
20
|
+
* @param inLabelledbyTraversal - Whether this computation is part of an aria-labelledby traversal
|
|
21
|
+
* @returns The resolved name result, or null if no label provides a name
|
|
22
|
+
* @see https://www.w3.org/TR/accname-1.2/#computation-steps — AccName 1.2 §4.3.2 Step 2E
|
|
23
|
+
* @see https://www.w3.org/TR/html-aam-1.0/#el-input-text — HTML-AAM label association
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveLabelText(el: AccnameElement, resolver: AccnameResolver, visited: ReadonlySet<string>, computeFn: (el: AccnameElement, resolver: AccnameResolver, inLabelledbyTraversal: boolean, visited: ReadonlySet<string>) => AccnameResult, inLabelledbyTraversal: boolean): AccnameResult | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types -- AccnameElement wraps mutable DOM types */
|
|
2
|
+
import { ELEMENT_NODE, TEXT_NODE } from '../../../const/index.js';
|
|
3
|
+
import { flattenText, makeResult, resolveLabel } from './helpers.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves accessible name from `<label>` element association.
|
|
6
|
+
*
|
|
7
|
+
* Part of AccName 1.2 §4.3.2 Step 2E — for labelable elements, checks
|
|
8
|
+
* the HTML label association before other element-specific rules.
|
|
9
|
+
*
|
|
10
|
+
* Control flow:
|
|
11
|
+
* 1. Find labels via `resolveLabel`: explicit `<label for="id">` first,
|
|
12
|
+
* then implicit ancestor `<label>`.
|
|
13
|
+
* 2. For each label, collect its text content via `collectLabelText`,
|
|
14
|
+
* which walks the label's children but **excludes** the labeled
|
|
15
|
+
* element itself (prevents circular inclusion).
|
|
16
|
+
* 3. Join all label texts with a space separator and flatten whitespace.
|
|
17
|
+
*
|
|
18
|
+
* @param el - The labelable element to resolve label text for
|
|
19
|
+
* @param resolver - Environment-dependent resolver for label lookups
|
|
20
|
+
* @param visited - Set of element IDs already visited (cycle prevention)
|
|
21
|
+
* @param computeFn - The recursive accessible name computation function
|
|
22
|
+
* @param inLabelledbyTraversal - Whether this computation is part of an aria-labelledby traversal
|
|
23
|
+
* @returns The resolved name result, or null if no label provides a name
|
|
24
|
+
* @see https://www.w3.org/TR/accname-1.2/#computation-steps — AccName 1.2 §4.3.2 Step 2E
|
|
25
|
+
* @see https://www.w3.org/TR/html-aam-1.0/#el-input-text — HTML-AAM label association
|
|
26
|
+
*/
|
|
27
|
+
export function resolveLabelText(el, resolver, visited, computeFn, inLabelledbyTraversal) {
|
|
28
|
+
const labels = resolveLabel(el, resolver);
|
|
29
|
+
if (labels.length === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const parts = [];
|
|
33
|
+
for (const label of labels) {
|
|
34
|
+
const text = collectLabelText(label, el, resolver, visited, computeFn, inLabelledbyTraversal);
|
|
35
|
+
if (text) {
|
|
36
|
+
parts.push(text);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const name = flattenText(parts.join(' '));
|
|
40
|
+
if (name) {
|
|
41
|
+
return makeResult(name, 'label');
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Collects text from a label element, excluding the labeled element itself.
|
|
47
|
+
* Parts are joined with a space separator, matching the AccName 1.2
|
|
48
|
+
* concatenation behavior for label text.
|
|
49
|
+
*/
|
|
50
|
+
function collectLabelText(label, labeledElement, resolver, visited, computeFn, inLabelledbyTraversal) {
|
|
51
|
+
const parts = [];
|
|
52
|
+
for (const child of label.childNodes) {
|
|
53
|
+
if (child.nodeType === TEXT_NODE) {
|
|
54
|
+
parts.push(child.textContent ?? '');
|
|
55
|
+
}
|
|
56
|
+
else if (child.nodeType === ELEMENT_NODE) {
|
|
57
|
+
const childEl = child;
|
|
58
|
+
if (childEl === labeledElement) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const result = computeFn(childEl, resolver, inLabelledbyTraversal, visited);
|
|
62
|
+
if (result.name) {
|
|
63
|
+
parts.push(result.name);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// Transparent traversal: when the child element has no accessible name
|
|
67
|
+
// (e.g., <span> with role="generic"), recursively collect its descendant text.
|
|
68
|
+
// This matches the behavior of resolveNameFromContent's fallback.
|
|
69
|
+
parts.push(collectLabelText(childEl, labeledElement, resolver, visited, computeFn, inLabelledbyTraversal));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return parts.join(' ');
|
|
74
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AccnameElement } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Checks whether an SVG element has any accessible name source, without performing
|
|
4
|
+
* the full name computation.
|
|
5
|
+
*
|
|
6
|
+
* Used by `get-computed-role.ts` for SVG accessibility tree inclusion decisions
|
|
7
|
+
* per SVG-AAM §5.1.1. This is a lightweight check that avoids the full AccName
|
|
8
|
+
* algorithm, breaking the circular dependency between role computation and
|
|
9
|
+
* name computation.
|
|
10
|
+
*
|
|
11
|
+
* Checks (in order): `aria-label`, `aria-labelledby`, `<title>` child, `<desc>` child.
|
|
12
|
+
*
|
|
13
|
+
* @param el - The SVG element to check for accessible name sources
|
|
14
|
+
* @returns True if the element has aria-label, aria-labelledby, or a title/desc child
|
|
15
|
+
* @see https://www.w3.org/TR/svg-aam-1.0/#include_elements — SVG-AAM §5.1.1
|
|
16
|
+
*/
|
|
17
|
+
export declare function hasSvgAccessibleNameSource(el: AccnameElement): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/prefer-readonly-parameter-types -- AccnameElement wraps mutable DOM types */
|
|
2
|
+
/**
|
|
3
|
+
* Checks whether an SVG element has any accessible name source, without performing
|
|
4
|
+
* the full name computation.
|
|
5
|
+
*
|
|
6
|
+
* Used by `get-computed-role.ts` for SVG accessibility tree inclusion decisions
|
|
7
|
+
* per SVG-AAM §5.1.1. This is a lightweight check that avoids the full AccName
|
|
8
|
+
* algorithm, breaking the circular dependency between role computation and
|
|
9
|
+
* name computation.
|
|
10
|
+
*
|
|
11
|
+
* Checks (in order): `aria-label`, `aria-labelledby`, `<title>` child, `<desc>` child.
|
|
12
|
+
*
|
|
13
|
+
* @param el - The SVG element to check for accessible name sources
|
|
14
|
+
* @returns True if the element has aria-label, aria-labelledby, or a title/desc child
|
|
15
|
+
* @see https://www.w3.org/TR/svg-aam-1.0/#include_elements — SVG-AAM §5.1.1
|
|
16
|
+
*/
|
|
17
|
+
export function hasSvgAccessibleNameSource(el) {
|
|
18
|
+
if (el.getAttribute('aria-label')?.trim()) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (el.getAttribute('aria-labelledby')?.trim()) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
for (const child of el.children) {
|
|
25
|
+
if ((child.localName === 'title' || child.localName === 'desc') && child.textContent?.trim()) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal node interface for accessible name computation.
|
|
3
|
+
* Structurally compatible with both DOM `Element` and `MLElement`,
|
|
4
|
+
* enabling the AccName algorithm to run in both JSDOM (tests, accname-computation.ts)
|
|
5
|
+
* and ml-core (MLElement-based lint rules) environments without adaptation.
|
|
6
|
+
*/
|
|
7
|
+
export interface AccnameNode {
|
|
8
|
+
readonly nodeType: number;
|
|
9
|
+
readonly textContent: string | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Minimal element interface for accessible name computation.
|
|
13
|
+
* MLElement satisfies this interface via structural typing (no adapter needed).
|
|
14
|
+
*/
|
|
15
|
+
export interface AccnameElement extends AccnameNode {
|
|
16
|
+
readonly nodeType: number;
|
|
17
|
+
readonly localName: string;
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly namespaceURI: string | null;
|
|
20
|
+
getAttribute(name: string): string | null;
|
|
21
|
+
hasAttribute(name: string): boolean;
|
|
22
|
+
readonly parentElement: AccnameElement | null;
|
|
23
|
+
readonly children: Iterable<AccnameElement>;
|
|
24
|
+
readonly childNodes: Iterable<AccnameNode>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Environment-dependent resolver for accessible name computation.
|
|
28
|
+
*
|
|
29
|
+
* Decouples the pure AccName algorithm (accname/) from DOM traversal
|
|
30
|
+
* and role resolution. Two implementations exist:
|
|
31
|
+
* - **accname-computation.ts**: DOM-based resolver using `document.getElementById`,
|
|
32
|
+
* `querySelectorAll`, and `getComputedRole` for JSDOM/browser environments.
|
|
33
|
+
* - **ml-core**: MLElement-based resolver using the parsed MLDOM tree.
|
|
34
|
+
*/
|
|
35
|
+
export interface AccnameResolver {
|
|
36
|
+
getElementById(id: string): AccnameElement | null;
|
|
37
|
+
getLabelsForId(id: string): readonly AccnameElement[];
|
|
38
|
+
allowsNameFromContent(el: AccnameElement): boolean;
|
|
39
|
+
isHidden(el: AccnameElement): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Checks if an element is an embedded control whose value should be used
|
|
42
|
+
* in name-from-content computation (textbox, combobox, listbox, spinbutton, slider, searchbox).
|
|
43
|
+
*/
|
|
44
|
+
isEmbeddedControl(el: AccnameElement): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Returns a pre-computed name for an element, bypassing the standard algorithm.
|
|
47
|
+
* Used by ml-core for Pretender integration where framework components provide
|
|
48
|
+
* accessible names through configuration.
|
|
49
|
+
*/
|
|
50
|
+
getPrecomputedName?(el: AccnameElement): string | null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Identifies the source of an element's accessible name per HTML-AAM §4.1.
|
|
54
|
+
* Used for diagnostic purposes (e.g., reporting which source provided the name).
|
|
55
|
+
*/
|
|
56
|
+
export type AccnameSource = 'aria-labelledby' | 'aria-label' | 'label' | 'alt' | 'content' | 'title' | 'placeholder' | 'value' | 'legend' | 'caption' | 'svg-title' | 'default';
|
|
57
|
+
/**
|
|
58
|
+
* Result of accessible name computation, including the name string and its source.
|
|
59
|
+
*/
|
|
60
|
+
export interface AccnameResult {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly source: AccnameSource | null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Internal options passed through recursive calls.
|
|
66
|
+
*/
|
|
67
|
+
export interface AccnameOptions {
|
|
68
|
+
readonly inLabelledbyTraversal?: boolean;
|
|
69
|
+
readonly visited?: ReadonlySet<string>;
|
|
70
|
+
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
import type { ARIAVersion, MLMLSpec } from '../../types/index.js';
|
|
1
2
|
/**
|
|
2
|
-
* Computes the accessible name for
|
|
3
|
-
*
|
|
3
|
+
* Computes the accessible name for a DOM `Element` using the AccName 1.2 algorithm.
|
|
4
|
+
*
|
|
5
|
+
* This is the public facade that bridges the pure AccName algorithm (in `accname/compute.ts`)
|
|
6
|
+
* with the DOM environment. It creates a DOM-based `AccnameResolver` and delegates to
|
|
7
|
+
* `computeAccessibleName`.
|
|
8
|
+
*
|
|
9
|
+
* **Reentrant guard**: Uses a `WeakSet<Element>` to detect and short-circuit
|
|
10
|
+
* recursive calls caused by the `:aria(has name)` pseudo-class selector in
|
|
11
|
+
* `getComputedRole` → `getARIA` → `matches` chain.
|
|
4
12
|
*
|
|
5
13
|
* @param el - The DOM element to compute the accessible name for
|
|
14
|
+
* @param specs - The ML specification data for role resolution
|
|
15
|
+
* @param version - The ARIA version to use for role computation
|
|
6
16
|
* @returns The computed accessible name string, or an empty string if none is found
|
|
17
|
+
* @see https://www.w3.org/TR/accname-1.2/#computation-steps — AccName 1.2 §4.3.2
|
|
7
18
|
*/
|
|
8
|
-
export declare function getAccname(el: Element): string;
|
|
19
|
+
export declare function getAccname(el: Element, specs: MLMLSpec, version: ARIAVersion): string;
|