From fae39d1cc634e466b4727a88b73414bee7bf4b36 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 27 May 2026 13:32:38 +0000 Subject: [PATCH] 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. --- .../__tests__/metadata-utils.test.ts | 104 ++++++++++++++++++ .../analytics-metadata/metadata-utils.ts | 36 +++++- .../analytics-metadata/page-scanner-utils.ts | 1 + 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts b/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts index a4d9021..0df4d45 100644 --- a/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts +++ b/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts @@ -337,6 +337,110 @@ describe('processMetadata', () => { document.body.removeChild(mockDiv); }); + + test('extracts component description from aria-describedby on label element', () => { + const mockDiv = document.createElement('div'); + mockDiv.innerHTML = ` + Must be 3-20 characters + + `; + document.body.appendChild(mockDiv); + + const result: any = processMetadata(mockDiv, { name: 'awsui.Input', label: 'input' }); + expect(result.description).toBe('Must be 3-20 characters'); + + document.body.removeChild(mockDiv); + }); + + test('concatenates multiple aria-describedby IDs', () => { + const mockDiv = document.createElement('div'); + mockDiv.innerHTML = ` + Enter your work email + Must end with @amazon.com + + `; + document.body.appendChild(mockDiv); + + const result: any = processMetadata(mockDiv, { name: 'awsui.Input', label: 'input' }); + expect(result.description).toBe('Enter your work email Must end with @amazon.com'); + + document.body.removeChild(mockDiv); + }); + + test('does not extract description when label element has no aria-describedby', () => { + const mockDiv = document.createElement('div'); + mockDiv.innerHTML = ` + +
This is a description
+ + `; + document.body.appendChild(mockDiv); + + const result: any = processMetadata(mockDiv, { name: 'awsui.FormField', label: '.label' }); + expect(result.description).toBeUndefined(); + + document.body.removeChild(mockDiv); + }); + + test('does not extract description when component has no label selector', () => { + const mockDiv = document.createElement('div'); + mockDiv.innerHTML = ` + Some description + + `; + document.body.appendChild(mockDiv); + + const result: any = processMetadata(mockDiv, { name: 'awsui.Input' }); + expect(result.description).toBeUndefined(); + + document.body.removeChild(mockDiv); + }); + + test('does not extract description from child elements', () => { + const mockDiv = document.createElement('div'); + mockDiv.innerHTML = ` +
This is a description
+ + `; + document.body.appendChild(mockDiv); + + const result: any = processMetadata(mockDiv, { name: 'awsui.FormField', label: '.label' }); + expect(result.description).toBeUndefined(); + + document.body.removeChild(mockDiv); + }); + + test('skips missing IDs in aria-describedby', () => { + const mockDiv = document.createElement('div'); + mockDiv.innerHTML = ` + Constraint text + + `; + document.body.appendChild(mockDiv); + + const result: any = processMetadata(mockDiv, { name: 'awsui.Input', label: 'input' }); + expect(result.description).toBe('Constraint text'); + + document.body.removeChild(mockDiv); + }); + + test('extracts description when label is a LabelIdentifier with array selector', () => { + const mockDiv = document.createElement('div'); + mockDiv.innerHTML = ` + Help text + Title + + `; + document.body.appendChild(mockDiv); + + const result: any = processMetadata(mockDiv, { + name: 'awsui.Select', + label: { selector: ['.header', '.trigger'] }, + }); + expect(result.description).toBe('Help text'); + + document.body.removeChild(mockDiv); + }); }); describe('merge', () => { diff --git a/src/internal/analytics-metadata/metadata-utils.ts b/src/internal/analytics-metadata/metadata-utils.ts index 6766283..f1cc3b5 100644 --- a/src/internal/analytics-metadata/metadata-utils.ts +++ b/src/internal/analytics-metadata/metadata-utils.ts @@ -22,7 +22,7 @@ export const processMetadata = ( localMetadata: any, options?: GetComponentsTreeOptions ): GeneratedAnalyticsMetadataFragment => { - return Object.keys(localMetadata).reduce((acc: any, key: string) => { + const result: any = Object.keys(localMetadata).reduce((acc: any, key: string) => { if (key.toLowerCase().match(/labels$/)) { acc[key] = processLabel(node, localMetadata[key], 'multi'); } else if (key.toLowerCase().match(/label$/)) { @@ -68,6 +68,15 @@ export const processMetadata = ( } return acc; }, {}); + + if (result.name && node && localMetadata.label) { + const description = resolveComponentDescription(node, localMetadata.label); + if (description) { + result.description = description; + } + } + + return result; }; const isNil = (value: any) => { @@ -159,6 +168,31 @@ const resolveInputDescription = (root: HTMLElement, input: HTMLElement): string return ''; }; +const resolveComponentDescription = (node: HTMLElement, label: string | { selector?: string | string[] }): string => { + const selectors = + typeof label === 'string' ? [label] : Array.isArray(label.selector) ? label.selector : [label.selector || '']; + const doc = node.ownerDocument || document; + for (const selector of selectors) { + const el = selector ? node.querySelector(selector) : node; + if (!el) { + continue; + } + const describedBy = el.getAttribute('aria-describedby'); + if (!describedBy) { + continue; + } + const description = describedBy + .split(' ') + .map(id => doc.getElementById(id)?.textContent?.trim() || '') + .filter(Boolean) + .join(' '); + if (description) { + return description; + } + } + return ''; +}; + const getRadioGroupOptions = (node: HTMLElement): Array => { const inputs = Array.from(node.querySelectorAll('input[type="radio"]')) as HTMLElement[]; return inputs diff --git a/src/internal/analytics-metadata/page-scanner-utils.ts b/src/internal/analytics-metadata/page-scanner-utils.ts index 88d5f21..ff93e7b 100644 --- a/src/internal/analytics-metadata/page-scanner-utils.ts +++ b/src/internal/analytics-metadata/page-scanner-utils.ts @@ -8,6 +8,7 @@ import { getGeneratedAnalyticsMetadata } from './utils.js'; interface GeneratedAnalyticsMetadataComponentTree { name: string; label: string; + description?: string; properties?: Record | Array> | Array | Array>; children?: Array; }