@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.
Files changed (78) hide show
  1. package/ARCHITECTURE.ja.md +24 -10
  2. package/ARCHITECTURE.md +24 -10
  3. package/CHANGELOG.md +34 -0
  4. package/README.md +2 -2
  5. package/docs/aria-algorithms.ja.md +180 -33
  6. package/docs/aria-algorithms.md +183 -34
  7. package/docs/html-algorithms.ja.md +2 -2
  8. package/docs/html-algorithms.md +2 -2
  9. package/docs/maintenance.ja.md +13 -22
  10. package/docs/maintenance.md +13 -22
  11. package/docs/spec-resolution.ja.md +44 -9
  12. package/docs/spec-resolution.md +53 -16
  13. package/docs/type-definitions.ja.md +8 -4
  14. package/docs/type-definitions.md +8 -4
  15. package/lib/algorithm/aria/accname/__tests__/test-helpers.d.ts +45 -0
  16. package/lib/algorithm/aria/accname/__tests__/test-helpers.js +120 -0
  17. package/lib/algorithm/aria/accname/aria-steps.d.ts +51 -0
  18. package/lib/algorithm/aria/accname/aria-steps.js +104 -0
  19. package/lib/algorithm/aria/accname/compute.d.ts +51 -0
  20. package/lib/algorithm/aria/accname/compute.js +101 -0
  21. package/lib/algorithm/aria/accname/element-names.d.ts +36 -0
  22. package/lib/algorithm/aria/accname/element-names.js +342 -0
  23. package/lib/algorithm/aria/accname/helpers.d.ts +98 -0
  24. package/lib/algorithm/aria/accname/helpers.js +330 -0
  25. package/lib/algorithm/aria/accname/index.d.ts +4 -0
  26. package/lib/algorithm/aria/accname/index.js +3 -0
  27. package/lib/algorithm/aria/accname/label-steps.d.ts +25 -0
  28. package/lib/algorithm/aria/accname/label-steps.js +74 -0
  29. package/lib/algorithm/aria/accname/svg-helpers.d.ts +17 -0
  30. package/lib/algorithm/aria/accname/svg-helpers.js +30 -0
  31. package/lib/algorithm/aria/accname/types.d.ts +70 -0
  32. package/lib/algorithm/aria/accname/types.js +2 -0
  33. package/lib/algorithm/aria/accname-computation.d.ts +14 -3
  34. package/lib/algorithm/aria/accname-computation.js +131 -8
  35. package/lib/algorithm/aria/aria-specs.d.ts +1 -0
  36. package/lib/algorithm/aria/get-aria.js +30 -4
  37. package/lib/algorithm/aria/get-computed-role.js +106 -26
  38. package/lib/algorithm/aria/get-explicit-role.d.ts +12 -0
  39. package/lib/algorithm/aria/get-explicit-role.js +12 -0
  40. package/lib/algorithm/aria/get-non-presentational-ancestor.d.ts +12 -0
  41. package/lib/algorithm/aria/get-non-presentational-ancestor.js +14 -2
  42. package/lib/algorithm/aria/get-permitted-roles-spec.d.ts +8 -4
  43. package/lib/algorithm/aria/get-permitted-roles-spec.js +19 -6
  44. package/lib/algorithm/aria/get-role-spec.js +10 -3
  45. package/lib/algorithm/aria/has-required-owned-elements.d.ts +2 -1
  46. package/lib/algorithm/aria/has-required-owned-elements.js +18 -15
  47. package/lib/algorithm/aria/is-presentational.d.ts +24 -0
  48. package/lib/algorithm/aria/is-presentational.js +31 -0
  49. package/lib/algorithm/aria/matches-context-role.d.ts +12 -4
  50. package/lib/algorithm/aria/matches-context-role.js +39 -6
  51. package/lib/algorithm/html/content-model-category-to-tag-names.js +1 -1
  52. package/lib/algorithm/html/get-content-model.d.ts +4 -2
  53. package/lib/algorithm/html/get-content-model.js +6 -7
  54. package/lib/const/accname.d.ts +29 -0
  55. package/lib/const/accname.js +76 -0
  56. package/lib/const/dom.d.ts +8 -0
  57. package/lib/const/dom.js +8 -0
  58. package/lib/const/index.d.ts +2 -0
  59. package/lib/const/index.js +2 -0
  60. package/lib/index.d.ts +3 -0
  61. package/lib/index.js +4 -0
  62. package/lib/types/aria.d.ts +3 -3
  63. package/lib/types/index.d.ts +65 -0
  64. package/lib/utils/aria-version.d.ts +1 -1
  65. package/lib/utils/aria-version.js +1 -1
  66. package/lib/utils/directive-resolver.d.ts +23 -0
  67. package/lib/utils/directive-resolver.js +50 -0
  68. package/lib/utils/get-attr-specs-spec.js +4 -3
  69. package/lib/utils/get-ns.d.ts +7 -0
  70. package/lib/utils/get-ns.js +7 -0
  71. package/lib/utils/get-spec-by-tag-name.d.ts +1 -1
  72. package/lib/utils/merge-array.d.ts +10 -0
  73. package/lib/utils/merge-array.js +10 -0
  74. package/lib/utils/resolve-version.d.ts +11 -0
  75. package/lib/utils/resolve-version.js +13 -1
  76. package/lib/utils/schema-to-spec.d.ts +2 -0
  77. package/lib/utils/schema-to-spec.js +15 -4
  78. 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,3 @@
1
+ export { computeAccessibleName } from './compute.js';
2
+ export { escapeCSS } from './helpers.js';
3
+ export { hasSvgAccessibleNameSource } from './svg-helpers.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
+ }
@@ -0,0 +1,2 @@
1
+ /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types -- AccnameElement wraps mutable DOM types */
2
+ export {};
@@ -1,8 +1,19 @@
1
+ import type { ARIAVersion, MLMLSpec } from '../../types/index.js';
1
2
  /**
2
- * Computes the accessible name for an element using the WAI-ARIA accessible name computation algorithm.
3
- * Falls back to the placeholder attribute value for input elements when no accessible name is found.
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;