Skip to content

Commit 3b89535

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 9cf66dc commit 3b89535

3 files changed

Lines changed: 96 additions & 1 deletion

File tree

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

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

338338
document.body.removeChild(mockDiv);
339339
});
340+
341+
test('extracts component description from aria-describedby when label is present', () => {
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 no aria-describedby exists', () => {
371+
const mockDiv = document.createElement('div');
372+
mockDiv.innerHTML = `<input />`;
373+
document.body.appendChild(mockDiv);
374+
375+
const result: any = processMetadata(mockDiv, { name: 'awsui.Input', label: 'input' });
376+
expect(result.description).toBeUndefined();
377+
378+
document.body.removeChild(mockDiv);
379+
});
380+
381+
test('does not extract description when component has no label', () => {
382+
const mockDiv = document.createElement('div');
383+
mockDiv.innerHTML = `
384+
<span id="desc-1">Some description</span>
385+
<input aria-describedby="desc-1" />
386+
`;
387+
document.body.appendChild(mockDiv);
388+
389+
const result: any = processMetadata(mockDiv, { name: 'awsui.Input' });
390+
expect(result.description).toBeUndefined();
391+
392+
document.body.removeChild(mockDiv);
393+
});
394+
395+
test('skips missing IDs in aria-describedby', () => {
396+
const mockDiv = document.createElement('div');
397+
mockDiv.innerHTML = `
398+
<span id="desc-exists">Constraint text</span>
399+
<input aria-describedby="desc-missing desc-exists" />
400+
`;
401+
document.body.appendChild(mockDiv);
402+
403+
const result: any = processMetadata(mockDiv, { name: 'awsui.Input', label: 'input' });
404+
expect(result.description).toBe('Constraint text');
405+
406+
document.body.removeChild(mockDiv);
407+
});
340408
});
341409

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

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

Lines changed: 27 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,15 @@ export const processMetadata = (
6868
}
6969
return acc;
7070
}, {});
71+
72+
if (result.name && result.label && node) {
73+
const description = resolveComponentDescription(node);
74+
if (description) {
75+
result.description = description;
76+
}
77+
}
78+
79+
return result;
7180
};
7281

7382
const isNil = (value: any) => {
@@ -159,6 +168,23 @@ const resolveInputDescription = (root: HTMLElement, input: HTMLElement): string
159168
return '';
160169
};
161170

171+
const resolveComponentDescription = (node: HTMLElement): string => {
172+
const el = node.querySelector('[aria-describedby]');
173+
if (!el) {
174+
return '';
175+
}
176+
const describedBy = el.getAttribute('aria-describedby');
177+
if (!describedBy) {
178+
return '';
179+
}
180+
const doc = node.ownerDocument || document;
181+
return describedBy
182+
.split(' ')
183+
.map(id => doc.getElementById(id)?.textContent?.trim() || '')
184+
.filter(Boolean)
185+
.join(' ');
186+
};
187+
162188
const getRadioGroupOptions = (node: HTMLElement): Array<OptionItem> => {
163189
const inputs = Array.from(node.querySelectorAll('input[type="radio"]')) as HTMLElement[];
164190
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)