diff --git a/pages/table/expandable-rows-filter-highlight.page.tsx b/pages/table/expandable-rows-filter-highlight.page.tsx new file mode 100644 index 0000000000..36b01c2a26 --- /dev/null +++ b/pages/table/expandable-rows-filter-highlight.page.tsx @@ -0,0 +1,148 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import { Box, Checkbox, Header, Input, SpaceBetween, Table, TableProps } from '~components'; +import I18nProvider from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; + +interface Node { + id: string; + name: string; + type: string; + size: string; + children?: Node[]; +} + +// A small static hierarchy so the page does not depend on collection-hooks tree filtering. +const data: Node[] = [ + { + id: 'eu', + name: 'eu-west-1', + type: 'region', + size: '—', + children: [ + { + id: 'eu-prod', + name: 'production', + type: 'cluster', + size: '—', + children: [ + { id: 'eu-prod-db1', name: 'orders-db', type: 'instance', size: 'r5.large' }, + { id: 'eu-prod-db2', name: 'payments-db', type: 'instance', size: 'r5.xlarge' }, + ], + }, + { + id: 'eu-staging', + name: 'staging', + type: 'cluster', + size: '—', + children: [{ id: 'eu-staging-db1', name: 'orders-db-staging', type: 'instance', size: 't3.medium' }], + }, + ], + }, + { + id: 'us', + name: 'us-east-1', + type: 'region', + size: '—', + children: [ + { + id: 'us-prod', + name: 'production', + type: 'cluster', + size: '—', + children: [ + { id: 'us-prod-db1', name: 'analytics-db', type: 'instance', size: 'r5.2xlarge' }, + { id: 'us-prod-db2', name: 'orders-db', type: 'instance', size: 'r5.large' }, + ], + }, + ], + }, +]; + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'name', header: 'Name', cell: item => item.name, isRowHeader: true }, + { id: 'type', header: 'Type', cell: item => item.type }, + { id: 'size', header: 'Size', cell: item => item.size }, +]; + +const getItemChildren = (item: Node) => item.children ?? []; + +function subtreeHasMatch(item: Node, isMatched: (item: Node) => boolean): boolean { + return isMatched(item) || getItemChildren(item).some(child => subtreeHasMatch(child, isMatched)); +} + +export default function ExpandableFilterHighlightPage() { + const [filteringText, setFilteringText] = useState(''); + const [expandedItems, setExpandedItems] = useState>([]); + const [highlightMatched, setHighlightMatched] = useState(true); + + const isItemMatched = useMemo(() => { + const query = filteringText.trim().toLowerCase(); + return (item: Node) => query.length > 0 && item.name.toLowerCase().includes(query); + }, [filteringText]); + + // Keep parent rows of matching descendants visible: only surface roots whose subtree contains a match. + const items = useMemo(() => { + if (filteringText.trim().length === 0) { + return data; + } + return data.filter(root => subtreeHasMatch(root, isItemMatched)); + }, [filteringText, isItemMatched]); + + return ( + + + +
+ Expandable table filter highlight +
+ + +
+ setFilteringText(e.detail.value)} + placeholder="Filter by name (e.g. orders-db)" + ariaLabel="Filter databases" + type="search" + /> +
+ setHighlightMatched(e.detail.checked)}> + Highlight & auto-expand matches + +
+ + Databases} + ariaLabels={{ + tableLabel: 'Databases', + expandButtonLabel: () => 'Expand row', + collapseButtonLabel: () => 'Collapse row', + }} + expandableRows={{ + getItemChildren, + isItemExpandable: item => getItemChildren(item).length > 0, + expandedItems, + onExpandableItemToggle: ({ detail }) => + setExpandedItems(prev => + detail.expanded ? [...prev, detail.item] : prev.filter(i => i.id !== detail.item.id) + ), + highlightMatched, + isItemMatched, + }} + empty={No matches} + /> + + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..76702a427a 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28587,7 +28587,12 @@ The value is passed as \`itemsCount\` property to the \`ariaLabels.allItemsSelec * \`getSelectedItemsCount\` (optional, (Item) => number) - Use it to indicate the number of selected resources nested under the given item. The value is passed as \`selectedItemsCount\` property to the \`columnDefinitions[index].counter\` and \`ariaLabels.itemSelectionLabel\` functions. * \`totalSelectedItemsCount\` (optional, number) - Use it to indicate the total number of selected resources in the table. -The value is passed as \`selectedItemsCount\` property to the \`ariaLabels.allItemsSelectionLabel\`.", +The value is passed as \`selectedItemsCount\` property to the \`ariaLabels.allItemsSelectionLabel\`. +* \`highlightMatched\` (optional, boolean) - When set to \`true\` (together with \`isItemMatched\`), the table keeps ancestor +rows of matching descendants visible, automatically expands those ancestors so the matches are reachable, and visually +highlights the matched rows. This is opt-in and backward compatible; when omitted the expandable rows behave as before. +* \`isItemMatched\` (optional, (Item) => boolean) - A predicate returning \`true\` for items that match the currently applied +filter. Used together with \`highlightMatched\` to determine which ancestors to auto-expand and which rows to highlight.", "inlineType": { "name": "TableProps.ExpandableRows", "properties": [ @@ -28665,6 +28670,11 @@ The value is passed as \`selectedItemsCount\` property to the \`ariaLabels.allIt "optional": true, "type": "TableProps.GroupSelectionState", }, + { + "name": "highlightMatched", + "optional": true, + "type": "boolean", + }, { "inlineType": { "name": "(item: T) => boolean", @@ -28681,6 +28691,22 @@ The value is passed as \`selectedItemsCount\` property to the \`ariaLabels.allIt "optional": false, "type": "(item: T) => boolean", }, + { + "inlineType": { + "name": "(item: T) => boolean", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "boolean", + "type": "function", + }, + "name": "isItemMatched", + "optional": true, + "type": "((item: T) => boolean)", + }, { "inlineType": { "name": "TableProps.OnExpandableItemToggle", @@ -44334,6 +44360,21 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from ], }, }, + { + "description": "Returns rows highlighted as matching the current filter. Only relevant for expandable tables +using \`expandableRows.highlightMatched\`.", + "name": "findMatchedRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, { "name": "findPagination", "parameters": [], @@ -53754,6 +53795,21 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from "name": "ElementWrapper", }, }, + { + "description": "Returns rows highlighted as matching the current filter. Only relevant for expandable tables +using \`expandableRows.highlightMatched\`.", + "name": "findMatchedRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, { "name": "findPagination", "parameters": [], 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 4b1af42bc5..12e19948d3 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 @@ -659,6 +659,7 @@ exports[`test-utils selectors 1`] = ` "awsui_resizer_x7peu", "awsui_root_1s55x", "awsui_root_wih1l", + "awsui_row-match-highlight_wih1l", "awsui_row-selected_wih1l", "awsui_row_wih1l", "awsui_table_wih1l", diff --git a/src/table/__tests__/expandable-rows-filter-highlight.test.tsx b/src/table/__tests__/expandable-rows-filter-highlight.test.tsx new file mode 100644 index 0000000000..188fc60e3d --- /dev/null +++ b/src/table/__tests__/expandable-rows-filter-highlight.test.tsx @@ -0,0 +1,191 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +interface Instance { + name: string; + children?: Instance[]; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [{ header: 'name', cell: item => item.name }]; + +const nestedItems: Instance[] = [ + { + name: 'Root-1', + children: [ + { name: 'Nested-1.1', children: [] }, + { name: 'Nested-1.2' }, + { name: 'Nested-1.3', children: [{ name: 'Nested-1.3.1' }, { name: 'Nested-1.3.2' }] }, + ], + }, + { + name: 'Root-2', + children: [{ name: 'Nested-2.1' }, { name: 'Nested-2.2' }], + }, +]; + +function renderTable(props: Partial> & { items: readonly Instance[] }) { + const mergedProps = { columnDefinitions, ...props } as TableProps; + const { container } = render(
); + return createWrapper(container).findTable()!; +} + +const baseExpandableRows = { + isItemExpandable: (item: Instance) => !!item.children && item.children.length > 0, + getItemChildren: (item: Instance) => item.children ?? [], + expandedItems: [] as Instance[], + onExpandableItemToggle: () => {}, +}; + +function rowTexts(table: ReturnType) { + return table.findRows().map(r => r.find('td')!.getElement().textContent); +} + +describe('Expandable rows filter highlight', () => { + test('auto-expands ancestors of a matching descendant so the match becomes reachable', () => { + const table = renderTable({ + items: nestedItems, + trackBy: item => item.name, + expandableRows: { + ...baseExpandableRows, + highlightMatched: true, + isItemMatched: item => item.name === 'Nested-1.3.2', + }, + }); + + // Root-1 and Nested-1.3 are ancestors of the match and are auto-expanded; Root-2 stays collapsed. + expect(rowTexts(table)).toEqual([ + 'Root-1', + 'Nested-1.1', + 'Nested-1.2', + 'Nested-1.3', + 'Nested-1.3.1', + 'Nested-1.3.2', + 'Root-2', + ]); + }); + + test('highlights only the matched rows', () => { + const table = renderTable({ + items: nestedItems, + trackBy: item => item.name, + expandableRows: { + ...baseExpandableRows, + highlightMatched: true, + isItemMatched: item => item.name === 'Nested-1.3.2', + }, + }); + + const matched = table.findMatchedRows(); + expect(matched).toHaveLength(1); + expect(matched[0].find('td')!.getElement().textContent).toBe('Nested-1.3.2'); + }); + + test('highlights every matching row at multiple levels', () => { + const table = renderTable({ + items: nestedItems, + trackBy: item => item.name, + expandableRows: { + ...baseExpandableRows, + highlightMatched: true, + // Match a parent and a deeply nested child in different subtrees. + isItemMatched: item => item.name === 'Nested-1.3' || item.name === 'Nested-2.2', + }, + }); + + expect(table.findMatchedRows().map(r => r.find('td')!.getElement().textContent)).toEqual([ + 'Nested-1.3', + 'Nested-2.2', + ]); + // Root-2 is auto-expanded because Nested-2.2 matches. + expect(rowTexts(table)).toContain('Nested-2.2'); + }); + + test('auto-expanded ancestors report aria-expanded="true"', () => { + const table = renderTable({ + items: nestedItems, + trackBy: item => item.name, + expandableRows: { + ...baseExpandableRows, + highlightMatched: true, + isItemMatched: item => item.name === 'Nested-1.3.2', + }, + }); + + // Row 1 = Root-1 (auto-expanded), row 4 = Nested-1.3 (auto-expanded). + expect(table.findExpandToggle(1)!.getElement()).toHaveAttribute('aria-expanded', 'true'); + expect(table.findExpandToggle(4)!.getElement()).toHaveAttribute('aria-expanded', 'true'); + }); + + test('toggling an auto-expanded ancestor fires onExpandableItemToggle with expanded=false', () => { + const onExpandableItemToggle = jest.fn(); + const table = renderTable({ + items: nestedItems, + trackBy: item => item.name, + expandableRows: { + ...baseExpandableRows, + onExpandableItemToggle, + highlightMatched: true, + isItemMatched: item => item.name === 'Nested-1.3.2', + }, + }); + + table.findExpandToggle(1)!.click(); + expect(onExpandableItemToggle).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ expanded: false }) }) + ); + }); + + test('is backward compatible: without highlightMatched no auto-expansion or highlighting occurs', () => { + const table = renderTable({ + items: nestedItems, + trackBy: item => item.name, + expandableRows: { + ...baseExpandableRows, + // isItemMatched provided but highlightMatched not enabled -> no effect. + isItemMatched: item => item.name === 'Nested-1.3.2', + }, + }); + + expect(rowTexts(table)).toEqual(['Root-1', 'Root-2']); + expect(table.findMatchedRows()).toHaveLength(0); + }); + + test('does not auto-expand subtrees without matches', () => { + const table = renderTable({ + items: nestedItems, + trackBy: item => item.name, + expandableRows: { + ...baseExpandableRows, + highlightMatched: true, + isItemMatched: item => item.name === 'Nested-2.1', + }, + }); + + // Only Root-2's subtree is expanded; Root-1 stays collapsed. + expect(rowTexts(table)).toEqual(['Root-1', 'Root-2', 'Nested-2.1', 'Nested-2.2']); + expect(table.findMatchedRows().map(r => r.find('td')!.getElement().textContent)).toEqual(['Nested-2.1']); + }); + + test('manually expanded items remain expanded alongside auto-expanded ancestors', () => { + const table = renderTable({ + items: nestedItems, + trackBy: item => item.name, + expandableRows: { + ...baseExpandableRows, + expandedItems: [nestedItems[1]], // Root-2 expanded manually + highlightMatched: true, + isItemMatched: item => item.name === 'Nested-1.3.2', + }, + }); + + const texts = rowTexts(table); + // Root-2 stays expanded (manual) and Root-1 subtree auto-expands to reveal the match. + expect(texts).toContain('Nested-2.1'); + expect(texts).toContain('Nested-1.3.2'); + }); +}); diff --git a/src/table/body-cell/styles.scss b/src/table/body-cell/styles.scss index a25f7f3f55..98791bd1dc 100644 --- a/src/table/body-cell/styles.scss +++ b/src/table/body-cell/styles.scss @@ -221,6 +221,12 @@ $cell-negative-space-vertical: 2px; &-shaded { background: awsui.$color-background-cell-shaded; } + // Highlight for rows that match the currently applied filter in expandable tables. + // Applied only when `expandableRows.highlightMatched` is enabled. Kept as a background-only + // treatment (no border changes) to avoid row height shifts. Selection takes precedence. + &-match-highlight:not(.body-cell-selected) { + background: awsui.$color-background-status-info; + } &.has-striped-rows:not(.body-cell-selected):not(.body-cell-last-row) { border-block-end-color: awsui.$color-border-cell-shaded; } diff --git a/src/table/body-cell/td-element.tsx b/src/table/body-cell/td-element.tsx index 49c45e45ef..6c63af9325 100644 --- a/src/table/body-cell/td-element.tsx +++ b/src/table/body-cell/td-element.tsx @@ -36,6 +36,7 @@ export interface TableTdElementProps { onBlur?: () => void; children?: React.ReactNode; isEvenRow?: boolean; + isMatched?: boolean; stripedRows?: boolean; isSelection?: boolean; hasSelection?: boolean; @@ -77,6 +78,7 @@ export const TableTdElement = React.forwardRef extends ExpandableItemDetail { isExpandable: boolean; isExpanded: boolean; + isMatched: boolean; onExpandableItemToggle: () => void; expandButtonLabel?: string; collapseButtonLabel?: string; @@ -33,6 +34,9 @@ export interface InternalExpandableRowsProps { onGroupSelectionChange?: TableProps.OnGroupSelectionChange; totalItemsCount?: number; totalSelectedItemsCount?: number; + // Number of items (at any nesting level) that satisfy `expandableRows.isItemMatched`. + // Only meaningful when `highlightMatched` is enabled; otherwise 0. + matchedItemsCount: number; } export function useExpandableTableProps({ @@ -51,6 +55,46 @@ export function useExpandableTableProps({ const expandedSet = new ItemSet(trackBy, expandableRows?.expandedItems ?? []); + // Filter highlighting (opt-in, backward compatible): when `highlightMatched` is enabled and an + // `isItemMatched` predicate is provided, we keep ancestor rows of matching descendants visible, + // auto-expand those ancestors so the matches are reachable, and flag matched rows for highlighting. + const highlightMatched = isExpandable && !!expandableRows?.highlightMatched && !!expandableRows?.isItemMatched; + const matchedSet = new ItemSet(trackBy, []); + const autoExpandedSet = new ItemSet(trackBy, []); + let matchedItemsCount = 0; + + if (highlightMatched) { + const isItemMatched = expandableRows!.isItemMatched!; + // Traverse the full tree (regardless of the current expansion state) to discover matched items + // and every ancestor on the path to a match. Ancestors are collected into `autoExpandedSet` so + // that the visible traversal below reveals the matches. + const collectMatches = (item: T): boolean => { + const children = expandableRows!.getItemChildren(item); + let subtreeHasMatch = false; + if (isItemMatched(item)) { + matchedSet.put(item); + matchedItemsCount++; + subtreeHasMatch = true; + } + let hasMatchingDescendant = false; + for (const child of children) { + if (collectMatches(child)) { + hasMatchingDescendant = true; + } + } + if (hasMatchingDescendant) { + autoExpandedSet.put(item); + subtreeHasMatch = true; + } + return subtreeHasMatch; + }; + items.forEach(item => collectMatches(item)); + } + + // An item is "effectively expanded" when the consumer expanded it, or when it is auto-expanded to + // reveal a matching descendant during filtering. + const isEffectivelyExpanded = (item: T) => expandedSet.has(item) || autoExpandedSet.has(item); + let allItems = items; const itemToDetail = new Map>(); const getItemLevel = (item: T) => itemToDetail.get(item)?.level ?? 0; @@ -68,7 +112,7 @@ export function useExpandableTableProps({ traverse( child, { level: detail.level + 1, setSize: children.length, posInSet: index + 1, parent: item }, - expandedSet.has(item) + isEffectivelyExpanded(item) ) ); } @@ -79,7 +123,7 @@ export function useExpandableTableProps({ for (let index = 0; index < visibleItems.length; index++) { const item = visibleItems[index]; - if (expandedSet.has(item)) { + if (isEffectivelyExpanded(item)) { let insertionIndex = index + 1; for (insertionIndex; insertionIndex < visibleItems.length; insertionIndex++) { const insertionItem = visibleItems[insertionIndex]; @@ -96,14 +140,16 @@ export function useExpandableTableProps({ const getExpandableItemProps = (item: T): ExpandableItemProps => { const { level = 1, setSize = 1, posInSet = 1, parent = null, children = [] } = itemToDetail.get(item) ?? {}; + const isExpanded = isEffectivelyExpanded(item); return { level, setSize, posInSet, isExpandable: expandableRows?.isItemExpandable(item) ?? true, - isExpanded: expandedSet.has(item), + isExpanded, + isMatched: matchedSet.has(item), onExpandableItemToggle: () => - fireNonCancelableEvent(expandableRows?.onExpandableItemToggle, { item, expanded: !expandedSet.has(item) }), + fireNonCancelableEvent(expandableRows?.onExpandableItemToggle, { item, expanded: !isExpanded }), expandButtonLabel: i18n('ariaLabels.expandButtonLabel', ariaLabels?.expandButtonLabel?.(item)), collapseButtonLabel: i18n('ariaLabels.collapseButtonLabel', ariaLabels?.collapseButtonLabel?.(item)), parent, @@ -122,5 +168,6 @@ export function useExpandableTableProps({ onGroupSelectionChange: expandableRows?.onGroupSelectionChange, totalItemsCount: expandableRows?.totalItemsCount, totalSelectedItemsCount: expandableRows?.totalSelectedItemsCount, + matchedItemsCount, }; } diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index e00ef8fc17..7dac666884 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -426,6 +426,11 @@ export interface TableProps extends BaseComponentProps { * The value is passed as `selectedItemsCount` property to the `columnDefinitions[index].counter` and `ariaLabels.itemSelectionLabel` functions. * * `totalSelectedItemsCount` (optional, number) - Use it to indicate the total number of selected resources in the table. * The value is passed as `selectedItemsCount` property to the `ariaLabels.allItemsSelectionLabel`. + * * `highlightMatched` (optional, boolean) - When set to `true` (together with `isItemMatched`), the table keeps ancestor + * rows of matching descendants visible, automatically expands those ancestors so the matches are reachable, and visually + * highlights the matched rows. This is opt-in and backward compatible; when omitted the expandable rows behave as before. + * * `isItemMatched` (optional, (Item) => boolean) - A predicate returning `true` for items that match the currently applied + * filter. Used together with `highlightMatched` to determine which ancestors to auto-expand and which rows to highlight. */ expandableRows?: TableProps.ExpandableRows; @@ -668,6 +673,8 @@ export namespace TableProps { totalItemsCount?: number; getSelectedItemsCount?: (item: T) => number; totalSelectedItemsCount?: number; + highlightMatched?: boolean; + isItemMatched?: (item: T) => boolean; } export type OnExpandableItemToggle = NonCancelableEventHandler>; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index d58a59f1f9..4d52f36761 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -662,6 +662,7 @@ const InternalTable = React.forwardRef( isPrevSelected: hasSelection && !isFirstRow && isRowSelected(allRows[rowIndex - 1]), isNextSelected: hasSelection && !isLastDataRow && isRowSelected(allRows[rowIndex + 1]), isEvenRow: rowIndex % 2 === 0, + isMatched: !!rowExpandableProps?.isMatched, stripedRows, hasSelection, hasFooter, @@ -673,7 +674,11 @@ const InternalTable = React.forwardRef( return ( { // When an element inside table row receives focus we want to adjust the scroll. // However, that behavior is unwanted when the focus is received as result of a click diff --git a/src/table/styles.scss b/src/table/styles.scss index 2585b019ab..d6f0f1df23 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -229,7 +229,8 @@ filter search icon. .thead-active, .row, -.row-selected { +.row-selected, +.row-match-highlight { /* used in test-utils */ } diff --git a/src/test-utils/dom/table/index.ts b/src/test-utils/dom/table/index.ts index b074bb30d4..d1c899e3d7 100644 --- a/src/test-utils/dom/table/index.ts +++ b/src/test-utils/dom/table/index.ts @@ -109,6 +109,14 @@ export default class TableWrapper extends ComponentWrapper { return this.findAllByClassName(styles['row-selected']); } + /** + * Returns rows highlighted as matching the current filter. Only relevant for expandable tables + * using `expandableRows.highlightMatched`. + */ + findMatchedRows(): Array { + return this.findAllByClassName(styles['row-match-highlight']); + } + /** * Alias for findEmptySlot method for compatibility with previous versions * @deprecated