Skip to content

Commit 44e36e4

Browse files
committed
feat: Extract component description from aria-describedby
Add description field to component metadata tree output. When a component has a label extracted, the toolkit now also resolves aria-describedby on the first child element that has it, concatenating all referenced IDs' text content (matching screen reader behavior). This enables consumers like the page scanner to access constraint text, descriptions, and other accessible descriptions alongside labels.
1 parent eb4fa98 commit 44e36e4

3 files changed

Lines changed: 137 additions & 1 deletion

File tree

src/internal/analytics-metadata/__tests__/metadata-utils.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,109 @@ describe('processMetadata', () => {
337337

338338
document.body.removeChild(mockDiv);
339339
});
340+
341+
test('extracts component description from aria-describedby on label element', () => {
342+
const mockDiv = document.createElement('div');
343+
mockDiv.innerHTML = `
344+
<span id="desc-1">Must be 3-20 characters</span>
345+
<input aria-describedby="desc-1" />
346+
`;
347+
document.body.appendChild(mockDiv);
348+
349+
const result: any = processMetadata(mockDiv, { name: 'awsui.Input', label: 'input' });
350+
expect(result.description).toBe('Must be 3-20 characters');
351+
352+
document.body.removeChild(mockDiv);
353+
});
354+
355+
test('concatenates multiple aria-describedby IDs', () => {
356+
const mockDiv = document.createElement('div');
357+
mockDiv.innerHTML = `
358+
<span id="desc-a">Enter your work email</span>
359+
<span id="desc-b">Must end with @amazon.com</span>
360+
<input aria-describedby="desc-a desc-b" />
361+
`;
362+
document.body.appendChild(mockDiv);
363+
364+
const result: any = processMetadata(mockDiv, { name: 'awsui.Input', label: 'input' });
365+
expect(result.description).toBe('Enter your work email Must end with @amazon.com');
366+
367+
document.body.removeChild(mockDiv);
368+
});
369+
370+
test('does not extract description when label element has no aria-describedby', () => {
371+
const mockDiv = document.createElement('div');
372+
mockDiv.innerHTML = `
373+
<label class="label">Form field label</label>
374+
<div id="ff-desc">This is a description</div>
375+
<input aria-describedby="ff-desc" />
376+
`;
377+
document.body.appendChild(mockDiv);
378+
379+
const result: any = processMetadata(mockDiv, { name: 'awsui.FormField', label: '.label' });
380+
expect(result.description).toBeUndefined();
381+
382+
document.body.removeChild(mockDiv);
383+
});
384+
385+
test('does not extract description when component has no label selector', () => {
386+
const mockDiv = document.createElement('div');
387+
mockDiv.innerHTML = `
388+
<span id="desc-1">Some description</span>
389+
<input aria-describedby="desc-1" />
390+
`;
391+
document.body.appendChild(mockDiv);
392+
393+
const result: any = processMetadata(mockDiv, { name: 'awsui.Input' });
394+
expect(result.description).toBeUndefined();
395+
396+
document.body.removeChild(mockDiv);
397+
});
398+
399+
test('does not extract description from child elements', () => {
400+
const mockDiv = document.createElement('div');
401+
mockDiv.innerHTML = `
402+
<div id="ff-desc">This is a description</div>
403+
<input aria-describedby="ff-desc" />
404+
`;
405+
document.body.appendChild(mockDiv);
406+
407+
const result: any = processMetadata(mockDiv, { name: 'awsui.FormField', label: '.label' });
408+
expect(result.description).toBeUndefined();
409+
410+
document.body.removeChild(mockDiv);
411+
});
412+
413+
test('skips missing IDs in aria-describedby', () => {
414+
const mockDiv = document.createElement('div');
415+
mockDiv.innerHTML = `
416+
<span id="desc-exists">Constraint text</span>
417+
<input aria-describedby="desc-missing desc-exists" />
418+
`;
419+
document.body.appendChild(mockDiv);
420+
421+
const result: any = processMetadata(mockDiv, { name: 'awsui.Input', label: 'input' });
422+
expect(result.description).toBe('Constraint text');
423+
424+
document.body.removeChild(mockDiv);
425+
});
426+
427+
test('extracts description when label is a LabelIdentifier with array selector', () => {
428+
const mockDiv = document.createElement('div');
429+
mockDiv.innerHTML = `
430+
<span id="desc-1">Help text</span>
431+
<button class="trigger" aria-describedby="desc-1">Select</button>
432+
`;
433+
document.body.appendChild(mockDiv);
434+
435+
const result: any = processMetadata(mockDiv, {
436+
name: 'awsui.Select',
437+
label: { selector: ['.trigger', '.fallback'] },
438+
});
439+
expect(result.description).toBe('Help text');
440+
441+
document.body.removeChild(mockDiv);
442+
});
340443
});
341444

342445
describe('merge', () => {

src/internal/analytics-metadata/metadata-utils.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const processMetadata = (
2222
localMetadata: any,
2323
options?: GetComponentsTreeOptions
2424
): GeneratedAnalyticsMetadataFragment => {
25-
return Object.keys(localMetadata).reduce((acc: any, key: string) => {
25+
const result: any = Object.keys(localMetadata).reduce((acc: any, key: string) => {
2626
if (key.toLowerCase().match(/labels$/)) {
2727
acc[key] = processLabel(node, localMetadata[key], 'multi');
2828
} else if (key.toLowerCase().match(/label$/)) {
@@ -68,6 +68,21 @@ export const processMetadata = (
6868
}
6969
return acc;
7070
}, {});
71+
72+
if (result.name && node && localMetadata.label) {
73+
const labelSelector =
74+
typeof localMetadata.label === 'string'
75+
? localMetadata.label
76+
: Array.isArray(localMetadata.label.selector)
77+
? localMetadata.label.selector[0]
78+
: localMetadata.label.selector || '';
79+
const description = resolveComponentDescription(node, labelSelector);
80+
if (description) {
81+
result.description = description;
82+
}
83+
}
84+
85+
return result;
7186
};
7287

7388
const isNil = (value: any) => {
@@ -159,6 +174,23 @@ const resolveInputDescription = (root: HTMLElement, input: HTMLElement): string
159174
return '';
160175
};
161176

177+
const resolveComponentDescription = (node: HTMLElement, labelSelector: string): string => {
178+
const el = labelSelector ? node.querySelector(labelSelector) : node;
179+
if (!el) {
180+
return '';
181+
}
182+
const describedBy = el.getAttribute('aria-describedby');
183+
if (!describedBy) {
184+
return '';
185+
}
186+
const doc = node.ownerDocument || document;
187+
return describedBy
188+
.split(' ')
189+
.map(id => doc.getElementById(id)?.textContent?.trim() || '')
190+
.filter(Boolean)
191+
.join(' ');
192+
};
193+
162194
const getRadioGroupOptions = (node: HTMLElement): Array<OptionItem> => {
163195
const inputs = Array.from(node.querySelectorAll('input[type="radio"]')) as HTMLElement[];
164196
return inputs

src/internal/analytics-metadata/page-scanner-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getGeneratedAnalyticsMetadata } from './utils.js';
88
interface GeneratedAnalyticsMetadataComponentTree {
99
name: string;
1010
label: string;
11+
description?: string;
1112
properties?: Record<string, string | Array<string> | Array<Array<string>> | Array<OptionItem> | Array<TabItem>>;
1213
children?: Array<GeneratedAnalyticsMetadataComponentTree>;
1314
}

0 commit comments

Comments
 (0)