diff --git a/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts b/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts
index ca7701c..a4d9021 100644
--- a/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts
+++ b/src/internal/analytics-metadata/__tests__/metadata-utils.test.ts
@@ -177,6 +177,166 @@ describe('processMetadata', () => {
document.body.removeChild(mockTable);
});
+
+ test('extracts table rows when includeAllTableRows option is true', () => {
+ const mockTable = document.createElement('table');
+ mockTable.innerHTML = `
+ | Name | Status |
+
+ | Item 1 | Active |
+ | Item 2 | Inactive |
+
+ `;
+ document.body.appendChild(mockTable);
+
+ const result: any = processMetadata(
+ mockTable,
+ { name: 'awsui.Table', properties: {} },
+ { includeAllTableRows: true }
+ );
+ expect(result.properties.rows).toEqual([
+ ['Item 1', 'Active'],
+ ['Item 2', 'Inactive'],
+ ]);
+
+ document.body.removeChild(mockTable);
+ });
+
+ test('does not extract table rows when includeAllTableRows is false or omitted', () => {
+ const mockTable = document.createElement('table');
+ mockTable.innerHTML = `
+ | Name |
+ | Item 1 |
+ `;
+ document.body.appendChild(mockTable);
+
+ const result1: any = processMetadata(mockTable, { name: 'awsui.Table', properties: {} });
+ expect(result1.properties.rows).toBeUndefined();
+
+ const result2: any = processMetadata(
+ mockTable,
+ { name: 'awsui.Table', properties: {} },
+ { includeAllTableRows: false }
+ );
+ expect(result2.properties.rows).toBeUndefined();
+
+ document.body.removeChild(mockTable);
+ });
+
+ test('extracts RadioGroup options with value, label, and description', () => {
+ const mockDiv = document.createElement('div');
+ mockDiv.innerHTML = `
+
+ First choice
+ Description one
+
+ Second choice
+
+
+ `;
+ document.body.appendChild(mockDiv);
+
+ const result: any = processMetadata(mockDiv, { name: 'awsui.RadioGroup', properties: { value: 'first' } });
+ expect(result.properties.options).toEqual([
+ { value: 'first', label: 'First choice', description: 'Description one' },
+ { value: 'second', label: 'Second choice' },
+ ]);
+
+ document.body.removeChild(mockDiv);
+ });
+
+ test('extracts RadioGroup options using aria-label fallback', () => {
+ const mockDiv = document.createElement('div');
+ mockDiv.innerHTML = `
+
+
+ `;
+ document.body.appendChild(mockDiv);
+
+ const result: any = processMetadata(mockDiv, { name: 'awsui.RadioGroup', properties: {} });
+ expect(result.properties.options).toEqual([
+ { value: 'opt1', label: 'Option One' },
+ { value: 'opt2', label: 'Option Two' },
+ ]);
+
+ document.body.removeChild(mockDiv);
+ });
+
+ test('extracts Tiles options same as RadioGroup', () => {
+ const mockDiv = document.createElement('div');
+ mockDiv.innerHTML = `
+ Tile A
+
+ Tile B
+
+ `;
+ document.body.appendChild(mockDiv);
+
+ const result: any = processMetadata(mockDiv, { name: 'awsui.Tiles', properties: { value: 'a' } });
+ expect(result.properties.options).toEqual([
+ { value: 'a', label: 'Tile A' },
+ { value: 'b', label: 'Tile B' },
+ ]);
+
+ document.body.removeChild(mockDiv);
+ });
+
+ test('extracts Cards items from selection inputs', () => {
+ const mockDiv = document.createElement('div');
+ mockDiv.innerHTML = `
+
+ -
+
+ Running, t2.micro
+
+ -
+
+
+
+ `;
+ document.body.appendChild(mockDiv);
+
+ const result: any = processMetadata(mockDiv, { name: 'awsui.Cards', properties: {} });
+ expect(result.properties.options).toEqual([
+ { value: 'id-1', label: 'Select item 1', description: 'Running, t2.micro' },
+ { value: 'id-2', label: 'Select item 2' },
+ ]);
+
+ document.body.removeChild(mockDiv);
+ });
+
+ test('extracts Tabs items from role=tab elements', () => {
+ const mockDiv = document.createElement('div');
+ mockDiv.innerHTML = `
+
+
+
+
+
+ `;
+ document.body.appendChild(mockDiv);
+
+ const result: any = processMetadata(mockDiv, { name: 'awsui.Tabs', properties: { tabsCount: '3' } });
+ expect(result.properties.tabs).toEqual([
+ { value: 'details', label: 'Details' },
+ { value: 'monitoring', label: 'Monitoring' },
+ { value: 'tags', label: 'Tags', disabled: 'true' },
+ ]);
+
+ document.body.removeChild(mockDiv);
+ });
+
+ test('does not extract options for non-matching components', () => {
+ const mockDiv = document.createElement('div');
+ mockDiv.innerHTML = ``;
+ document.body.appendChild(mockDiv);
+
+ const result: any = processMetadata(mockDiv, { name: 'awsui.Button', properties: {} });
+ expect(result.properties.options).toBeUndefined();
+ expect(result.properties.tabs).toBeUndefined();
+
+ 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 08e7015..6766283 100644
--- a/src/internal/analytics-metadata/metadata-utils.ts
+++ b/src/internal/analytics-metadata/metadata-utils.ts
@@ -3,6 +3,7 @@
import { GeneratedAnalyticsMetadataFragment } from './interfaces.js';
import { processLabel } from './labels-utils.js';
+import type { GetComponentsTreeOptions, OptionItem, TabItem } from './page-scanner-utils.js';
export const mergeMetadata = (
metadata: GeneratedAnalyticsMetadataFragment | null,
@@ -16,14 +17,18 @@ export const mergeMetadata = (
return output;
};
-export const processMetadata = (node: HTMLElement | null, localMetadata: any): GeneratedAnalyticsMetadataFragment => {
+export const processMetadata = (
+ node: HTMLElement | null,
+ localMetadata: any,
+ options?: GetComponentsTreeOptions
+): GeneratedAnalyticsMetadataFragment => {
return 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$/)) {
acc[key] = processLabel(node, localMetadata[key], 'single');
} else if (typeof localMetadata[key] !== 'string' && !Array.isArray(localMetadata[key])) {
- acc[key] = processMetadata(node, localMetadata[key]);
+ acc[key] = processMetadata(node, localMetadata[key], options);
if (key === 'properties' && localMetadata.name === 'awsui.Table') {
const selectedItems = getTableSelectedItems(node);
if (selectedItems.length) {
@@ -33,6 +38,30 @@ export const processMetadata = (node: HTMLElement | null, localMetadata: any): G
if (columns.length) {
acc[key].columnLabels = columns;
}
+ if (options?.includeAllTableRows) {
+ const rows = getTableRows(node!);
+ if (rows.length) {
+ acc[key].rows = rows;
+ }
+ }
+ }
+ if (key === 'properties' && (localMetadata.name === 'awsui.RadioGroup' || localMetadata.name === 'awsui.Tiles')) {
+ const items = getRadioGroupOptions(node!);
+ if (items.length) {
+ acc[key].options = items;
+ }
+ }
+ if (key === 'properties' && localMetadata.name === 'awsui.Cards') {
+ const items = getCardsItems(node!);
+ if (items.length) {
+ acc[key].options = items;
+ }
+ }
+ if (key === 'properties' && localMetadata.name === 'awsui.Tabs') {
+ const tabs = getTabsItems(node!);
+ if (tabs.length) {
+ acc[key].tabs = tabs;
+ }
}
} else {
acc[key] = localMetadata[key];
@@ -95,3 +124,96 @@ const getTableColumns = (node: HTMLElement | null): string[] => {
.filter(Boolean)
: [];
};
+
+const getTableRows = (node: HTMLElement): string[][] => {
+ const rows = Array.from(node.querySelectorAll('tbody tr'));
+ return rows
+ .map(row =>
+ Array.from(row.querySelectorAll('td, th'))
+ .filter(cell => !(cell as HTMLElement).querySelector('input[type="checkbox"], input[type="radio"]'))
+ .map(cell => cell.textContent?.trim() || '')
+ .filter(Boolean)
+ )
+ .filter(row => row.length > 0);
+};
+
+const resolveInputLabel = (root: HTMLElement, input: HTMLElement): string => {
+ const labelledBy = input.getAttribute('aria-labelledby');
+ if (labelledBy) {
+ const doc = root.ownerDocument || document;
+ const labelEl = doc.getElementById(labelledBy.split(' ')[0]);
+ if (labelEl?.textContent?.trim()) {
+ return labelEl.textContent.trim();
+ }
+ }
+ return input.getAttribute('aria-label') || '';
+};
+
+const resolveInputDescription = (root: HTMLElement, input: HTMLElement): string => {
+ const describedBy = input.getAttribute('aria-describedby');
+ if (describedBy) {
+ const doc = root.ownerDocument || document;
+ const descEl = doc.getElementById(describedBy.split(' ')[0]);
+ return descEl?.textContent?.trim() || '';
+ }
+ return '';
+};
+
+const getRadioGroupOptions = (node: HTMLElement): Array => {
+ const inputs = Array.from(node.querySelectorAll('input[type="radio"]')) as HTMLElement[];
+ return inputs
+ .map(input => {
+ const value = input.getAttribute('value') || '';
+ const label = resolveInputLabel(node, input);
+ const description = resolveInputDescription(node, input);
+ const option: OptionItem = { value, label };
+ if (description) {
+ option.description = description;
+ }
+ return option;
+ })
+ .filter(opt => opt.value || opt.label);
+};
+
+const getCardsItems = (node: HTMLElement): Array => {
+ const inputs = Array.from(node.querySelectorAll('input[type="checkbox"], input[type="radio"]')) as HTMLElement[];
+ return inputs
+ .map(input => {
+ const label = resolveInputLabel(node, input);
+ const description = resolveInputDescription(node, input);
+ let value = '';
+ const li = input.closest('li');
+ if (li) {
+ const metadataStr = (li as HTMLElement).dataset?.awsuiAnalytics;
+ if (metadataStr) {
+ try {
+ const meta = JSON.parse(metadataStr);
+ value = meta?.component?.innerContext?.item || '';
+ } catch {
+ /* empty */
+ }
+ }
+ }
+ const item: OptionItem = { value, label };
+ if (description) {
+ item.description = description;
+ }
+ return item;
+ })
+ .filter(opt => opt.value || opt.label);
+};
+
+const getTabsItems = (node: HTMLElement): Array => {
+ const tabs = Array.from(node.querySelectorAll('[role="tab"]')) as HTMLElement[];
+ return tabs
+ .map(tab => {
+ const id = tab.getAttribute('data-testid') || tab.id || '';
+ const label = tab.textContent?.trim() || tab.getAttribute('aria-label') || '';
+ const item: TabItem = { value: id, label };
+ if (tab.getAttribute('aria-disabled') === 'true') {
+ item.disabled = 'true';
+ }
+ return item;
+ })
+ .filter(tab => tab.label);
+};
diff --git a/src/internal/analytics-metadata/page-scanner-utils.ts b/src/internal/analytics-metadata/page-scanner-utils.ts
index 065b9ab..88d5f21 100644
--- a/src/internal/analytics-metadata/page-scanner-utils.ts
+++ b/src/internal/analytics-metadata/page-scanner-utils.ts
@@ -8,10 +8,27 @@ import { getGeneratedAnalyticsMetadata } from './utils.js';
interface GeneratedAnalyticsMetadataComponentTree {
name: string;
label: string;
- properties?: Record | Array>>;
+ properties?: Record | Array> | Array | Array>;
children?: Array;
}
+export interface OptionItem {
+ value: string;
+ label: string;
+ description?: string;
+}
+
+export interface TabItem {
+ value: string;
+ label: string;
+ disabled?: string;
+}
+
+export interface GetComponentsTreeOptions {
+ /** When true, include full table row data in awsui.Table properties. Default: false. */
+ includeAllTableRows?: boolean;
+}
+
interface ComponentsMap {
roots: Array;
parents: Map>;
@@ -85,15 +102,16 @@ const mergeComponentsMaps = (
const getComponentsTreeRecursive = (
componentNodes: Array,
- parentsMap: Map>
+ parentsMap: Map>,
+ options?: GetComponentsTreeOptions
): Array => {
const tree: Array = [];
componentNodes.forEach(componentNode => {
const treeItem: GeneratedAnalyticsMetadataComponentTree = {
- ...getGeneratedAnalyticsMetadata(componentNode).contexts[0].detail,
+ ...getGeneratedAnalyticsMetadata(componentNode, options).contexts[0].detail,
};
const children = parentsMap.has(componentNode)
- ? getComponentsTreeRecursive(parentsMap.get(componentNode)!, parentsMap)
+ ? getComponentsTreeRecursive(parentsMap.get(componentNode)!, parentsMap, options)
: [];
if (children.length > 0) {
treeItem.children = children;
@@ -104,11 +122,12 @@ const getComponentsTreeRecursive = (
};
export const getComponentsTree = (
- node: HTMLElement | Document | null = document
+ node: HTMLElement | Document | null = document,
+ options?: GetComponentsTreeOptions
): Array => {
if (!node) {
return [];
}
const { roots, parents } = buildComponentsMap(node);
- return getComponentsTreeRecursive(roots, parents);
+ return getComponentsTreeRecursive(roots, parents, options);
};
diff --git a/src/internal/analytics-metadata/utils.ts b/src/internal/analytics-metadata/utils.ts
index b3db081..06020a6 100644
--- a/src/internal/analytics-metadata/utils.ts
+++ b/src/internal/analytics-metadata/utils.ts
@@ -3,13 +3,18 @@
export { getRawAnalyticsMetadata } from './testing-utils.js';
export { getComponentsTree } from './page-scanner-utils.js';
+export type { GetComponentsTreeOptions, OptionItem, TabItem } from './page-scanner-utils.js';
import { METADATA_DATA_ATTRIBUTE } from './attributes.js';
import { GeneratedAnalyticsMetadata, GeneratedAnalyticsMetadataFragment } from './interfaces.js';
import { findLogicalParent } from './dom-utils.js';
import { mergeMetadata, processMetadata } from './metadata-utils.js';
+import type { GetComponentsTreeOptions } from './page-scanner-utils.js';
-export const getGeneratedAnalyticsMetadata = (target: HTMLElement | null): GeneratedAnalyticsMetadata => {
+export const getGeneratedAnalyticsMetadata = (
+ target: HTMLElement | null,
+ options?: GetComponentsTreeOptions
+): GeneratedAnalyticsMetadata => {
let metadata: GeneratedAnalyticsMetadataFragment = {};
let currentNode = target;
while (currentNode) {
@@ -17,7 +22,7 @@ export const getGeneratedAnalyticsMetadata = (target: HTMLElement | null): Gener
const currentMetadataString = currentNode.dataset[METADATA_DATA_ATTRIBUTE];
if (currentMetadataString) {
const currentMetadata = JSON.parse(currentMetadataString);
- metadata = mergeMetadata(metadata, processMetadata(currentNode, currentMetadata));
+ metadata = mergeMetadata(metadata, processMetadata(currentNode, currentMetadata, options));
}
} catch {
/* empty */