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/pages/common/i18n-strings.ts b/pages/common/i18n-strings.ts index 0df7313c49..5b6c57edda 100644 --- a/pages/common/i18n-strings.ts +++ b/pages/common/i18n-strings.ts @@ -14,7 +14,7 @@ export const contentDisplayPreferenceI18nStrings: Partial 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. @@ -8932,7 +8935,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", @@ -8957,6 +8970,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", @@ -37434,9 +37452,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", @@ -37475,6 +37507,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", @@ -37504,7 +37563,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": { @@ -48326,9 +48386,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", @@ -48362,6 +48436,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", @@ -48381,7 +48482,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 9eb07a2a12..6b673cf078 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/__tests__/shared.tsx b/src/collection-preferences/__tests__/shared.tsx index f877fc9cc1..3c4900f691 100644 --- a/src/collection-preferences/__tests__/shared.tsx +++ b/src/collection-preferences/__tests__/shared.tsx @@ -18,7 +18,7 @@ const i18nMessages = { '{count, select, zero {0 matches} one {1 match} other {{count} matches}}', 'contentDisplayPreference.dragHandleAriaLabel': 'Drag handle', '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.", + "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.liveAnnouncementDndItemReordered': 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 4f163dd064..0d9010e863 100644 --- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx +++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx @@ -559,3 +559,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 173522ee6d..b599467098 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,118 +203,130 @@ 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 ( -
-

- {i18n('contentDisplayPreference.title', title)} -

-

- {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 }) - )} - /> -
- )} +
+
+

+ {i18n('contentDisplayPreference.title', title)} +

+

+ {i18n('contentDisplayPreference.description', description)} +

- {/* 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?.(filteredOptions.length), + format => format({ count: filteredOptions.length }) + )} + /> +
+ )} - ({ - 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 - ), - }} - /> + {noResults && ( +
+ + + {i18n( + 'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText', + i18nStrings?.columnFilteringNoMatchText + )} + + setColumnFilteringText('')}> + {i18n( + 'contentDisplayPreference.i18nStrings.columnFilteringClearFilterText', + i18nStrings?.columnFilteringClearFilterText + )} + + +
+ )} + {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/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index 8987f49208..8187003788 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -116,7 +116,7 @@ "contentDisplayPreference.title": "Column preferences", "contentDisplayPreference.description": "Customize the visibility and order of the columns.", "contentDisplayPreference.dragHandleAriaLabel": "Drag handle", - "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.", + "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.i18nStrings.columnFilteringPlaceholder": "Filter columns", diff --git a/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx b/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx index 13d915ed12..05f15cbe83 100644 --- a/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx +++ b/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx @@ -53,10 +53,10 @@ test('assigns aria-labelledby attribute', () => { expect(document.querySelector(`.${styles.handle}`)).toHaveAccessibleName('custom label'); }); -test('has role="application" if no ariaValue is provided', () => { +test('has role="button" if no ariaValue is provided', () => { render(); - expect(screen.getByRole('application')).toHaveAccessibleName('drag handle'); + expect(screen.getByRole('button')).toHaveAccessibleName('drag handle'); }); test('has role="slider" and aria-value attributes when ariaValue is set', () => { diff --git a/src/internal/components/drag-handle/button.tsx b/src/internal/components/drag-handle/button.tsx index b048b6887d..03da884862 100644 --- a/src/internal/components/drag-handle/button.tsx +++ b/src/internal/components/drag-handle/button.tsx @@ -58,7 +58,7 @@ const DragHandleButton = forwardRef( // when it is being dragged.
{ isDragGhost: false, isSortingActive: false, dragHandleProps: { + active: false, ariaLabel: `Drag handle ${items[i].label}`, ariaDescribedby: expect.any(String), disabled: false, diff --git a/src/internal/components/sortable-area/index.tsx b/src/internal/components/sortable-area/index.tsx index b2eb82df74..77dc592d4a 100644 --- a/src/internal/components/sortable-area/index.tsx +++ b/src/internal/components/sortable-area/index.tsx @@ -177,6 +177,7 @@ function DraggableItem({ isDragGhost: false, dragHandleProps: { ...dragHandleListeners, + active: isDragging, ariaLabel: joinStrings(dragHandleAriaLabel, itemDefinition.label(item)) ?? '', ariaDescribedby: attributes['aria-describedby'], disabled: attributes['aria-disabled'], diff --git a/src/internal/components/sortable-area/use-live-announcements.ts b/src/internal/components/sortable-area/use-live-announcements.ts index df95b79a18..b440826afc 100644 --- a/src/internal/components/sortable-area/use-live-announcements.ts +++ b/src/internal/components/sortable-area/use-live-announcements.ts @@ -50,7 +50,10 @@ export default function useLiveAnnouncements({ onDragStart({ active }: DragStartEvent) { if (active && liveAnnouncementDndStarted) { const index = items.findIndex(item => itemDefinition.id(item) === active.id); - return liveAnnouncementDndStarted(index + 1, items.length); + const item = items.find(item => itemDefinition.id(item) === active.id); + const label = item ? itemDefinition.label(item) : ''; + const announcement = liveAnnouncementDndStarted(index + 1, items.length); + return label ? `${label}. ${announcement}` : announcement; } }, onDragOver({ active, over }: DragOverEvent) { @@ -71,7 +74,10 @@ export default function useLiveAnnouncements({ if (liveAnnouncementDndItemCommitted) { const initialIndex = items.findIndex(item => itemDefinition.id(item) === active.id); const finalIndex = over ? items.findIndex(item => itemDefinition.id(item) === over.id) : initialIndex; - return liveAnnouncementDndItemCommitted(initialIndex + 1, finalIndex + 1, items.length); + const item = items.find(item => itemDefinition.id(item) === active.id); + const label = item ? itemDefinition.label(item) : ''; + const announcement = liveAnnouncementDndItemCommitted(initialIndex + 1, finalIndex + 1, items.length); + return label ? `${label}. ${announcement}` : announcement; } }, onDragCancel() { 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()));