Skip to content

Commit 9a628bf

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 9a628bf

3 files changed

Lines changed: 114 additions & 2 deletions

File tree

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,89 @@ 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 selector', () => {
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('does not extract description when label element has no aria-describedby', () => {
396+
const mockDiv = document.createElement('div');
397+
mockDiv.innerHTML = `
398+
<label class="label">Form field label</label>
399+
<div id="ff-desc">This is a description</div>
400+
<input aria-describedby="ff-desc" />
401+
`;
402+
document.body.appendChild(mockDiv);
403+
404+
const result: any = processMetadata(mockDiv, { name: 'awsui.FormField', label: '.label' });
405+
expect(result.description).toBeUndefined();
406+
407+
document.body.removeChild(mockDiv);
408+
});
409+
410+
test('skips missing IDs in aria-describedby', () => {
411+
const mockDiv = document.createElement('div');
412+
mockDiv.innerHTML = `
413+
<span id="desc-exists">Constraint text</span>
414+
<input aria-describedby="desc-missing desc-exists" />
415+
`;
416+
document.body.appendChild(mockDiv);
417+
418+
const result: any = processMetadata(mockDiv, { name: 'awsui.Input', label: 'input' });
419+
expect(result.description).toBe('Constraint text');
420+
421+
document.body.removeChild(mockDiv);
422+
});
340423
});
341424

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

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { GeneratedAnalyticsMetadataFragment } from './interfaces.js';
4+
import { GeneratedAnalyticsMetadataFragment, LabelIdentifier } from './interfaces.js';
55
import { processLabel } from './labels-utils.js';
66
import type { GetComponentsTreeOptions, OptionItem, TabItem } from './page-scanner-utils.js';
77

@@ -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,25 @@ const resolveInputDescription = (root: HTMLElement, input: HTMLElement): string
159168
return '';
160169
};
161170

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