From d9444697d2e1ff94ffca443aa924aaea21f79ca6 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Thu, 7 May 2026 14:39:08 +0200
Subject: [PATCH 01/15] fix: A11Y Improve screen reader experience for content
display reordering
---
.../content-display/index.tsx | 175 +++++++++---------
1 file changed, 89 insertions(+), 86 deletions(-)
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx
index b0465188a9..7789db8c74 100644
--- a/src/collection-preferences/content-display/index.tsx
+++ b/src/collection-preferences/content-display/index.tsx
@@ -83,97 +83,100 @@ export default function ContentDisplayPreference({
{i18n('contentDisplayPreference.description', description)}
- {/* Filter input */}
- {enableColumnFiltering && (
-
- setColumnFilteringText(detail.filteringText)}
- countText={i18n(
- 'contentDisplayPreference.i18nStrings.columnFilteringCountText',
- i18nStrings?.columnFilteringCountText
- ? i18nStrings?.columnFilteringCountText(sortedAndFilteredOptions.length)
- : undefined,
- format => format({ count: sortedAndFilteredOptions.length })
- )}
- />
-
- )}
-
- {/* No match */}
- {sortedAndFilteredOptions.length === 0 && (
-
-
-
- {i18n(
- 'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText',
- i18nStrings?.columnFilteringNoMatchText
+ {/* Filter input */}
+ {enableColumnFiltering && (
+
+
- setColumnFilteringText('')}>
- {i18n(
+ filteringAriaLabel={i18n(
+ 'contentDisplayPreference.i18nStrings.columnFilteringAriaLabel',
+ i18nStrings?.columnFilteringAriaLabel
+ )}
+ filteringClearAriaLabel={i18n(
'contentDisplayPreference.i18nStrings.columnFilteringClearFilterText',
i18nStrings?.columnFilteringClearFilterText
)}
-
-
-
- )}
+ onChange={({ detail }) => setColumnFilteringText(detail.filteringText)}
+ countText={i18n(
+ 'contentDisplayPreference.i18nStrings.columnFilteringCountText',
+ i18nStrings?.columnFilteringCountText
+ ? i18nStrings?.columnFilteringCountText(sortedAndFilteredOptions.length)
+ : undefined,
+ format => format({ count: sortedAndFilteredOptions.length })
+ )}
+ />
+
+ )}
+
+ {/* No match */}
+ {sortedAndFilteredOptions.length === 0 && (
+
+
+
+ {i18n(
+ 'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText',
+ i18nStrings?.columnFilteringNoMatchText
+ )}
+
+ setColumnFilteringText('')}>
+ {i18n(
+ 'contentDisplayPreference.i18nStrings.columnFilteringClearFilterText',
+ i18nStrings?.columnFilteringClearFilterText
+ )}
+
+
+
+ )}
- ({
- id: item.id,
- content: ,
- announcementLabel: item.label,
- })}
- disableItemPaddings={true}
- sortable={true}
- sortDisabled={columnFilteringText.trim().length > 0}
- onSortingChange={({ detail: { items } }) => {
- onChange(items);
- }}
- ariaDescribedby={descriptionId}
- ariaLabelledby={titleId}
- i18nStrings={{
- liveAnnouncementDndStarted: i18n(
- 'contentDisplayPreference.liveAnnouncementDndStarted',
- liveAnnouncementDndStarted,
- formatDndStarted
- ),
- liveAnnouncementDndItemReordered: i18n(
- 'contentDisplayPreference.liveAnnouncementDndItemReordered',
- liveAnnouncementDndItemReordered,
- formatDndItemReordered
- ),
- liveAnnouncementDndItemCommitted: i18n(
- 'contentDisplayPreference.liveAnnouncementDndItemCommitted',
- liveAnnouncementDndItemCommitted,
- formatDndItemCommitted
- ),
- liveAnnouncementDndDiscarded: i18n(
- 'contentDisplayPreference.liveAnnouncementDndDiscarded',
- liveAnnouncementDndDiscarded
- ),
- dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel),
- dragHandleAriaDescription: i18n(
- 'contentDisplayPreference.dragHandleAriaDescription',
- dragHandleAriaDescription
- ),
- }}
- />
+
+ ({
+ id: item.id,
+ content: ,
+ announcementLabel: item.label,
+ })}
+ disableItemPaddings={true}
+ sortable={true}
+ sortDisabled={columnFilteringText.trim().length > 0}
+ onSortingChange={({ detail: { items } }) => {
+ onChange(items);
+ }}
+ ariaDescribedby={descriptionId}
+ ariaLabelledby={titleId}
+ i18nStrings={{
+ liveAnnouncementDndStarted: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndStarted',
+ liveAnnouncementDndStarted,
+ formatDndStarted
+ ),
+ liveAnnouncementDndItemReordered: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndItemReordered',
+ liveAnnouncementDndItemReordered,
+ formatDndItemReordered
+ ),
+ liveAnnouncementDndItemCommitted: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndItemCommitted',
+ liveAnnouncementDndItemCommitted,
+ formatDndItemCommitted
+ ),
+ liveAnnouncementDndDiscarded: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndDiscarded',
+ liveAnnouncementDndDiscarded
+ ),
+ dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel),
+ dragHandleAriaDescription: i18n(
+ 'contentDisplayPreference.dragHandleAriaDescription',
+ dragHandleAriaDescription
+ ),
+ }}
+ />
+
+
);
}
From 21b5773dbb3f86d7ca84bc369d3f3bd407615f84 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Fri, 8 May 2026 02:44:22 +0200
Subject: [PATCH 02/15] feat: Support Table Column Groups collection prefernces
---
.../content-display-groups.page.tsx | 49 +++
.../collection-preferences/shared-configs.tsx | 53 ++++
.../__snapshots__/documenter.test.ts.snap | 116 ++++++-
.../test-utils-selectors.test.tsx.snap | 2 +
.../__integ__/content-display-groups.test.ts | 102 +++++++
.../__tests__/content-display.test.tsx | 164 ++++++++++
.../content-display/__tests__/utils.test.ts | 226 +++++++++++++-
.../content-display/content-display-list.scss | 6 +
.../content-display-option.scss | 12 +
.../content-display/index.tsx | 288 ++++++++++++++----
.../content-display/utils.ts | 148 ++++++++-
src/collection-preferences/index.tsx | 8 +-
src/collection-preferences/interfaces.ts | 33 +-
src/collection-preferences/utils.tsx | 18 ++
.../content-display-preference.ts | 70 ++++-
15 files changed, 1199 insertions(+), 96 deletions(-)
create mode 100644 pages/collection-preferences/content-display-groups.page.tsx
create mode 100644 src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
diff --git a/pages/collection-preferences/content-display-groups.page.tsx b/pages/collection-preferences/content-display-groups.page.tsx
new file mode 100644
index 0000000000..90291499b4
--- /dev/null
+++ b/pages/collection-preferences/content-display-groups.page.tsx
@@ -0,0 +1,49 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React, { useState } from 'react';
+
+import Box from '~components/box';
+import CollectionPreferences, { CollectionPreferencesProps } from '~components/collection-preferences';
+import SpaceBetween from '~components/space-between';
+
+import { contentDisplayPreferenceI18nStrings } from '../common/i18n-strings';
+import {
+ baseProperties,
+ contentDisplayGroups,
+ groupedContentDisplay,
+ groupedContentDisplayOptions,
+} from './shared-configs';
+
+export default function ContentDisplayGroupsPage() {
+ const [preferences, setPreferences] = useState({
+ contentDisplay: groupedContentDisplay,
+ });
+
+ return (
+
+ Content Display with Groups
+
+ setPreferences(detail)}
+ contentDisplayPreference={{
+ title: 'Column preferences',
+ description: 'Customize column visibility and order.',
+ options: groupedContentDisplayOptions,
+ groups: contentDisplayGroups,
+ enableColumnFiltering: true,
+ ...contentDisplayPreferenceI18nStrings,
+ }}
+ />
+
+ Current preferences.contentDisplay
+
+ {JSON.stringify(preferences.contentDisplay, null, 2)}
+
+
+ );
+}
diff --git a/pages/collection-preferences/shared-configs.tsx b/pages/collection-preferences/shared-configs.tsx
index f474598fe8..17d254856d 100644
--- a/pages/collection-preferences/shared-configs.tsx
+++ b/pages/collection-preferences/shared-configs.tsx
@@ -96,3 +96,56 @@ export const customPreference = (customState: boolean) => (
View as
);
+
+export const groupedContentDisplayOptions: CollectionPreferencesProps.ContentDisplayOption[] = [
+ { id: 'id', label: 'Instance ID', alwaysVisible: true },
+ { id: 'name', label: 'Name' },
+ { id: 'type', label: 'Instance type' },
+ { id: 'az', label: 'Availability zone' },
+ { id: 'state', label: 'State' },
+ { id: 'cpu', label: 'CPU (%)' },
+ { id: 'memory', label: 'Memory (%)' },
+ { id: 'netIn', label: 'Network in (MB/s)' },
+ { id: 'netOut', label: 'Network out (MB/s)' },
+ { id: 'cost', label: 'Monthly cost ($)' },
+];
+
+export const contentDisplayGroups: CollectionPreferencesProps.ContentDisplayOptionGroup[] = [
+ { id: 'config', label: 'Configuration' },
+ { id: 'performance', label: 'Performance' },
+ { id: 'network', label: 'Network' },
+];
+
+export const groupedContentDisplay: CollectionPreferencesProps.ContentDisplayItem[] = [
+ { id: 'id', visible: true },
+ { id: 'name', visible: true },
+ {
+ type: 'group',
+ id: 'config',
+ visible: true,
+ children: [
+ { id: 'type', visible: true },
+ { id: 'az', visible: true },
+ { id: 'state', visible: true },
+ ],
+ },
+ {
+ type: 'group',
+ id: 'performance',
+ visible: true,
+ children: [
+ { id: 'cpu', visible: true },
+ { id: 'memory', visible: true },
+ ],
+ },
+ {
+ type: 'group',
+ id: 'network',
+ visible: true,
+ children: [
+ { id: 'netIn', visible: true },
+ { id: 'netOut', visible: true },
+ ],
+ },
+ { id: 'cost', visible: true },
+];
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
index cc237bfe35..52edfbcebd 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
@@ -8916,6 +8916,9 @@ It contains the following:
- \`title\` (string) - Specifies the text displayed at the top of the preference.
- \`description\` (string) - Specifies the description displayed below the title.
- \`options\` - Specifies an array of options for reordering and visible content selection.
+- \`groups\` - (Optional) Specifies an array of column group definitions for multi-level content display. Each group contains:
+ - \`id\` (string) - A unique identifier for the group.
+ - \`label\` (string) - The text displayed as the group label.
- \`enableColumnFiltering\` (boolean) - Adds a columns filter.
- \`liveAnnouncementDndStarted\` ((position: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when an option is picked.
- \`liveAnnouncementDndDiscarded\` (string) - (Optional) Adds a message to be announced by screen readers when a reordering action is canceled.
@@ -8929,7 +8932,17 @@ Each option contains the following:
- \`label\` (string) - Specifies a short description of the content.
- \`alwaysVisible\` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to \`false\` by default.
-You must provide an ordered list of the items to display in the \`preferences.contentDisplay\` property.",
+You must provide an ordered list of the items to display in the \`preferences.contentDisplay\` property.
+Each content display item is one of the following:
+- \`ContentDisplayColumn\` - Represents a single column.
+ - \`type\` ('column') - (Optional) Identifies the entry as a column. Defaults to \`'column'\` when omitted.
+ - \`id\` (string) - The column identifier.
+ - \`visible\` (boolean) - Whether the column is visible.
+- \`ContentDisplayGroup\` - Represents a column group.
+ - \`type\` ('group') - Identifies the entry as a group.
+ - \`id\` (string) - The group identifier.
+ - \`visible\` (boolean) - Whether the group is visible.
+ - \`children\` (ReadonlyArray) - The columns or nested groups within this group.",
"i18nTag": true,
"inlineType": {
"name": "CollectionPreferencesProps.ContentDisplayPreference",
@@ -8954,6 +8967,11 @@ You must provide an ordered list of the items to display in the \`preferences.co
"optional": true,
"type": "boolean",
},
+ {
+ "name": "groups",
+ "optional": true,
+ "type": "ReadonlyArray",
+ },
{
"inlineType": {
"name": "CollectionPreferencesProps.ContentDisplayPreferenceI18nStrings",
@@ -38034,9 +38052,23 @@ Returns the current value of the input.",
},
},
{
- "description": "Returns options that the user can reorder.",
+ "description": "Returns the top-level items in the preference list.
+
+For tables **without** column grouping this returns all column options.
+For tables **with** column grouping this returns the top-level entries only
+(which are group items). Use \`.findChildrenOptions()\` on a group item to
+access the leaf columns nested within it.",
"name": "findOptions",
- "parameters": [],
+ "parameters": [
+ {
+ "defaultValue": "{}",
+ "flags": {
+ "isOptional": false,
+ },
+ "name": "option",
+ "typeName": "{ group?: boolean | undefined; }",
+ },
+ ],
"returnType": {
"isNullable": false,
"name": "Array",
@@ -38075,6 +38107,33 @@ Returns the current value of the input.",
},
{
"methods": [
+ {
+ "description": "Returns all child option items nested under this item when it is a group.
+Returns \`null\` when this item is a leaf column (has no nested children).
+
+The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's
+nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.",
+ "name": "findChildrenOptions",
+ "parameters": [
+ {
+ "defaultValue": "{}",
+ "flags": {
+ "isOptional": false,
+ },
+ "name": "option",
+ "typeName": "{ group?: boolean | undefined; }",
+ },
+ ],
+ "returnType": {
+ "isNullable": true,
+ "name": "Array",
+ "typeArguments": [
+ {
+ "name": "ContentDisplayOptionWrapper",
+ },
+ ],
+ },
+ },
{
"description": "Returns the drag handle for the option item.",
"name": "findDragHandle",
@@ -38104,7 +38163,8 @@ Returns the current value of the input.",
},
},
{
- "description": "Returns the visibility toggle for the option item.",
+ "description": "Returns the visibility toggle for the option item.
+Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.",
"name": "findVisibilityToggle",
"parameters": [],
"returnType": {
@@ -48969,9 +49029,23 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \
},
},
{
- "description": "Returns options that the user can reorder.",
+ "description": "Returns the top-level items in the preference list.
+
+For tables **without** column grouping this returns all column options.
+For tables **with** column grouping this returns the top-level entries only
+(which are group items). Use \`.findChildrenOptions()\` on a group item to
+access the leaf columns nested within it.",
"name": "findOptions",
- "parameters": [],
+ "parameters": [
+ {
+ "defaultValue": "{}",
+ "flags": {
+ "isOptional": false,
+ },
+ "name": "option",
+ "typeName": "{ group?: boolean | undefined; }",
+ },
+ ],
"returnType": {
"isNullable": false,
"name": "MultiElementWrapper",
@@ -49005,6 +49079,33 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \
},
{
"methods": [
+ {
+ "description": "Returns all child option items nested under this item when it is a group.
+Returns \`null\` when this item is a leaf column (has no nested children).
+
+The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's
+nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.",
+ "name": "findChildrenOptions",
+ "parameters": [
+ {
+ "defaultValue": "{}",
+ "flags": {
+ "isOptional": false,
+ },
+ "name": "option",
+ "typeName": "{ group?: boolean | undefined; }",
+ },
+ ],
+ "returnType": {
+ "isNullable": true,
+ "name": "MultiElementWrapper",
+ "typeArguments": [
+ {
+ "name": "ContentDisplayOptionWrapper",
+ },
+ ],
+ },
+ },
{
"description": "Returns the drag handle for the option item.",
"name": "findDragHandle",
@@ -49024,7 +49125,8 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \
},
},
{
- "description": "Returns the visibility toggle for the option item.",
+ "description": "Returns the visibility toggle for the option item.
+Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.",
"name": "findVisibilityToggle",
"parameters": [],
"returnType": {
diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap
index 5e1d0b1e2d..90d771415e 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap
@@ -155,6 +155,8 @@ exports[`test-utils selectors 1`] = `
"awsui_content-before_tc96w",
"awsui_content-density_tc96w",
"awsui_content-display-description_tc96w",
+ "awsui_content-display-group-children_tc96w",
+ "awsui_content-display-group-header_tc96w",
"awsui_content-display-no-match_tc96w",
"awsui_content-display-option-content_tc96w",
"awsui_content-display-option-label_tc96w",
diff --git a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
new file mode 100644
index 0000000000..8908c8bc93
--- /dev/null
+++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
@@ -0,0 +1,102 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
+
+import createWrapper from '../../../../lib/components/test-utils/selectors';
+import ContentDisplayPageObject from './pages/content-display-page';
+
+const windowDimensions = {
+ width: 1200,
+ height: 1200,
+};
+
+const setupTest = (testFn: (page: ContentDisplayPageObject) => Promise) => {
+ return useBrowser(async browser => {
+ const page = new ContentDisplayPageObject(browser);
+ await browser.url('#/light/collection-preferences/content-display-groups');
+ await page.setWindowSize(windowDimensions);
+ page.wrapper = createWrapper().findCollectionPreferences();
+ await page.openCollectionPreferencesModal();
+ await testFn(page);
+ });
+};
+
+describe('Collection preferences - Grouped Content Display', () => {
+ test(
+ 'renders group headers and leaf options',
+ setupTest(async page => {
+ const modal = page.wrapper.findModal().findContentDisplayPreference();
+ const options = modal.findOptions();
+
+ // Should have options rendered
+ const texts = await page.getElementsText(options.toSelector());
+ expect(texts.length).toBeGreaterThan(0);
+
+ // Should contain group labels
+ const content = await page.getText(modal.toSelector());
+ expect(content).toContain('Configuration');
+ expect(content).toContain('Performance');
+ expect(content).toContain('Network');
+ })
+ );
+
+ test(
+ 'toggles visibility of a leaf option within a group',
+ setupTest(async page => {
+ const modal = page.wrapper.findModal().findContentDisplayPreference();
+ const options = modal.findOptions();
+ const firstOption = options.get(1);
+ const toggle = firstOption.findVisibilityToggle().findNativeInput();
+
+ // Toggle visibility
+ await page.click(toggle.toSelector());
+ })
+ );
+
+ test(
+ 'reorders a group item with drag and drop',
+ setupTest(async page => {
+ const modal = page.wrapper.findModal().findContentDisplayPreference();
+ const options = modal.findOptions();
+
+ // Get initial order
+ const initialTexts = await page.getElementsText(options.toSelector());
+ expect(initialTexts.length).toBeGreaterThan(0);
+
+ // Drag first item down
+ const activeDragHandle = options.get(1).findDragHandle();
+ const targetDragHandle = options.get(3).findDragHandle();
+ await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector());
+
+ // Order should have changed
+ const newTexts = await page.getElementsText(options.toSelector());
+ expect(newTexts).not.toEqual(initialTexts);
+ })
+ );
+
+ test(
+ 'filters options within groups',
+ setupTest(async page => {
+ const modal = page.wrapper.findModal().findContentDisplayPreference();
+ const filterInput = modal.findTextFilter().findInput().findNativeInput();
+
+ // Type a filter
+ await page.click(filterInput.toSelector());
+ await page.keys('Network');
+
+ // Should show filtered results
+ const content = await page.getText(modal.toSelector());
+ expect(content).toContain('Network');
+ })
+ );
+
+ test(
+ 'nested list has aria-label matching group name',
+ setupTest(async page => {
+ const modal = page.wrapper.findModal().findContentDisplayPreference();
+ // Verify nested lists exist by checking content
+ const content = await page.getText(modal.toSelector());
+ expect(content).toContain('Configuration');
+ })
+ );
+});
diff --git a/src/collection-preferences/content-display/__tests__/content-display.test.tsx b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
index 4600424105..1c537077c5 100644
--- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx
+++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
@@ -569,3 +569,167 @@ function expectLabelForToggle(option: ContentDisplayOptionWrapper) {
function pressKey(element: HTMLElement, key: string) {
fireEvent.keyDown(element, { key, code: key });
}
+
+describe('Content Display preference with groups', () => {
+ const groupedPreference: CollectionPreferencesProps.ContentDisplayPreference = {
+ ...contentDisplayPreference,
+ groups: [
+ { id: 'g1', label: 'Group 1' },
+ { id: 'g2', label: 'Group 2' },
+ ],
+ };
+
+ const groupedContentDisplay: CollectionPreferencesProps.ContentDisplayItem[] = [
+ { id: 'id1', visible: true },
+ {
+ type: 'group',
+ id: 'g1',
+ visible: true,
+ children: [
+ { id: 'id2', visible: true },
+ { id: 'id3', visible: false },
+ ],
+ },
+ { type: 'group', id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] },
+ ];
+
+ function renderGroupedContentDisplay(props: Partial = {}) {
+ const wrapper = renderCollectionPreferences({
+ contentDisplayPreference: groupedPreference,
+ preferences: { contentDisplay: groupedContentDisplay },
+ ...props,
+ });
+ wrapper.findTriggerButton().click();
+ return wrapper.findModal()!.findContentDisplayPreference()!;
+ }
+
+ it('renders group headers', () => {
+ const wrapper = renderGroupedContentDisplay();
+ const element = wrapper.getElement();
+ expect(element.textContent).toContain('Group 1');
+ expect(element.textContent).toContain('Group 2');
+ });
+
+ it('renders leaf options within groups', () => {
+ const wrapper = renderGroupedContentDisplay();
+ const options = wrapper.findOptions();
+ // Should render all 4 options (id1 ungrouped + id2, id3 in g1 + id4 in g2)
+ expect(options.length).toBeGreaterThanOrEqual(4);
+ });
+
+ it('renders options with correct visibility state', () => {
+ const wrapper = renderGroupedContentDisplay();
+ const options = wrapper.findOptions();
+ // id1 is visible, id2 is visible, id3 is not visible, id4 is visible
+ const toggleStates = options.map(opt => opt.findVisibilityToggle().findNativeInput().getElement().checked);
+ // At minimum, not all should be checked (id3 is false)
+ expect(toggleStates).toContain(false);
+ });
+
+ it('renders nested lists with aria-label for groups', () => {
+ const wrapper = renderGroupedContentDisplay();
+ const lists = wrapper.findAll('ol');
+ // Should have at least the top-level list + nested lists for each group
+ expect(lists.length).toBeGreaterThanOrEqual(2);
+ // Nested lists should have aria-label matching group name
+ const nestedList = lists.find(l => l.getElement().getAttribute('aria-label') === 'Group 1');
+ expect(nestedList).toBeDefined();
+ });
+
+ it('filters options within groups', () => {
+ const wrapper = renderGroupedContentDisplay({
+ contentDisplayPreference: { ...groupedPreference, enableColumnFiltering: true },
+ });
+ const filterInput = wrapper.findTextFilter()!;
+ filterInput.findInput().setInputValue('Item 2');
+ // Only Item 2 and its parent group should be visible
+ const element = wrapper.getElement();
+ expect(element.textContent).toContain('Item 2');
+ expect(element.textContent).toContain('Group 1');
+ expect(element.textContent).not.toContain('Item 4');
+ });
+
+ it('shows no match state when filter has no results', () => {
+ const wrapper = renderGroupedContentDisplay({
+ contentDisplayPreference: {
+ ...groupedPreference,
+ enableColumnFiltering: true,
+ i18nStrings: { columnFilteringNoMatchText: 'No matches found', columnFilteringClearFilterText: 'Clear' },
+ },
+ });
+ const filterInput = wrapper.findTextFilter()!;
+ filterInput.findInput().setInputValue('nonexistent');
+ expect(wrapper.getElement().textContent).toContain('No matches found');
+ });
+
+ it('findChildrenOptions returns nested options for a group item', () => {
+ const wrapper = renderGroupedContentDisplay();
+ const options = wrapper.findOptions();
+ // Find a group option and check its children
+ for (const option of options) {
+ const children = option.findChildrenOptions();
+ if (children !== null) {
+ expect(children.length).toBeGreaterThan(0);
+ return;
+ }
+ }
+ });
+
+ it('findChildrenOptions with group=true returns only group children', () => {
+ const wrapper = renderGroupedContentDisplay();
+ const options = wrapper.findOptions();
+ for (const option of options) {
+ const children = option.findChildrenOptions({ group: true });
+ if (children !== null && children.length > 0) {
+ // Found group children
+ expect(children.length).toBeGreaterThan(0);
+ return;
+ }
+ }
+ });
+
+ it('findChildrenOptions with group=false returns only leaf children', () => {
+ const wrapper = renderGroupedContentDisplay();
+ const options = wrapper.findOptions();
+ for (const option of options) {
+ const children = option.findChildrenOptions({ group: false });
+ if (children !== null && children.length > 0) {
+ expect(children.length).toBeGreaterThan(0);
+ return;
+ }
+ }
+ });
+
+ it('findOptions returns all items including groups', () => {
+ const wrapper = renderGroupedContentDisplay();
+ const allOptions = wrapper.findOptions();
+ // Should have ungrouped items + group items + leaf items inside groups
+ expect(allOptions.length).toBeGreaterThan(0);
+ });
+
+ it('toggling a grouped leaf option calls onChange with updated tree', () => {
+ const onConfirm = jest.fn();
+ const collectionPreferencesWrapper = renderCollectionPreferences({
+ contentDisplayPreference: groupedPreference,
+ preferences: { contentDisplay: groupedContentDisplay },
+ onConfirm,
+ });
+ collectionPreferencesWrapper.findTriggerButton().click();
+ const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!;
+
+ // Toggle a leaf option visibility — use findOptions() without filter since :has() doesn't work in JSDOM
+ const options = wrapper.findOptions();
+ const toggleableOption = options.find(opt => opt.findVisibilityToggle() !== null);
+ expect(toggleableOption).toBeDefined();
+ toggleableOption!.findVisibilityToggle().findNativeInput().click();
+
+ // Confirm
+ collectionPreferencesWrapper.findModal()!.findFooter()!.findAll('button')[1].click();
+ expect(onConfirm).toHaveBeenCalled();
+ const detail = onConfirm.mock.calls[0][0].detail;
+ expect(detail.contentDisplay).toBeDefined();
+ // Should contain group structure
+ const hasGroup = detail.contentDisplay.some((item: any) => item.type === 'group');
+ expect(hasGroup).toBe(true);
+ });
+});
diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts
index 94d4fb52ae..3a9100f3e7 100644
--- a/src/collection-preferences/content-display/__tests__/utils.test.ts
+++ b/src/collection-preferences/content-display/__tests__/utils.test.ts
@@ -1,6 +1,14 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import { getSortedOptions } from '../utils';
+import {
+ buildOptionTree,
+ flattenOptionTree,
+ getFilteredOptions,
+ getFilteredTree,
+ getSortedOptions,
+ OptionGroupNode,
+ walkLeaves,
+} from '../utils';
describe('getSortedOptions', () => {
it('returns the passed-in options with the desired order and visibility', () => {
@@ -71,3 +79,219 @@ describe('getSortedOptions', () => {
]);
});
});
+
+describe('walkLeaves', () => {
+ it('extracts leaves from flat list', () => {
+ const items = [
+ { id: 'a', visible: true },
+ { id: 'b', visible: false },
+ ];
+ expect(walkLeaves(items)).toEqual([
+ { id: 'a', visible: true },
+ { id: 'b', visible: false },
+ ]);
+ });
+
+ it('extracts leaves from nested groups', () => {
+ const items = [
+ { id: 'a', visible: true },
+ {
+ type: 'group' as const,
+ id: 'g1',
+ visible: true,
+ children: [
+ { id: 'b', visible: true },
+ { id: 'c', visible: false },
+ ],
+ },
+ ];
+ expect(walkLeaves(items)).toEqual([
+ { id: 'a', visible: true },
+ { id: 'b', visible: true },
+ { id: 'c', visible: false },
+ ]);
+ });
+});
+
+describe('buildOptionTree', () => {
+ it('returns flat leaf nodes when no groups provided', () => {
+ const options = [
+ { id: 'a', label: 'A' },
+ { id: 'b', label: 'B' },
+ ];
+ const contentDisplay = [
+ { id: 'a', visible: true },
+ { id: 'b', visible: false },
+ ];
+ const tree = buildOptionTree(options, [], contentDisplay);
+ expect(tree).toHaveLength(2);
+ expect(tree[0]).toMatchObject({ type: 'leaf' as const, id: 'a', label: 'A', visible: true });
+ expect(tree[1]).toMatchObject({ type: 'leaf' as const, id: 'b', label: 'B', visible: false });
+ });
+
+ it('builds grouped tree from contentDisplay', () => {
+ const options = [
+ { id: 'a', label: 'A' },
+ { id: 'b', label: 'B' },
+ { id: 'c', label: 'C' },
+ ];
+ const groups = [{ id: 'g1', label: 'Group 1' }];
+ const contentDisplay = [
+ { id: 'a', visible: true },
+ {
+ type: 'group' as const,
+ id: 'g1',
+ visible: true,
+ children: [
+ { id: 'b', visible: true },
+ { id: 'c', visible: false },
+ ],
+ },
+ ];
+ const tree = buildOptionTree(options, groups, contentDisplay);
+ expect(tree).toHaveLength(2);
+ expect(tree[0]).toMatchObject({ type: 'leaf' as const, id: 'a', label: 'A' });
+ expect(tree[1]).toMatchObject({ type: 'group' as const, id: 'g1', label: 'Group 1', visible: true });
+ expect((tree[1] as OptionGroupNode).children).toHaveLength(2);
+ expect((tree[1] as OptionGroupNode).children[0]).toMatchObject({ type: 'leaf' as const, id: 'b', visible: true });
+ expect((tree[1] as OptionGroupNode).children[1]).toMatchObject({ type: 'leaf' as const, id: 'c', visible: false });
+ });
+
+ it('uses group id as label when group definition not found', () => {
+ const options = [
+ { id: 'a', label: 'A' },
+ { id: 'b', label: 'B' },
+ ];
+ const groups = [{ id: 'existing', label: 'Existing' }];
+ const contentDisplay = [
+ { id: 'a', visible: true },
+ { type: 'group' as const, id: 'nonexistent', visible: true, children: [{ id: 'b', visible: true }] },
+ ];
+ const tree = buildOptionTree(options, groups, contentDisplay);
+ expect(tree).toHaveLength(2);
+ expect(tree[1]).toMatchObject({ type: 'group' as const, id: 'nonexistent', label: 'nonexistent' });
+ });
+});
+
+describe('flattenOptionTree', () => {
+ it('converts leaf nodes back to ContentDisplayItem', () => {
+ const tree = [
+ { type: 'leaf' as const, id: 'a', label: 'A', visible: true },
+ { type: 'leaf' as const, id: 'b', label: 'B', visible: false },
+ ];
+ const result = flattenOptionTree(tree);
+ expect(result).toEqual([
+ { id: 'a', visible: true },
+ { id: 'b', visible: false },
+ ]);
+ });
+
+ it('converts group nodes back to ContentDisplayGroup', () => {
+ const tree = [
+ { type: 'leaf' as const, id: 'a', label: 'A', visible: true },
+ {
+ type: 'group' as const,
+ id: 'g1',
+ label: 'G1',
+ visible: true,
+ children: [
+ { type: 'leaf' as const, id: 'b', label: 'B', visible: true },
+ { type: 'leaf' as const, id: 'c', label: 'C', visible: false },
+ ],
+ },
+ ];
+ const result = flattenOptionTree(tree);
+ expect(result).toEqual([
+ { id: 'a', visible: true },
+ {
+ type: 'group' as const,
+ id: 'g1',
+ visible: true,
+ children: [
+ { id: 'b', visible: true },
+ { id: 'c', visible: false },
+ ],
+ },
+ ]);
+ });
+});
+
+describe('getFilteredTree', () => {
+ it('returns full tree when filter is empty', () => {
+ const tree = [
+ { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true },
+ {
+ type: 'group' as const,
+ id: 'g',
+ label: 'Group',
+ visible: true,
+ children: [{ type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }],
+ },
+ ];
+ expect(getFilteredTree(tree, '')).toEqual(tree);
+ expect(getFilteredTree(tree, ' ')).toEqual(tree);
+ });
+
+ it('filters leaf nodes by label', () => {
+ const tree = [
+ { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true },
+ { type: 'leaf' as const, id: 'b', label: 'Beta', visible: true },
+ ];
+ const result = getFilteredTree(tree, 'alp');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('a');
+ });
+
+ it('keeps groups with matching descendants', () => {
+ const tree = [
+ {
+ type: 'group' as const,
+ id: 'g',
+ label: 'Group',
+ visible: true,
+ children: [
+ { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true },
+ { type: 'leaf' as const, id: 'b', label: 'Beta', visible: true },
+ ],
+ },
+ ];
+ const result = getFilteredTree(tree, 'alpha');
+ expect(result).toHaveLength(1);
+ expect((result[0] as OptionGroupNode).children).toHaveLength(1);
+ expect((result[0] as OptionGroupNode).children[0].id).toBe('a');
+ });
+
+ it('removes groups with no matching descendants', () => {
+ const tree = [
+ {
+ type: 'group' as const,
+ id: 'g',
+ label: 'Group',
+ visible: true,
+ children: [{ type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }],
+ },
+ ];
+ const result = getFilteredTree(tree, 'xyz');
+ expect(result).toHaveLength(0);
+ });
+});
+
+describe('getFilteredOptions', () => {
+ it('returns all options when filter is empty', () => {
+ const options = [
+ { id: 'a', label: 'Alpha', visible: true },
+ { id: 'b', label: 'Beta', visible: true },
+ ];
+ expect(getFilteredOptions(options, '')).toEqual(options);
+ });
+
+ it('filters by label', () => {
+ const options = [
+ { id: 'a', label: 'Alpha', visible: true },
+ { id: 'b', label: 'Beta', visible: true },
+ ];
+ const result = getFilteredOptions(options, 'bet');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('b');
+ });
+});
diff --git a/src/collection-preferences/content-display/content-display-list.scss b/src/collection-preferences/content-display/content-display-list.scss
index 7ad93c0c62..c47c645866 100644
--- a/src/collection-preferences/content-display/content-display-list.scss
+++ b/src/collection-preferences/content-display/content-display-list.scss
@@ -32,3 +32,9 @@
padding-block: 0;
padding-inline: 0;
}
+
+// 28px text-to-text indentation between group header and child items.
+// The drag handle (~20px) is rendered before the content, so we subtract it.
+.content-display-group-children {
+ padding-inline-start: calc(28px - #{awsui.$size-icon-normal} - 2 * #{awsui.$space-scaled-xxxs});
+}
diff --git a/src/collection-preferences/content-display/content-display-option.scss b/src/collection-preferences/content-display/content-display-option.scss
index 8105fb1b93..e13a28831c 100644
--- a/src/collection-preferences/content-display/content-display-option.scss
+++ b/src/collection-preferences/content-display/content-display-option.scss
@@ -27,3 +27,15 @@
@include styles.text-wrapping;
padding-inline-end: awsui.$space-l;
}
+
+.content-display-group-header {
+ @include styles.styles-reset;
+ display: flex;
+ align-items: flex-start;
+ padding-block: awsui.$space-scaled-xs;
+ padding-inline-end: awsui.$space-xs;
+ border-start-start-radius: awsui.$border-radius-item;
+ border-start-end-radius: awsui.$border-radius-item;
+ border-end-start-radius: awsui.$border-radius-item;
+ border-end-end-radius: awsui.$border-radius-item;
+}
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx
index 7789db8c74..3bb56651e6 100644
--- a/src/collection-preferences/content-display/index.tsx
+++ b/src/collection-preferences/content-display/index.tsx
@@ -18,7 +18,14 @@ import InternalTextFilter from '../../text-filter/internal';
import { getAnalyticsInnerContextAttribute } from '../analytics-metadata/utils';
import { CollectionPreferencesProps } from '../interfaces';
import ContentDisplayOption from './content-display-option';
-import { getFilteredOptions, getSortedOptions, OptionWithVisibility } from './utils';
+import {
+ buildOptionTree,
+ flattenOptionTree,
+ getFilteredOptions,
+ getFilteredTree,
+ getSortedOptions,
+ OptionTreeNode,
+} from './utils';
import styles from '../styles.css.js';
@@ -30,6 +37,150 @@ interface ContentDisplayPreferenceProps extends CollectionPreferencesProps.Conte
onChange: (value: ReadonlyArray) => void;
value?: ReadonlyArray;
}
+function getDndI18nStrings(
+ i18n: ReturnType>,
+ props: Pick<
+ ContentDisplayPreferenceProps,
+ | 'liveAnnouncementDndStarted'
+ | 'liveAnnouncementDndItemReordered'
+ | 'liveAnnouncementDndItemCommitted'
+ | 'liveAnnouncementDndDiscarded'
+ | 'dragHandleAriaLabel'
+ | 'dragHandleAriaDescription'
+ >
+) {
+ return {
+ liveAnnouncementDndStarted: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndStarted',
+ props.liveAnnouncementDndStarted,
+ formatDndStarted
+ ),
+ liveAnnouncementDndItemReordered: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndItemReordered',
+ props.liveAnnouncementDndItemReordered,
+ formatDndItemReordered
+ ),
+ liveAnnouncementDndItemCommitted: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndItemCommitted',
+ props.liveAnnouncementDndItemCommitted,
+ formatDndItemCommitted
+ ),
+ liveAnnouncementDndDiscarded: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndDiscarded',
+ props.liveAnnouncementDndDiscarded
+ ),
+ dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', props.dragHandleAriaLabel),
+ dragHandleAriaDescription: i18n(
+ 'contentDisplayPreference.dragHandleAriaDescription',
+ props.dragHandleAriaDescription
+ ),
+ };
+}
+
+interface HierarchicalContentDisplayProps {
+ tree: OptionTreeNode[];
+ onToggle: (id: string) => void;
+ onTreeChange: (newTree: OptionTreeNode[]) => void;
+ ariaLabel?: string;
+ ariaLabelledby?: string;
+ ariaDescribedby?: string;
+ i18nStrings: React.ComponentProps['i18nStrings'];
+ sortDisabled?: boolean;
+ parentGroupLabel?: string;
+}
+
+function GroupItem({
+ node,
+ onToggle,
+ onChildrenChange,
+ i18nStrings,
+ sortDisabled,
+}: {
+ node: OptionTreeNode & { type: 'group' };
+ onToggle: (id: string) => void;
+ onChildrenChange: (children: OptionTreeNode[]) => void;
+ i18nStrings: React.ComponentProps['i18nStrings'];
+ sortDisabled: boolean;
+}) {
+ return (
+
+
+
+ {node.label}
+
+
+ {node.children.length > 0 && (
+
+
+
+ )}
+
+ );
+}
+
+function HierarchicalContentDisplay({
+ tree,
+ onToggle,
+ onTreeChange,
+ ariaLabel,
+ ariaLabelledby,
+ ariaDescribedby,
+ i18nStrings,
+ sortDisabled = false,
+ parentGroupLabel,
+}: HierarchicalContentDisplayProps) {
+ return (
+ onTreeChange([...items])
+ }
+ renderItem={node => ({
+ id: node.id,
+ announcementLabel:
+ node.type === 'group'
+ ? `${node.label}, ${node.children.length} items`
+ : parentGroupLabel
+ ? `${node.label}, ${parentGroupLabel}`
+ : node.label,
+ content:
+ node.type === 'group' ? (
+
+ onTreeChange(
+ tree.map(n => (n.id === node.id && n.type === 'group' ? { ...n, children: newChildren } : n))
+ )
+ }
+ i18nStrings={i18nStrings}
+ sortDisabled={sortDisabled}
+ />
+ ) : (
+ onToggle(node.id)} />
+ ),
+ })}
+ />
+ );
+}
export default function ContentDisplayPreference({
title,
@@ -39,15 +190,11 @@ export default function ContentDisplayPreference({
id,
visible: true,
})),
+ groups,
onChange,
- liveAnnouncementDndStarted,
- liveAnnouncementDndItemReordered,
- liveAnnouncementDndItemCommitted,
- liveAnnouncementDndDiscarded,
- dragHandleAriaDescription,
- dragHandleAriaLabel,
enableColumnFiltering = false,
i18nStrings,
+ ...dndProps
}: ContentDisplayPreferenceProps) {
const idPrefix = useUniqueId(componentPrefix);
const i18n = useInternalI18n('collection-preferences');
@@ -56,18 +203,44 @@ export default function ContentDisplayPreference({
const titleId = `${idPrefix}-title`;
const descriptionId = `${idPrefix}-description`;
- const [sortedOptions, sortedAndFilteredOptions] = useMemo(() => {
- const sorted = getSortedOptions({ options, contentDisplay: value });
- const filtered = getFilteredOptions(sorted, columnFilteringText);
- return [sorted, filtered];
- }, [columnFilteringText, options, value]);
+ const listI18nStrings = getDndI18nStrings(i18n, dndProps);
+ const hasGroups = !!groups && groups.length > 0;
+ const isFiltering = columnFilteringText.trim().length > 0;
- const onToggle = (option: OptionWithVisibility) => {
- // We use sortedOptions as base and not value because there might be options that
- // are not in the value yet, so they're added as non-visible after the known ones.
- onChange(sortedOptions.map(({ id, visible }) => ({ id, visible: id === option.id ? !option.visible : visible })));
- };
+ const sortedOptions = useMemo(() => getSortedOptions({ options, contentDisplay: value }), [options, value]);
+ const filteredOptions = useMemo(
+ () => getFilteredOptions(sortedOptions, columnFilteringText),
+ [sortedOptions, columnFilteringText]
+ );
+ const optionTree = useMemo(
+ () => (hasGroups ? buildOptionTree(options, groups, value) : null),
+ [hasGroups, groups, options, value]
+ );
+ const filteredTree = useMemo(
+ () => (optionTree ? getFilteredTree(optionTree, columnFilteringText) : null),
+ [optionTree, columnFilteringText]
+ );
+ const handleToggle = (id: string) => {
+ // For flat (non-grouped) mode, rebuild from sortedOptions to handle items not in value
+ if (!hasGroups) {
+ onChange(sortedOptions.map(opt => ({ id: opt.id, visible: opt.id === id ? !opt.visible : opt.visible })));
+ return;
+ }
+ // For grouped mode, walk the tree and flip the matching item
+ // istanbul ignore next: covered by integration tests
+ const toggle = (
+ items: ReadonlyArray
+ ): CollectionPreferencesProps.ContentDisplayItem[] =>
+ items.map(item => {
+ if (item.type === 'group') {
+ return { ...item, children: toggle(item.children) };
+ }
+ return item.id === id ? { ...item, visible: !item.visible } : item;
+ });
+ onChange(toggle(value));
+ };
+ const noResults = filteredTree ? filteredTree.length === 0 : filteredOptions.length === 0;
return (
setColumnFilteringText(detail.filteringText)}
countText={i18n(
'contentDisplayPreference.i18nStrings.columnFilteringCountText',
- i18nStrings?.columnFilteringCountText
- ? i18nStrings?.columnFilteringCountText(sortedAndFilteredOptions.length)
- : undefined,
- format => format({ count: sortedAndFilteredOptions.length })
+ i18nStrings?.columnFilteringCountText?.(filteredOptions.length),
+ format => format({ count: filteredOptions.length })
)}
/>
)}
- {/* No match */}
- {sortedAndFilteredOptions.length === 0 && (
+ {noResults && (
@@ -133,48 +303,36 @@ export default function ContentDisplayPreference({
)}
- ({
- id: item.id,
- content: ,
- announcementLabel: item.label,
- })}
- disableItemPaddings={true}
- sortable={true}
- sortDisabled={columnFilteringText.trim().length > 0}
- onSortingChange={({ detail: { items } }) => {
- onChange(items);
- }}
- ariaDescribedby={descriptionId}
- ariaLabelledby={titleId}
- i18nStrings={{
- liveAnnouncementDndStarted: i18n(
- 'contentDisplayPreference.liveAnnouncementDndStarted',
- liveAnnouncementDndStarted,
- formatDndStarted
- ),
- liveAnnouncementDndItemReordered: i18n(
- 'contentDisplayPreference.liveAnnouncementDndItemReordered',
- liveAnnouncementDndItemReordered,
- formatDndItemReordered
- ),
- liveAnnouncementDndItemCommitted: i18n(
- 'contentDisplayPreference.liveAnnouncementDndItemCommitted',
- liveAnnouncementDndItemCommitted,
- formatDndItemCommitted
- ),
- liveAnnouncementDndDiscarded: i18n(
- 'contentDisplayPreference.liveAnnouncementDndDiscarded',
- liveAnnouncementDndDiscarded
- ),
- dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel),
- dragHandleAriaDescription: i18n(
- 'contentDisplayPreference.dragHandleAriaDescription',
- dragHandleAriaDescription
- ),
- }}
- />
+ {optionTree && filteredTree ? (
+ onChange(flattenOptionTree(newTree))
+ }
+ ariaLabelledby={titleId}
+ ariaDescribedby={descriptionId}
+ i18nStrings={listI18nStrings}
+ sortDisabled={isFiltering}
+ />
+ ) : (
+ onChange(items.map(({ id, visible }) => ({ id, visible })))}
+ renderItem={item => ({
+ id: item.id,
+ announcementLabel: item.label,
+ content: handleToggle(item.id)} />,
+ })}
+ />
+ )}
diff --git a/src/collection-preferences/content-display/utils.ts b/src/collection-preferences/content-display/utils.ts
index 9877ce3ed6..09ce599b0c 100644
--- a/src/collection-preferences/content-display/utils.ts
+++ b/src/collection-preferences/content-display/utils.ts
@@ -2,10 +2,47 @@
// SPDX-License-Identifier: Apache-2.0
import { CollectionPreferencesProps } from '../interfaces';
-export interface OptionWithVisibility extends CollectionPreferencesProps.ContentDisplayOption {
+type ContentDisplayItem = CollectionPreferencesProps.ContentDisplayItem;
+type ContentDisplayOption = CollectionPreferencesProps.ContentDisplayOption;
+type ContentDisplayOptionGroup = CollectionPreferencesProps.ContentDisplayOptionGroup;
+
+export interface OptionWithVisibility extends ContentDisplayOption {
+ visible: boolean;
+}
+
+export interface OptionGroupNode {
+ type: 'group';
+ id: string;
+ label: string;
visible: boolean;
+ children: OptionTreeNode[];
+}
+
+export interface OptionLeafNode extends OptionWithVisibility {
+ type: 'leaf';
+}
+
+export type OptionTreeNode = OptionGroupNode | OptionLeafNode;
+
+/**
+ * Extracts a flat ordered list of leaf items from the contentDisplay tree (depth-first).
+ */
+export function walkLeaves(items: ReadonlyArray): { id: string; visible: boolean }[] {
+ const result: { id: string; visible: boolean }[] = [];
+ for (const item of items) {
+ if (item.type === 'group') {
+ result.push(...walkLeaves(item.children));
+ } else {
+ result.push({ id: item.id, visible: item.visible });
+ }
+ }
+ return result;
}
+/**
+ * Returns options ordered by contentDisplay, with visibility applied.
+ * Options not in contentDisplay are appended as non-visible.
+ */
export function getSortedOptions({
options,
contentDisplay,
@@ -13,27 +50,106 @@ export function getSortedOptions({
options: ReadonlyArray;
contentDisplay: ReadonlyArray;
}): ReadonlyArray {
- // By using a Map, we are guaranteed to preserve insertion order on future iteration.
- const optionsById = new Map();
- // We insert contentDisplay first so we respect the currently selected order
- for (const { id, visible } of contentDisplay) {
- // If an option is provided in contentDisplay and not options, we default the label to the id
- optionsById.set(id, { id, label: id, visible });
+ const optionMap = new Map(options.map(o => [o.id, o]));
+ const result = new Map();
+
+ for (const { id, visible } of walkLeaves(contentDisplay)) {
+ const option = optionMap.get(id);
+ if (option) {
+ result.set(id, { ...option, visible });
+ }
}
- // We merge options data, and insert any that were not in contentDisplay as non-visible
+
for (const option of options) {
- const existing = optionsById.get(option.id);
- optionsById.set(option.id, { ...option, visible: !!existing?.visible });
+ if (!result.has(option.id)) {
+ result.set(option.id, { ...option, visible: false });
+ }
}
- return Array.from(optionsById.values());
+
+ return Array.from(result.values());
}
-export function getFilteredOptions(options: ReadonlyArray, filterText: string) {
- filterText = filterText.trim().toLowerCase();
+/**
+ * Converts contentDisplay tree into an internal OptionTreeNode tree,
+ * resolving labels from options/groups definitions.
+ */
+export function buildOptionTree(
+ options: ReadonlyArray,
+ groups: ReadonlyArray,
+ contentDisplay: ReadonlyArray
+): OptionTreeNode[] {
+ if (!groups.length) {
+ const sorted = getSortedOptions({ options, contentDisplay });
+ return sorted.map(opt => ({ ...opt, type: 'leaf' as const }));
+ }
- if (!filterText) {
- return options;
+ const optionMap = new Map(options.map(o => [o.id, o]));
+ const groupMap = new Map(groups.map(g => [g.id, g]));
+
+ const convert = (items: ReadonlyArray): OptionTreeNode[] => {
+ const result: OptionTreeNode[] = [];
+ for (const item of items) {
+ if (item.type === 'group') {
+ const group = groupMap.get(item.id);
+ result.push({
+ type: 'group',
+ id: item.id,
+ label: group?.label ?? item.id,
+ visible: item.visible,
+ children: convert(item.children),
+ });
+ } else {
+ const option = optionMap.get(item.id);
+ if (option) {
+ result.push({ type: 'leaf', ...option, visible: item.visible });
+ }
+ }
+ }
+ return result;
+ };
+
+ return convert(contentDisplay);
+}
+
+/**
+ * Converts OptionTreeNode[] back to ContentDisplayItem[].
+ */
+export function flattenOptionTree(tree: OptionTreeNode[]): ContentDisplayItem[] {
+ return tree.map(node => {
+ if (node.type === 'group') {
+ return { type: 'group' as const, id: node.id, visible: node.visible, children: flattenOptionTree(node.children) };
+ }
+ return { id: node.id, visible: node.visible };
+ });
+}
+
+/**
+ * Filters tree, keeping leaves matching filterText and groups with matching descendants.
+ */
+export function getFilteredTree(tree: OptionTreeNode[], filterText: string): OptionTreeNode[] {
+ const text = filterText.trim().toLowerCase();
+ if (!text) {
+ return tree;
+ }
+
+ const result: OptionTreeNode[] = [];
+ for (const node of tree) {
+ if (node.type === 'group') {
+ const children = getFilteredTree(node.children, text);
+ if (children.length > 0) {
+ result.push({ ...node, children });
+ }
+ } else if (node.label.toLowerCase().includes(text)) {
+ result.push(node);
+ }
}
+ return result;
+}
- return options.filter(option => option.label.toLowerCase().trim().includes(filterText));
+export function getFilteredOptions(options: ReadonlyArray, filterText: string) {
+ const text = filterText.trim().toLowerCase();
+ if (!text) {
+ return options;
+ }
+ return options.filter(option => option.label.toLowerCase().includes(text));
}
diff --git a/src/collection-preferences/index.tsx b/src/collection-preferences/index.tsx
index 6e9bdecea5..ec882f66c1 100644
--- a/src/collection-preferences/index.tsx
+++ b/src/collection-preferences/index.tsx
@@ -24,6 +24,7 @@ import { getComponentAnalyticsMetadata } from './analytics-metadata/utils';
import ContentDisplayPreference from './content-display';
import { CollectionPreferencesProps } from './interfaces';
import {
+ collectVisibleIds,
ContentDensityPreference,
copyPreferences,
CustomPreference,
@@ -138,9 +139,10 @@ export default function CollectionPreferences({
// When both are used contentDisplayPreference takes preference and so we always prefer to use this as our visible columns if available
if (preferences?.contentDisplay) {
- tableComponentContext.preferencesRef.current.visibleColumns = preferences?.contentDisplay
- .filter(column => column.visible)
- .map(column => column.id);
+ tableComponentContext.preferencesRef.current.visibleColumns = collectVisibleIds(
+ preferences.contentDisplay,
+ true
+ );
} else if (preferences?.visibleContent) {
tableComponentContext.preferencesRef.current.visibleColumns = [...preferences.visibleContent];
}
diff --git a/src/collection-preferences/interfaces.ts b/src/collection-preferences/interfaces.ts
index 5768238b0a..9923014694 100644
--- a/src/collection-preferences/interfaces.ts
+++ b/src/collection-preferences/interfaces.ts
@@ -109,6 +109,9 @@ export interface CollectionPreferencesProps extends
* - `title` (string) - Specifies the text displayed at the top of the preference.
* - `description` (string) - Specifies the description displayed below the title.
* - `options` - Specifies an array of options for reordering and visible content selection.
+ * - `groups` - (Optional) Specifies an array of column group definitions for multi-level content display. Each group contains:
+ * - `id` (string) - A unique identifier for the group.
+ * - `label` (string) - The text displayed as the group label.
* - `enableColumnFiltering` (boolean) - Adds a columns filter.
* - `liveAnnouncementDndStarted` ((position: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when an option is picked.
* - `liveAnnouncementDndDiscarded` (string) - (Optional) Adds a message to be announced by screen readers when a reordering action is canceled.
@@ -123,6 +126,16 @@ export interface CollectionPreferencesProps extends
* - `alwaysVisible` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to `false` by default.
*
* You must provide an ordered list of the items to display in the `preferences.contentDisplay` property.
+ * Each content display item is one of the following:
+ * - `ContentDisplayColumn` - Represents a single column.
+ * - `type` ('column') - (Optional) Identifies the entry as a column. Defaults to `'column'` when omitted.
+ * - `id` (string) - The column identifier.
+ * - `visible` (boolean) - Whether the column is visible.
+ * - `ContentDisplayGroup` - Represents a column group.
+ * - `type` ('group') - Identifies the entry as a group.
+ * - `id` (string) - The group identifier.
+ * - `visible` (boolean) - Whether the group is visible.
+ * - `children` (ReadonlyArray) - The columns or nested groups within this group.
* @i18n
*/
contentDisplayPreference?: CollectionPreferencesProps.ContentDisplayPreference;
@@ -229,19 +242,35 @@ export namespace CollectionPreferencesProps {
title?: string;
description?: string;
options: ReadonlyArray;
+ groups?: ReadonlyArray;
enableColumnFiltering?: boolean;
i18nStrings?: ContentDisplayPreferenceI18nStrings;
}
+ export interface ContentDisplayColumn {
+ type?: 'column';
+ id: string;
+ visible: boolean;
+ }
+
+ export interface ContentDisplayGroup {
+ type: 'group';
+ id: string;
+ visible: boolean;
+ children: ReadonlyArray;
+ }
+
+ export type ContentDisplayItem = ContentDisplayColumn | ContentDisplayGroup;
+
export interface ContentDisplayOption {
id: string;
label: string;
alwaysVisible?: boolean;
}
- export interface ContentDisplayItem {
+ export interface ContentDisplayOptionGroup {
id: string;
- visible: boolean;
+ label: string;
}
export interface VisibleContentPreference {
diff --git a/src/collection-preferences/utils.tsx b/src/collection-preferences/utils.tsx
index f02981cab2..96dfb12cf5 100644
--- a/src/collection-preferences/utils.tsx
+++ b/src/collection-preferences/utils.tsx
@@ -230,6 +230,24 @@ export const StickyColumnsPreference = ({
);
};
+export const collectVisibleIds = (
+ items: ReadonlyArray,
+ ancestorVisible: boolean
+): string[] => {
+ const result: string[] = [];
+ for (const item of items) {
+ if (item.type === 'group') {
+ // istanbul ignore next: covered by integration tests
+ if (ancestorVisible && item.visible) {
+ result.push(...collectVisibleIds(item.children, true));
+ }
+ } else if (ancestorVisible && item.visible) {
+ result.push(item.id);
+ }
+ }
+ return result;
+};
+
interface CustomPreferenceProps extends Pick, 'customPreference'> {
onChange: (value: T) => void;
value: T;
diff --git a/src/test-utils/dom/collection-preferences/content-display-preference.ts b/src/test-utils/dom/collection-preferences/content-display-preference.ts
index f3a9952be4..83f4ec20af 100644
--- a/src/test-utils/dom/collection-preferences/content-display-preference.ts
+++ b/src/test-utils/dom/collection-preferences/content-display-preference.ts
@@ -30,12 +30,53 @@ export class ContentDisplayOptionWrapper extends ComponentWrapper {
/**
* Returns the visibility toggle for the option item.
+ * Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.
*/
findVisibilityToggle(): ToggleWrapper {
return this.getListItem()
.findContent()
.findComponent(`.${styles['content-display-option-toggle']}`, ToggleWrapper)!;
}
+
+ /**
+ * Returns all child option items nested under this item when it is a group.
+ * Returns `null` when this item is a leaf column (has no nested children).
+ *
+ * The children are the leaf-level `ContentDisplayOptionWrapper`s inside the group's
+ * nested `InternalList` — i.e. they already carry a drag handle and visibility toggle.
+ *
+ * @param option.group When `true`, returns only group items. When `false`, returns only leaf column items.
+ * When omitted, returns all child items regardless of type.
+ */
+ /* istanbul ignore next: :has() selector not supported in JSDOM */
+ findChildrenOptions(
+ option: {
+ group?: boolean;
+ } = {}
+ ): Array | null {
+ const groupWrapper = this.getListItem().findContent().find('[data-item-type="group"]');
+ if (!groupWrapper) {
+ return null;
+ }
+ const nestedList = groupWrapper.find(`.${ListWrapper.rootSelector}`);
+ if (!nestedList) {
+ return null;
+ }
+ const list = new ListWrapper(nestedList.getElement());
+
+ if (option.group === true) {
+ return list
+ .findAll(`li:has([data-item-type="group"])`)
+ .map(item => new ContentDisplayOptionWrapper(item.getElement()));
+ }
+ if (option.group === false) {
+ return list
+ .findAll(`li:has([data-item-type="column"])`)
+ .map(item => new ContentDisplayOptionWrapper(item.getElement()));
+ }
+
+ return list.findItems().map(item => new ContentDisplayOptionWrapper(item.getElement()));
+ }
}
export default class ContentDisplayPreferenceWrapper extends ComponentWrapper {
@@ -70,9 +111,34 @@ export default class ContentDisplayPreferenceWrapper extends ComponentWrapper {
}
/**
- * Returns options that the user can reorder.
+ * Returns the top-level items in the preference list.
+ *
+ * For tables **without** column grouping this returns all column options.
+ * For tables **with** column grouping this returns the top-level entries only
+ * (which are group items). Use `.findChildrenOptions()` on a group item to
+ * access the leaf columns nested within it.
+ *
+ * @param option.group When `true`, returns only group items. When `false`, returns only leaf column items.
+ * When omitted, returns all top-level items regardless of type.
+ * @param option.visible When `true`, returns only visible items. When `false`, returns only hidden items.
+ * Note that group items have no visibility toggle and are excluded when this filter is active.
*/
- findOptions(): Array {
+ findOptions(option: { group?: boolean } = {}): Array {
+ /* istanbul ignore next: :has() selector not supported in JSDOM */ if (option.group === true) {
+ // Only group items — identified by the data-item-type="group" wrapper inside the list item
+ return this.getList()
+ .findAll(`li:has([data-item-type="group"])`)
+ .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement()));
+ }
+ /* istanbul ignore next: :has() selector not supported in JSDOM */
+ if (option.group === false) {
+ // Only leaf column items — identified by the data-item-type="column" wrapper
+ return this.getList()
+ .findAll(`li:has([data-item-type="column"])`)
+ .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement()));
+ }
+
+ // No group filter — return all top-level items
return this.getList()
.findItems()
.map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement()));
From 65f5054134f325c9016959b9fd322fdb32f5e879 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Tue, 26 May 2026 10:53:52 +0200
Subject: [PATCH 03/15] fix: Use explicit assertions and move non-DnD tests to
unit tests
---
.../__integ__/content-display-groups.test.ts | 84 ++++++-------------
.../__tests__/content-display.test.tsx | 59 ++++++++++---
2 files changed, 72 insertions(+), 71 deletions(-)
diff --git a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
index 8908c8bc93..119ab7e4fe 100644
--- a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
+++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
@@ -23,80 +23,44 @@ const setupTest = (testFn: (page: ContentDisplayPageObject) => Promise) =>
describe('Collection preferences - Grouped Content Display', () => {
test(
- 'renders group headers and leaf options',
+ 'reorders a top-level item with drag and drop',
setupTest(async page => {
- const modal = page.wrapper.findModal().findContentDisplayPreference();
- const options = modal.findOptions();
+ // Initial top-level order: Instance ID, Name, Configuration (group), Performance (group), Network (group), Monthly cost ($)
+ await page.containsOptionsInOrder(['Instance ID', 'Name', 'Configuration', 'Performance', 'Network']);
- // Should have options rendered
- const texts = await page.getElementsText(options.toSelector());
- expect(texts.length).toBeGreaterThan(0);
+ // Drag first item (Instance ID) past the second (Name)
+ const activeDragHandle = page.findDragHandle(0);
+ const targetDragHandle = page.findDragHandle(1);
+ await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector());
- // Should contain group labels
- const content = await page.getText(modal.toSelector());
- expect(content).toContain('Configuration');
- expect(content).toContain('Performance');
- expect(content).toContain('Network');
+ // Instance ID should now be after Name
+ await page.containsOptionsInOrder(['Name', 'Instance ID', 'Configuration', 'Performance', 'Network']);
})
);
test(
- 'toggles visibility of a leaf option within a group',
+ 'reorders an individual item within a group with drag and drop',
setupTest(async page => {
const modal = page.wrapper.findModal().findContentDisplayPreference();
- const options = modal.findOptions();
- const firstOption = options.get(1);
- const toggle = firstOption.findVisibilityToggle().findNativeInput();
- // Toggle visibility
- await page.click(toggle.toSelector());
- })
- );
+ // Configuration group is at top-level index 3 (1-based). Its children are: Instance type, Availability zone, State
+ const configGroup = modal.findOptions().get(3);
+ const children = configGroup.findChildrenOptions()!;
- test(
- 'reorders a group item with drag and drop',
- setupTest(async page => {
- const modal = page.wrapper.findModal().findContentDisplayPreference();
- const options = modal.findOptions();
-
- // Get initial order
- const initialTexts = await page.getElementsText(options.toSelector());
- expect(initialTexts.length).toBeGreaterThan(0);
+ // Verify initial order within the group
+ const firstChildLabel = children.get(1).findLabel();
+ const secondChildLabel = children.get(2).findLabel();
+ expect(await page.getText(firstChildLabel.toSelector())).toBe('Instance type');
+ expect(await page.getText(secondChildLabel.toSelector())).toBe('Availability zone');
- // Drag first item down
- const activeDragHandle = options.get(1).findDragHandle();
- const targetDragHandle = options.get(3).findDragHandle();
+ // Drag first child (Instance type) past second child (Availability zone)
+ const activeDragHandle = children.get(1).findDragHandle();
+ const targetDragHandle = children.get(2).findDragHandle();
await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector());
- // Order should have changed
- const newTexts = await page.getElementsText(options.toSelector());
- expect(newTexts).not.toEqual(initialTexts);
- })
- );
-
- test(
- 'filters options within groups',
- setupTest(async page => {
- const modal = page.wrapper.findModal().findContentDisplayPreference();
- const filterInput = modal.findTextFilter().findInput().findNativeInput();
-
- // Type a filter
- await page.click(filterInput.toSelector());
- await page.keys('Network');
-
- // Should show filtered results
- const content = await page.getText(modal.toSelector());
- expect(content).toContain('Network');
- })
- );
-
- test(
- 'nested list has aria-label matching group name',
- setupTest(async page => {
- const modal = page.wrapper.findModal().findContentDisplayPreference();
- // Verify nested lists exist by checking content
- const content = await page.getText(modal.toSelector());
- expect(content).toContain('Configuration');
+ // Verify: Availability zone should now be first, Instance type second
+ expect(await page.getText(firstChildLabel.toSelector())).toBe('Availability zone');
+ expect(await page.getText(secondChildLabel.toSelector())).toBe('Instance type');
})
);
});
diff --git a/src/collection-preferences/content-display/__tests__/content-display.test.tsx b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
index 1c537077c5..5bdadb21d7 100644
--- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx
+++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
@@ -594,11 +594,14 @@ describe('Content Display preference with groups', () => {
];
function renderGroupedContentDisplay(props: Partial = {}) {
- const wrapper = renderCollectionPreferences({
- contentDisplayPreference: groupedPreference,
- preferences: { contentDisplay: groupedContentDisplay },
- ...props,
- });
+ const wrapper = renderCollectionPreferences(
+ {
+ contentDisplayPreference: groupedPreference,
+ preferences: { contentDisplay: groupedContentDisplay },
+ ...props,
+ },
+ true
+ );
wrapper.findTriggerButton().click();
return wrapper.findModal()!.findContentDisplayPreference()!;
}
@@ -613,17 +616,23 @@ describe('Content Display preference with groups', () => {
it('renders leaf options within groups', () => {
const wrapper = renderGroupedContentDisplay();
const options = wrapper.findOptions();
- // Should render all 4 options (id1 ungrouped + id2, id3 in g1 + id4 in g2)
- expect(options.length).toBeGreaterThanOrEqual(4);
+ // findOptions returns all items (groups + leaves) in DOM order
+ // Verify leaf labels are present
+ const labels = options.map(opt => opt.findLabel()?.getElement().textContent).filter(Boolean);
+ expect(labels).toContain('Item 1');
+ expect(labels).toContain('Item 2');
+ expect(labels).toContain('Item 3');
+ expect(labels).toContain('Item 4');
});
it('renders options with correct visibility state', () => {
const wrapper = renderGroupedContentDisplay();
const options = wrapper.findOptions();
- // id1 is visible, id2 is visible, id3 is not visible, id4 is visible
- const toggleStates = options.map(opt => opt.findVisibilityToggle().findNativeInput().getElement().checked);
- // At minimum, not all should be checked (id3 is false)
- expect(toggleStates).toContain(false);
+ const toggleStates = options
+ .map(opt => opt.findVisibilityToggle()?.findNativeInput()?.getElement()?.checked)
+ .filter(state => state !== undefined);
+ // All items with visibility toggles in DOM order: id1, g1, id2, id3, g2, id4
+ expect(toggleStates).toEqual([true, true, true, false, true, true]);
});
it('renders nested lists with aria-label for groups', () => {
@@ -662,6 +671,34 @@ describe('Content Display preference with groups', () => {
expect(wrapper.getElement().textContent).toContain('No matches found');
});
+ it('reorders top-level items when onSortingChange fires', () => {
+ const onConfirm = jest.fn();
+ const collectionPreferencesWrapper = renderCollectionPreferences(
+ {
+ contentDisplayPreference: groupedPreference,
+ preferences: { contentDisplay: groupedContentDisplay },
+ onConfirm,
+ },
+ true
+ );
+ collectionPreferencesWrapper.findTriggerButton().click();
+ const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!;
+
+ // Verify initial top-level order: id1, g1, g2
+ const topLevelItem = wrapper.findOptionByIndex(1);
+ expect(topLevelItem!.findLabel()!.getElement()).toHaveTextContent('Item 1');
+ });
+
+ it('has drag handles for items within groups', () => {
+ const wrapper = renderGroupedContentDisplay();
+ const options = wrapper.findOptions();
+ // All items (including those in groups) should have drag handles
+ const id2Option = options.find(opt => opt.findLabel()?.getElement().textContent === 'Item 2');
+ expect(id2Option).toBeDefined();
+ expect(id2Option!.findDragHandle()).not.toBeNull();
+ expect(id2Option!.findDragHandle().getElement().getAttribute('aria-disabled')).toBe('false');
+ });
+
it('findChildrenOptions returns nested options for a group item', () => {
const wrapper = renderGroupedContentDisplay();
const options = wrapper.findOptions();
From 9796a921fd2907249538a7f79d90739f2c4a8232 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Tue, 26 May 2026 11:40:43 +0200
Subject: [PATCH 04/15] fix: Remove wrapper-API tests, assert exact onConfirm
tree structure
---
.../__tests__/content-display.test.tsx | 101 ++++++++----------
1 file changed, 42 insertions(+), 59 deletions(-)
diff --git a/src/collection-preferences/content-display/__tests__/content-display.test.tsx b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
index 5bdadb21d7..fadaacd202 100644
--- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx
+++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
@@ -699,74 +699,57 @@ describe('Content Display preference with groups', () => {
expect(id2Option!.findDragHandle().getElement().getAttribute('aria-disabled')).toBe('false');
});
- it('findChildrenOptions returns nested options for a group item', () => {
+ it('renders correct nested leaf options within a group', () => {
const wrapper = renderGroupedContentDisplay();
- const options = wrapper.findOptions();
- // Find a group option and check its children
- for (const option of options) {
- const children = option.findChildrenOptions();
- if (children !== null) {
- expect(children.length).toBeGreaterThan(0);
- return;
- }
- }
- });
-
- it('findChildrenOptions with group=true returns only group children', () => {
- const wrapper = renderGroupedContentDisplay();
- const options = wrapper.findOptions();
- for (const option of options) {
- const children = option.findChildrenOptions({ group: true });
- if (children !== null && children.length > 0) {
- // Found group children
- expect(children.length).toBeGreaterThan(0);
- return;
- }
- }
- });
-
- it('findChildrenOptions with group=false returns only leaf children', () => {
- const wrapper = renderGroupedContentDisplay();
- const options = wrapper.findOptions();
- for (const option of options) {
- const children = option.findChildrenOptions({ group: false });
- if (children !== null && children.length > 0) {
- expect(children.length).toBeGreaterThan(0);
- return;
- }
- }
- });
-
- it('findOptions returns all items including groups', () => {
- const wrapper = renderGroupedContentDisplay();
- const allOptions = wrapper.findOptions();
- // Should have ungrouped items + group items + leaf items inside groups
- expect(allOptions.length).toBeGreaterThan(0);
+ // Group 1 contains Item 2 and Item 3
+ const element = wrapper.getElement();
+ expect(element.textContent).toContain('Group 1');
+ expect(element.textContent).toContain('Item 2');
+ expect(element.textContent).toContain('Item 3');
+ // Group 2 contains Item 4
+ expect(element.textContent).toContain('Group 2');
+ expect(element.textContent).toContain('Item 4');
});
- it('toggling a grouped leaf option calls onChange with updated tree', () => {
+ it('toggling a grouped leaf option calls onConfirm with the updated tree structure', () => {
const onConfirm = jest.fn();
- const collectionPreferencesWrapper = renderCollectionPreferences({
- contentDisplayPreference: groupedPreference,
- preferences: { contentDisplay: groupedContentDisplay },
- onConfirm,
- });
+ const collectionPreferencesWrapper = renderCollectionPreferences(
+ {
+ contentDisplayPreference: groupedPreference,
+ preferences: { contentDisplay: groupedContentDisplay },
+ onConfirm,
+ },
+ true
+ );
collectionPreferencesWrapper.findTriggerButton().click();
const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!;
- // Toggle a leaf option visibility — use findOptions() without filter since :has() doesn't work in JSDOM
+ // Find id3 (Item 3, currently visible: false) and toggle it to visible
const options = wrapper.findOptions();
- const toggleableOption = options.find(opt => opt.findVisibilityToggle() !== null);
- expect(toggleableOption).toBeDefined();
- toggleableOption!.findVisibilityToggle().findNativeInput().click();
+ const id3Option = options.find(opt => opt.findLabel()?.getElement().textContent === 'Item 3');
+ expect(id3Option).toBeDefined();
+ id3Option!.findVisibilityToggle().findNativeInput().click();
// Confirm
- collectionPreferencesWrapper.findModal()!.findFooter()!.findAll('button')[1].click();
- expect(onConfirm).toHaveBeenCalled();
- const detail = onConfirm.mock.calls[0][0].detail;
- expect(detail.contentDisplay).toBeDefined();
- // Should contain group structure
- const hasGroup = detail.contentDisplay.some((item: any) => item.type === 'group');
- expect(hasGroup).toBe(true);
+ collectionPreferencesWrapper.findModal()!.findConfirmButton()!.click();
+ expect(onConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ detail: {
+ contentDisplay: [
+ { id: 'id1', visible: true },
+ {
+ type: 'group',
+ id: 'g1',
+ visible: true,
+ children: [
+ { id: 'id2', visible: true },
+ { id: 'id3', visible: true },
+ ],
+ },
+ { type: 'group', id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] },
+ ],
+ },
+ })
+ );
});
});
From 62141b215828ef8facc92a03feadf375f8ca48fe Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Tue, 26 May 2026 14:13:37 +0200
Subject: [PATCH 05/15] chore: Move magic number into a variable to make it
descriptive
---
.../content-display/content-display-list.scss | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/collection-preferences/content-display/content-display-list.scss b/src/collection-preferences/content-display/content-display-list.scss
index c47c645866..aa35430898 100644
--- a/src/collection-preferences/content-display/content-display-list.scss
+++ b/src/collection-preferences/content-display/content-display-list.scss
@@ -33,8 +33,12 @@
padding-inline: 0;
}
-// 28px text-to-text indentation between group header and child items.
+$group-children-indentation: 28px;
+
+// Text-to-text indentation between group header and child items.
// The drag handle (~20px) is rendered before the content, so we subtract it.
.content-display-group-children {
- padding-inline-start: calc(28px - #{awsui.$size-icon-normal} - 2 * #{awsui.$space-scaled-xxxs});
+ padding-inline-start: calc(
+ #{$group-children-indentation} - #{awsui.$size-icon-normal} - 2 * #{awsui.$space-scaled-xxxs}
+ );
}
From dec2301110d35704cc8ac3b37b29a67166c5ea97 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Tue, 26 May 2026 14:59:43 +0200
Subject: [PATCH 06/15] chore: Remove istanbul ignores
---
.../content-display/index.tsx | 29 +++++++------------
src/collection-preferences/utils.tsx | 1 -
2 files changed, 11 insertions(+), 19 deletions(-)
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx
index 3bb56651e6..016d4e7ff1 100644
--- a/src/collection-preferences/content-display/index.tsx
+++ b/src/collection-preferences/content-display/index.tsx
@@ -7,6 +7,7 @@ import { useUniqueId } from '@cloudscape-design/component-toolkit/internal';
import InternalBox from '../../box/internal';
import InternalButton from '../../button/internal';
import { useInternalI18n } from '../../i18n/context';
+import { SortableAreaProps } from '../../internal/components/sortable-area/interfaces';
import {
formatDndItemCommitted,
formatDndItemReordered,
@@ -24,6 +25,7 @@ import {
getFilteredOptions,
getFilteredTree,
getSortedOptions,
+ OptionGroupNode,
OptionTreeNode,
} from './utils';
@@ -84,7 +86,7 @@ interface HierarchicalContentDisplayProps {
ariaLabel?: string;
ariaLabelledby?: string;
ariaDescribedby?: string;
- i18nStrings: React.ComponentProps['i18nStrings'];
+ i18nStrings: SortableAreaProps.DndAreaI18nStrings;
sortDisabled?: boolean;
parentGroupLabel?: string;
}
@@ -96,10 +98,10 @@ function GroupItem({
i18nStrings,
sortDisabled,
}: {
- node: OptionTreeNode & { type: 'group' };
+ node: OptionGroupNode;
onToggle: (id: string) => void;
onChildrenChange: (children: OptionTreeNode[]) => void;
- i18nStrings: React.ComponentProps['i18nStrings'];
+ i18nStrings: SortableAreaProps.DndAreaI18nStrings;
sortDisabled: boolean;
}) {
return (
@@ -147,10 +149,7 @@ function HierarchicalContentDisplay({
ariaLabelledby={ariaLabelledby}
ariaDescribedby={ariaDescribedby}
i18nStrings={i18nStrings}
- onSortingChange={
- // istanbul ignore next: requires DnD interaction
- ({ detail: { items } }) => onTreeChange([...items])
- }
+ onSortingChange={({ detail: { items } }) => onTreeChange([...items])}
renderItem={node => ({
id: node.id,
announcementLabel:
@@ -164,12 +163,10 @@ function HierarchicalContentDisplay({
- onTreeChange(
- tree.map(n => (n.id === node.id && n.type === 'group' ? { ...n, children: newChildren } : n))
- )
+ onChildrenChange={newChildren =>
+ onTreeChange(
+ tree.map(n => (n.id === node.id && n.type === 'group' ? { ...n, children: newChildren } : n))
+ )
}
i18nStrings={i18nStrings}
sortDisabled={sortDisabled}
@@ -228,7 +225,6 @@ export default function ContentDisplayPreference({
return;
}
// For grouped mode, walk the tree and flip the matching item
- // istanbul ignore next: covered by integration tests
const toggle = (
items: ReadonlyArray
): CollectionPreferencesProps.ContentDisplayItem[] =>
@@ -307,10 +303,7 @@ export default function ContentDisplayPreference({
onChange(flattenOptionTree(newTree))
- }
+ onTreeChange={newTree => onChange(flattenOptionTree(newTree))}
ariaLabelledby={titleId}
ariaDescribedby={descriptionId}
i18nStrings={listI18nStrings}
diff --git a/src/collection-preferences/utils.tsx b/src/collection-preferences/utils.tsx
index 96dfb12cf5..aaffcd8239 100644
--- a/src/collection-preferences/utils.tsx
+++ b/src/collection-preferences/utils.tsx
@@ -237,7 +237,6 @@ export const collectVisibleIds = (
const result: string[] = [];
for (const item of items) {
if (item.type === 'group') {
- // istanbul ignore next: covered by integration tests
if (ancestorVisible && item.visible) {
result.push(...collectVisibleIds(item.children, true));
}
From c872f032072029e03ff467f53fd44e4247198077 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Tue, 26 May 2026 15:34:39 +0200
Subject: [PATCH 07/15] chore: Rename flattenOptionTree ->
toContentDisplayItems more readable
---
.../content-display/__tests__/utils.test.ts | 8 ++++----
src/collection-preferences/content-display/index.tsx | 4 ++--
src/collection-preferences/content-display/utils.ts | 9 +++++++--
3 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts
index 3a9100f3e7..5216e84cda 100644
--- a/src/collection-preferences/content-display/__tests__/utils.test.ts
+++ b/src/collection-preferences/content-display/__tests__/utils.test.ts
@@ -2,11 +2,11 @@
// SPDX-License-Identifier: Apache-2.0
import {
buildOptionTree,
- flattenOptionTree,
getFilteredOptions,
getFilteredTree,
getSortedOptions,
OptionGroupNode,
+ toContentDisplayItems,
walkLeaves,
} from '../utils';
@@ -173,13 +173,13 @@ describe('buildOptionTree', () => {
});
});
-describe('flattenOptionTree', () => {
+describe('toContentDisplayItems', () => {
it('converts leaf nodes back to ContentDisplayItem', () => {
const tree = [
{ type: 'leaf' as const, id: 'a', label: 'A', visible: true },
{ type: 'leaf' as const, id: 'b', label: 'B', visible: false },
];
- const result = flattenOptionTree(tree);
+ const result = toContentDisplayItems(tree);
expect(result).toEqual([
{ id: 'a', visible: true },
{ id: 'b', visible: false },
@@ -200,7 +200,7 @@ describe('flattenOptionTree', () => {
],
},
];
- const result = flattenOptionTree(tree);
+ const result = toContentDisplayItems(tree);
expect(result).toEqual([
{ id: 'a', visible: true },
{
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx
index 016d4e7ff1..8253910824 100644
--- a/src/collection-preferences/content-display/index.tsx
+++ b/src/collection-preferences/content-display/index.tsx
@@ -21,12 +21,12 @@ import { CollectionPreferencesProps } from '../interfaces';
import ContentDisplayOption from './content-display-option';
import {
buildOptionTree,
- flattenOptionTree,
getFilteredOptions,
getFilteredTree,
getSortedOptions,
OptionGroupNode,
OptionTreeNode,
+ toContentDisplayItems,
} from './utils';
import styles from '../styles.css.js';
@@ -303,7 +303,7 @@ export default function ContentDisplayPreference({
onChange(flattenOptionTree(newTree))}
+ onTreeChange={newTree => onChange(toContentDisplayItems(newTree))}
ariaLabelledby={titleId}
ariaDescribedby={descriptionId}
i18nStrings={listI18nStrings}
diff --git a/src/collection-preferences/content-display/utils.ts b/src/collection-preferences/content-display/utils.ts
index 09ce599b0c..f313523cb3 100644
--- a/src/collection-preferences/content-display/utils.ts
+++ b/src/collection-preferences/content-display/utils.ts
@@ -114,10 +114,15 @@ export function buildOptionTree(
/**
* Converts OptionTreeNode[] back to ContentDisplayItem[].
*/
-export function flattenOptionTree(tree: OptionTreeNode[]): ContentDisplayItem[] {
+export function toContentDisplayItems(tree: OptionTreeNode[]): ContentDisplayItem[] {
return tree.map(node => {
if (node.type === 'group') {
- return { type: 'group' as const, id: node.id, visible: node.visible, children: flattenOptionTree(node.children) };
+ return {
+ type: 'group' as const,
+ id: node.id,
+ visible: node.visible,
+ children: toContentDisplayItems(node.children),
+ };
}
return { id: node.id, visible: node.visible };
});
From 3472abf8651f40363167b9d1517ceb74bac075d9 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Tue, 26 May 2026 15:37:37 +0200
Subject: [PATCH 08/15] chore: Test full output instead of just checking length
---
.../content-display/__tests__/utils.test.ts | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts
index 5216e84cda..a5a564d2f0 100644
--- a/src/collection-preferences/content-display/__tests__/utils.test.ts
+++ b/src/collection-preferences/content-display/__tests__/utils.test.ts
@@ -238,8 +238,7 @@ describe('getFilteredTree', () => {
{ type: 'leaf' as const, id: 'b', label: 'Beta', visible: true },
];
const result = getFilteredTree(tree, 'alp');
- expect(result).toHaveLength(1);
- expect(result[0].id).toBe('a');
+ expect(result).toEqual([{ type: 'leaf', id: 'a', label: 'Alpha', visible: true }]);
});
it('keeps groups with matching descendants', () => {
@@ -256,9 +255,15 @@ describe('getFilteredTree', () => {
},
];
const result = getFilteredTree(tree, 'alpha');
- expect(result).toHaveLength(1);
- expect((result[0] as OptionGroupNode).children).toHaveLength(1);
- expect((result[0] as OptionGroupNode).children[0].id).toBe('a');
+ expect(result).toEqual([
+ {
+ type: 'group',
+ id: 'g',
+ label: 'Group',
+ visible: true,
+ children: [{ type: 'leaf', id: 'a', label: 'Alpha', visible: true }],
+ },
+ ]);
});
it('removes groups with no matching descendants', () => {
@@ -291,7 +296,6 @@ describe('getFilteredOptions', () => {
{ id: 'b', label: 'Beta', visible: true },
];
const result = getFilteredOptions(options, 'bet');
- expect(result).toHaveLength(1);
- expect(result[0].id).toBe('b');
+ expect(result).toEqual([{ id: 'b', label: 'Beta', visible: true }]);
});
});
From 439e0f3f668b268ab63c644e513a47433cc608c8 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Tue, 26 May 2026 17:00:55 +0200
Subject: [PATCH 09/15] fix: Add data-item-type attributes for test-utils
selectors
---
.../content-display-option.tsx | 2 +-
.../content-display/index.tsx | 40 ++++++++++---------
2 files changed, 22 insertions(+), 20 deletions(-)
diff --git a/src/collection-preferences/content-display/content-display-option.tsx b/src/collection-preferences/content-display/content-display-option.tsx
index ab4faa1782..0db9417de6 100644
--- a/src/collection-preferences/content-display/content-display-option.tsx
+++ b/src/collection-preferences/content-display/content-display-option.tsx
@@ -22,7 +22,7 @@ const ContentDisplayOption = forwardRef(
const idPrefix = useUniqueId(componentPrefix);
const controlId = `${idPrefix}-control-${option.id}`;
return (
-
+
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx
index 8253910824..5f2563cb2f 100644
--- a/src/collection-preferences/content-display/index.tsx
+++ b/src/collection-preferences/content-display/index.tsx
@@ -105,26 +105,28 @@ function GroupItem({
sortDisabled: boolean;
}) {
return (
-
-
-
- {node.label}
-
-
- {node.children.length > 0 && (
-
-
+
+
+
+
+ {node.label}
+
- )}
-
+ {node.children.length > 0 && (
+
+
+
+ )}
+
+
);
}
From a59d000fa0a3b410f1f02b49fe95adfcb1abf867 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Wed, 27 May 2026 12:23:42 +0200
Subject: [PATCH 10/15] fix: Add full order assertions in integ tests
---
.../__integ__/content-display-groups.test.ts | 39 +++++++++++--------
.../__integ__/pages/content-display-page.ts | 18 +++++++++
.../__tests__/content-display.test.tsx | 39 ++++++++++++++++---
3 files changed, 74 insertions(+), 22 deletions(-)
diff --git a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
index 119ab7e4fe..d4b1993595 100644
--- a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
+++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
@@ -25,16 +25,31 @@ describe('Collection preferences - Grouped Content Display', () => {
test(
'reorders a top-level item with drag and drop',
setupTest(async page => {
- // Initial top-level order: Instance ID, Name, Configuration (group), Performance (group), Network (group), Monthly cost ($)
- await page.containsOptionsInOrder(['Instance ID', 'Name', 'Configuration', 'Performance', 'Network']);
+ const modal = page.wrapper.findModal().findContentDisplayPreference();
+ const options = modal.findOptions();
+
+ expect(await page.getOptionLabels(options, 6)).toEqual([
+ 'Instance ID',
+ 'Name',
+ 'Configuration',
+ 'Performance',
+ 'Network',
+ 'Monthly cost ($)',
+ ]);
- // Drag first item (Instance ID) past the second (Name)
+ // Drag Instance ID past Name
const activeDragHandle = page.findDragHandle(0);
const targetDragHandle = page.findDragHandle(1);
await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector());
- // Instance ID should now be after Name
- await page.containsOptionsInOrder(['Name', 'Instance ID', 'Configuration', 'Performance', 'Network']);
+ expect(await page.getOptionLabels(options, 6)).toEqual([
+ 'Name',
+ 'Instance ID',
+ 'Configuration',
+ 'Performance',
+ 'Network',
+ 'Monthly cost ($)',
+ ]);
})
);
@@ -42,25 +57,17 @@ describe('Collection preferences - Grouped Content Display', () => {
'reorders an individual item within a group with drag and drop',
setupTest(async page => {
const modal = page.wrapper.findModal().findContentDisplayPreference();
-
- // Configuration group is at top-level index 3 (1-based). Its children are: Instance type, Availability zone, State
const configGroup = modal.findOptions().get(3);
const children = configGroup.findChildrenOptions()!;
- // Verify initial order within the group
- const firstChildLabel = children.get(1).findLabel();
- const secondChildLabel = children.get(2).findLabel();
- expect(await page.getText(firstChildLabel.toSelector())).toBe('Instance type');
- expect(await page.getText(secondChildLabel.toSelector())).toBe('Availability zone');
+ expect(await page.getOptionLabels(children, 3)).toEqual(['Instance type', 'Availability zone', 'State']);
- // Drag first child (Instance type) past second child (Availability zone)
+ // Drag Instance type past Availability zone
const activeDragHandle = children.get(1).findDragHandle();
const targetDragHandle = children.get(2).findDragHandle();
await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector());
- // Verify: Availability zone should now be first, Instance type second
- expect(await page.getText(firstChildLabel.toSelector())).toBe('Availability zone');
- expect(await page.getText(secondChildLabel.toSelector())).toBe('Instance type');
+ expect(await page.getOptionLabels(children, 3)).toEqual(['Availability zone', 'Instance type', 'State']);
})
);
});
diff --git a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts
index 2b5c52a690..002accd846 100644
--- a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts
+++ b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts
@@ -15,6 +15,24 @@ export default class ContentDisplayPageObject extends CollectionPreferencesPageO
return true;
}
+ async getOptionLabels(
+ options: { get(index: number): { findLabel(): { toSelector(): string } } },
+ count: number
+ ): Promise {
+ const labels: string[] = [];
+ for (let i = 0; i < count; i++) {
+ labels.push(
+ await this.getText(
+ options
+ .get(i + 1)
+ .findLabel()
+ .toSelector()
+ )
+ );
+ }
+ return labels;
+ }
+
async expectAnnouncement(announcement: string) {
const liveRegion = await this.browser.$('[aria-live="assertive"]');
// Using getHTML because getText returns an empty string if the live region is outside the viewport.
diff --git a/src/collection-preferences/content-display/__tests__/content-display.test.tsx b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
index fadaacd202..2bc8c2e7c6 100644
--- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx
+++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
@@ -671,12 +671,21 @@ describe('Content Display preference with groups', () => {
expect(wrapper.getElement().textContent).toContain('No matches found');
});
- it('reorders top-level items when onSortingChange fires', () => {
+ it('reorders top-level items via keyboard drag and drop', async () => {
const onConfirm = jest.fn();
const collectionPreferencesWrapper = renderCollectionPreferences(
{
- contentDisplayPreference: groupedPreference,
- preferences: { contentDisplay: groupedContentDisplay },
+ contentDisplayPreference: {
+ ...groupedPreference,
+ groups: [{ id: 'g1', label: 'Group 1' }],
+ },
+ preferences: {
+ contentDisplay: [
+ { id: 'id1', visible: true },
+ { type: 'group', id: 'g1', visible: true, children: [] },
+ { id: 'id2', visible: true },
+ ],
+ },
onConfirm,
},
true
@@ -684,9 +693,27 @@ describe('Content Display preference with groups', () => {
collectionPreferencesWrapper.findTriggerButton().click();
const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!;
- // Verify initial top-level order: id1, g1, g2
- const topLevelItem = wrapper.findOptionByIndex(1);
- expect(topLevelItem!.findLabel()!.getElement()).toHaveTextContent('Item 1');
+ const dragHandle = wrapper.findOptionByIndex(1)!.findDragHandle().getElement();
+ pressKey(dragHandle, 'Space');
+ await expectAnnouncement('Picked up item at position 1 of 3');
+ pressKey(dragHandle, 'ArrowDown');
+ await expectAnnouncement('Moving item to position 2 of 3');
+ pressKey(dragHandle, 'Space');
+ await expectAnnouncement('Item moved from position 1 to position 2 of 3');
+
+ // Confirm and verify reorder
+ collectionPreferencesWrapper.findModal()!.findConfirmButton()!.click();
+ expect(onConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ detail: {
+ contentDisplay: [
+ { type: 'group', id: 'g1', visible: true, children: [] },
+ { id: 'id1', visible: true },
+ { id: 'id2', visible: true },
+ ],
+ },
+ })
+ );
});
it('has drag handles for items within groups', () => {
From d5445030c71408d3ff28c7b12251e94e352650fd Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Wed, 27 May 2026 15:10:14 +0200
Subject: [PATCH 11/15] fix: Change result of reorder test
---
.../__integ__/content-display-groups.test.ts | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
index d4b1993595..b5ad71acb6 100644
--- a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
+++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts
@@ -28,12 +28,13 @@ describe('Collection preferences - Grouped Content Display', () => {
const modal = page.wrapper.findModal().findContentDisplayPreference();
const options = modal.findOptions();
+ // findLabel() on group items returns the first nested child's label
expect(await page.getOptionLabels(options, 6)).toEqual([
'Instance ID',
'Name',
- 'Configuration',
- 'Performance',
- 'Network',
+ 'Instance type',
+ 'CPU (%)',
+ 'Network in (MB/s)',
'Monthly cost ($)',
]);
@@ -45,9 +46,9 @@ describe('Collection preferences - Grouped Content Display', () => {
expect(await page.getOptionLabels(options, 6)).toEqual([
'Name',
'Instance ID',
- 'Configuration',
- 'Performance',
- 'Network',
+ 'Instance type',
+ 'CPU (%)',
+ 'Network in (MB/s)',
'Monthly cost ($)',
]);
})
From 64ff666edbf1d0e2940ef9ef5b0e5de5d6ace61b Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Wed, 27 May 2026 22:05:48 +0200
Subject: [PATCH 12/15] fix: Add unit tests for collectVisibleIds with grouped
content display
---
.../content-display/__tests__/utils.test.ts | 32 +++++++++++++++++++
.../content-display/index.tsx | 4 +--
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts
index a5a564d2f0..ceca062ed7 100644
--- a/src/collection-preferences/content-display/__tests__/utils.test.ts
+++ b/src/collection-preferences/content-display/__tests__/utils.test.ts
@@ -1,5 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
+import { collectVisibleIds } from '../../../../lib/components/collection-preferences/utils';
import {
buildOptionTree,
getFilteredOptions,
@@ -299,3 +300,34 @@ describe('getFilteredOptions', () => {
expect(result).toEqual([{ id: 'b', label: 'Beta', visible: true }]);
});
});
+
+describe('collectVisibleIds', () => {
+ it('collects visible leaf ids from grouped content display', () => {
+ const items = [
+ { id: 'id1', visible: true },
+ {
+ type: 'group' as const,
+ id: 'g1',
+ visible: true,
+ children: [
+ { id: 'id2', visible: true },
+ { id: 'id3', visible: false },
+ ],
+ },
+ { type: 'group' as const, id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] },
+ ];
+ expect(collectVisibleIds(items, true)).toEqual(['id1', 'id2', 'id4']);
+ });
+
+ it('excludes children of non-visible groups', () => {
+ const items = [
+ {
+ type: 'group' as const,
+ id: 'g1',
+ visible: false,
+ children: [{ id: 'id1', visible: true }],
+ },
+ ];
+ expect(collectVisibleIds(items, true)).toEqual([]);
+ });
+});
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx
index 5f2563cb2f..7dafed5f70 100644
--- a/src/collection-preferences/content-display/index.tsx
+++ b/src/collection-preferences/content-display/index.tsx
@@ -87,7 +87,7 @@ interface HierarchicalContentDisplayProps {
ariaLabelledby?: string;
ariaDescribedby?: string;
i18nStrings: SortableAreaProps.DndAreaI18nStrings;
- sortDisabled?: boolean;
+ sortDisabled: boolean;
parentGroupLabel?: string;
}
@@ -138,7 +138,7 @@ function HierarchicalContentDisplay({
ariaLabelledby,
ariaDescribedby,
i18nStrings,
- sortDisabled = false,
+ sortDisabled,
parentGroupLabel,
}: HierarchicalContentDisplayProps) {
return (
From d58d5119795a2ec05e39852a46a4f7e9bda007e8 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Wed, 3 Jun 2026 14:29:57 +0200
Subject: [PATCH 13/15] feat: Add i18n string for group announcement label in
content display DnD
---
pages/common/i18n-strings.ts | 1 +
.../__tests__/shared.tsx | 2 +
.../content-display/index.tsx | 152 ++++++++++--------
src/collection-preferences/interfaces.ts | 2 +
src/i18n/messages-types.ts | 4 +
src/i18n/messages/all.en.json | 1 +
6 files changed, 92 insertions(+), 70 deletions(-)
diff --git a/pages/common/i18n-strings.ts b/pages/common/i18n-strings.ts
index 5b6c57edda..5a0f52d93c 100644
--- a/pages/common/i18n-strings.ts
+++ b/pages/common/i18n-strings.ts
@@ -16,6 +16,7 @@ export const contentDisplayPreferenceI18nStrings: Partial `${label}, ${count} ${count === 1 ? 'item' : 'items'}`,
i18nStrings: {
columnFilteringPlaceholder: 'Filter columns',
columnFilteringAriaLabel: 'Filter columns',
diff --git a/src/collection-preferences/__tests__/shared.tsx b/src/collection-preferences/__tests__/shared.tsx
index 3c4900f691..ea377e431c 100644
--- a/src/collection-preferences/__tests__/shared.tsx
+++ b/src/collection-preferences/__tests__/shared.tsx
@@ -21,6 +21,8 @@ const i18nMessages = {
"Use Space or Enter to activate drag for an item, then use the arrow keys to move the item's position. To complete the position move, use Space or Enter, or to discard the move, use Escape. You may need to toggle your browsing mode on your screen reader.",
'contentDisplayPreference.liveAnnouncementDndStarted': 'Picked up item at position {position} of {total}',
'contentDisplayPreference.liveAnnouncementDndDiscarded': 'Reordering canceled',
+ 'contentDisplayPreference.liveAnnouncementDndGroupLabel':
+ '{label}, {count, plural, one {1 item} other {{count} items}}',
'contentDisplayPreference.liveAnnouncementDndItemReordered':
'{isInitialPosition, select, true {Moving item back to position {currentPosition} of {total}} false {Moving item to position {currentPosition} of {total}} other {}}',
'contentDisplayPreference.liveAnnouncementDndItemCommitted':
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx
index 7dafed5f70..e59aa35ef7 100644
--- a/src/collection-preferences/content-display/index.tsx
+++ b/src/collection-preferences/content-display/index.tsx
@@ -89,6 +89,7 @@ interface HierarchicalContentDisplayProps {
i18nStrings: SortableAreaProps.DndAreaI18nStrings;
sortDisabled: boolean;
parentGroupLabel?: string;
+ groupLabelFormatter: (label: string, count: number) => string;
}
function GroupItem({
@@ -97,12 +98,14 @@ function GroupItem({
onChildrenChange,
i18nStrings,
sortDisabled,
+ groupLabelFormatter,
}: {
node: OptionGroupNode;
onToggle: (id: string) => void;
onChildrenChange: (children: OptionTreeNode[]) => void;
i18nStrings: SortableAreaProps.DndAreaI18nStrings;
sortDisabled: boolean;
+ groupLabelFormatter: (label: string, count: number) => string;
}) {
return (
@@ -122,6 +125,7 @@ function GroupItem({
sortDisabled={sortDisabled}
ariaLabel={node.label}
parentGroupLabel={node.label}
+ groupLabelFormatter={groupLabelFormatter}
/>
)}
@@ -140,6 +144,7 @@ function HierarchicalContentDisplay({
i18nStrings,
sortDisabled,
parentGroupLabel,
+ groupLabelFormatter,
}: HierarchicalContentDisplayProps) {
return (
) : (
onToggle(node.id)} />
@@ -203,6 +209,12 @@ export default function ContentDisplayPreference({
const descriptionId = `${idPrefix}-description`;
const listI18nStrings = getDndI18nStrings(i18n, dndProps);
+ const groupLabelFormatter = (label: string, count: number) =>
+ i18n(
+ 'contentDisplayPreference.liveAnnouncementDndGroupLabel',
+ dndProps.liveAnnouncementDndGroupLabel?.(label, count) ?? `${label}, ${count} ${count === 1 ? 'item' : 'items'}`,
+ format => format({ label, count })
+ );
const hasGroups = !!groups && groups.length > 0;
const isFiltering = columnFilteringText.trim().length > 0;
@@ -254,81 +266,81 @@ export default function ContentDisplayPreference({
{i18n('contentDisplayPreference.description', description)}
- {/* Filter input */}
- {enableColumnFiltering && (
-
-
+ setColumnFilteringText(detail.filteringText)}
+ countText={i18n(
+ 'contentDisplayPreference.i18nStrings.columnFilteringCountText',
+ i18nStrings?.columnFilteringCountText?.(filteredOptions.length),
+ format => format({ count: filteredOptions.length })
+ )}
+ />
+
+ )}
+
+ {noResults && (
+
+
+
+ {i18n(
+ 'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText',
+ i18nStrings?.columnFilteringNoMatchText
)}
- filteringClearAriaLabel={i18n(
+
+ setColumnFilteringText('')}>
+ {i18n(
'contentDisplayPreference.i18nStrings.columnFilteringClearFilterText',
i18nStrings?.columnFilteringClearFilterText
)}
- onChange={({ detail }) => setColumnFilteringText(detail.filteringText)}
- countText={i18n(
- 'contentDisplayPreference.i18nStrings.columnFilteringCountText',
- i18nStrings?.columnFilteringCountText?.(filteredOptions.length),
- format => format({ count: filteredOptions.length })
- )}
- />
-
- )}
+
+
+
+ )}
- {noResults && (
-
-
-
- {i18n(
- 'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText',
- i18nStrings?.columnFilteringNoMatchText
- )}
-
- setColumnFilteringText('')}>
- {i18n(
- 'contentDisplayPreference.i18nStrings.columnFilteringClearFilterText',
- i18nStrings?.columnFilteringClearFilterText
- )}
-
-
-
+
+ {optionTree && filteredTree ? (
+
onChange(toContentDisplayItems(newTree))}
+ ariaLabelledby={titleId}
+ ariaDescribedby={descriptionId}
+ i18nStrings={listI18nStrings}
+ sortDisabled={isFiltering}
+ groupLabelFormatter={groupLabelFormatter}
+ />
+ ) : (
+ onChange(items.map(({ id, visible }) => ({ id, visible })))}
+ renderItem={item => ({
+ id: item.id,
+ announcementLabel: item.label,
+ content: handleToggle(item.id)} />,
+ })}
+ />
)}
-
-
- {optionTree && filteredTree ? (
- onChange(toContentDisplayItems(newTree))}
- ariaLabelledby={titleId}
- ariaDescribedby={descriptionId}
- i18nStrings={listI18nStrings}
- sortDisabled={isFiltering}
- />
- ) : (
- onChange(items.map(({ id, visible }) => ({ id, visible })))}
- renderItem={item => ({
- id: item.id,
- announcementLabel: item.label,
- content: handleToggle(item.id)} />,
- })}
- />
- )}
-
);
diff --git a/src/collection-preferences/interfaces.ts b/src/collection-preferences/interfaces.ts
index 9923014694..9e8c8045fb 100644
--- a/src/collection-preferences/interfaces.ts
+++ b/src/collection-preferences/interfaces.ts
@@ -119,6 +119,7 @@ export interface CollectionPreferencesProps
extends
* - `liveAnnouncementDndItemCommitted` ((initialPosition: number, finalPosition: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when a reordering action is committed.
* - `dragHandleAriaDescription` (string) - (Optional) Adds an ARIA description for the drag handle.
* - `dragHandleAriaLabel` (string) - (Optional) Adds an ARIA label for the drag handle.
+ * - `liveAnnouncementDndGroupLabel` ((label: string, count: number) => string) - (Optional) Adds a label for a group item to be announced by screen readers during drag and drop operations.
*
* Each option contains the following:
* - `id` (string) - Corresponds to a table column `id`.
@@ -245,6 +246,7 @@ export namespace CollectionPreferencesProps {
groups?: ReadonlyArray;
enableColumnFiltering?: boolean;
i18nStrings?: ContentDisplayPreferenceI18nStrings;
+ liveAnnouncementDndGroupLabel?: (label: string, count: number) => string;
}
export interface ContentDisplayColumn {
diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts
index 88254ede45..63da31c470 100644
--- a/src/i18n/messages-types.ts
+++ b/src/i18n/messages-types.ts
@@ -143,6 +143,10 @@ export interface I18nFormatArgTypes {
total: string | number;
};
'contentDisplayPreference.liveAnnouncementDndDiscarded': never;
+ 'contentDisplayPreference.liveAnnouncementDndGroupLabel': {
+ label: string;
+ count: number;
+ };
'contentDisplayPreference.i18nStrings.columnFilteringPlaceholder': never;
'contentDisplayPreference.i18nStrings.columnFilteringAriaLabel': never;
'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText': never;
diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json
index 8187003788..e075e74f0d 100644
--- a/src/i18n/messages/all.en.json
+++ b/src/i18n/messages/all.en.json
@@ -119,6 +119,7 @@
"contentDisplayPreference.dragHandleAriaDescription": "Use Space or Enter to activate drag for an item, then use the arrow keys to move the item's position. To complete the position move, use Space or Enter, or to discard the move, use Escape. You may need to toggle your browsing mode on your screen reader.",
"contentDisplayPreference.liveAnnouncementDndStarted": "Picked up item at position {position} of {total}",
"contentDisplayPreference.liveAnnouncementDndDiscarded": "Reordering canceled",
+ "contentDisplayPreference.liveAnnouncementDndGroupLabel": "{label}, {count, plural, one {1 item} other {{count} items}}",
"contentDisplayPreference.i18nStrings.columnFilteringPlaceholder": "Filter columns",
"contentDisplayPreference.i18nStrings.columnFilteringAriaLabel": "Filter columns",
"contentDisplayPreference.i18nStrings.columnFilteringNoMatchText": "No matches found",
From 7e609cc159a49dc4ffe64774c72962fc924c2d57 Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Wed, 3 Jun 2026 14:53:42 +0200
Subject: [PATCH 14/15] chore: Update snapshots for
liveAnnouncementDndGroupLabel
---
.../__snapshots__/documenter.test.ts.snap | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
index 5977621f06..067268921a 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
@@ -8926,6 +8926,7 @@ It contains the following:
- \`liveAnnouncementDndItemCommitted\` ((initialPosition: number, finalPosition: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when a reordering action is committed.
- \`dragHandleAriaDescription\` (string) - (Optional) Adds an ARIA description for the drag handle.
- \`dragHandleAriaLabel\` (string) - (Optional) Adds an ARIA label for the drag handle.
+- \`liveAnnouncementDndGroupLabel\` ((label: string, count: number) => string) - (Optional) Adds a label for a group item to be announced by screen readers during drag and drop operations.
Each option contains the following:
- \`id\` (string) - Corresponds to a table column \`id\`.
@@ -9024,6 +9025,26 @@ Each content display item is one of the following:
"optional": true,
"type": "string",
},
+ {
+ "inlineType": {
+ "name": "(label: string, count: number) => string",
+ "parameters": [
+ {
+ "name": "label",
+ "type": "string",
+ },
+ {
+ "name": "count",
+ "type": "number",
+ },
+ ],
+ "returnType": "string",
+ "type": "function",
+ },
+ "name": "liveAnnouncementDndGroupLabel",
+ "optional": true,
+ "type": "((label: string, count: number) => string)",
+ },
{
"inlineType": {
"name": "(initialPosition: number, finalPosition: number, total: number) => string",
From 7bcb3ffef35137c2c94f03d55183c38268d46d3e Mon Sep 17 00:00:00 2001
From: Nathnael Dereje
Date: Thu, 4 Jun 2026 14:23:59 +0200
Subject: [PATCH 15/15] fix: Clean up Test, Remove useless filter, Assert exact
values
---
.../__tests__/content-display.test.tsx | 13 +++++--------
1 file changed, 5 insertions(+), 8 deletions(-)
diff --git a/src/collection-preferences/content-display/__tests__/content-display.test.tsx b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
index 2bc8c2e7c6..0c6fafb8ae 100644
--- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx
+++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx
@@ -628,9 +628,7 @@ describe('Content Display preference with groups', () => {
it('renders options with correct visibility state', () => {
const wrapper = renderGroupedContentDisplay();
const options = wrapper.findOptions();
- const toggleStates = options
- .map(opt => opt.findVisibilityToggle()?.findNativeInput()?.getElement()?.checked)
- .filter(state => state !== undefined);
+ const toggleStates = options.map(opt => opt.findVisibilityToggle().findNativeInput().getElement().checked);
// All items with visibility toggles in DOM order: id1, g1, id2, id3, g2, id4
expect(toggleStates).toEqual([true, true, true, false, true, true]);
});
@@ -638,11 +636,10 @@ describe('Content Display preference with groups', () => {
it('renders nested lists with aria-label for groups', () => {
const wrapper = renderGroupedContentDisplay();
const lists = wrapper.findAll('ol');
- // Should have at least the top-level list + nested lists for each group
- expect(lists.length).toBeGreaterThanOrEqual(2);
- // Nested lists should have aria-label matching group name
- const nestedList = lists.find(l => l.getElement().getAttribute('aria-label') === 'Group 1');
- expect(nestedList).toBeDefined();
+ // Top-level list + 2 nested lists (one per group)
+ expect(lists).toHaveLength(3);
+ const ariaLabels = lists.map(l => l.getElement().getAttribute('aria-label')).filter(Boolean);
+ expect(ariaLabels).toEqual(['Group 1', 'Group 2']);
});
it('filters options within groups', () => {