Skip to content

Commit 3ff2844

Browse files
feat: Extract component description from aria-describedby (#220)
1 parent 628cf81 commit 3ff2844

3 files changed

Lines changed: 140 additions & 1 deletion

File tree

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,110 @@ 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+
<span class="header">Title</span>
432+
<button class="trigger" aria-describedby="desc-1">Select</button>
433+
`;
434+
document.body.appendChild(mockDiv);
435+
436+
const result: any = processMetadata(mockDiv, {
437+
name: 'awsui.Select',
438+
label: { selector: ['.header', '.trigger'] },
439+
});
440+
expect(result.description).toBe('Help text');
441+
442+
document.body.removeChild(mockDiv);
443+
});
340444
});
341445

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

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

Lines changed: 35 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 && node && localMetadata.label) {
73+
const description = resolveComponentDescription(node, localMetadata.label);
74+
if (description) {
75+
result.description = description;
76+
}
77+
}
78+
79+
return result;
7180
};
7281

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

171+
const resolveComponentDescription = (node: HTMLElement, label: string | { selector?: string | string[] }): string => {
172+
const selectors =
173+
typeof label === 'string' ? [label] : Array.isArray(label.selector) ? label.selector : [label.selector || ''];
174+
const doc = node.ownerDocument || document;
175+
for (const selector of selectors) {
176+
const el = selector ? node.querySelector(selector) : node;
177+
if (!el) {
178+
continue;
179+
}
180+
const describedBy = el.getAttribute('aria-describedby');
181+
if (!describedBy) {
182+
continue;
183+
}
184+
const description = describedBy
185+
.split(' ')
186+
.map(id => doc.getElementById(id)?.textContent?.trim() || '')
187+
.filter(Boolean)
188+
.join(' ');
189+
if (description) {
190+
return description;
191+
}
192+
}
193+
return '';
194+
};
195+
162196
const getRadioGroupOptions = (node: HTMLElement): Array<OptionItem> => {
163197
const inputs = Array.from(node.querySelectorAll('input[type="radio"]')) as HTMLElement[];
164198
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)