From ada397405d891a29a2b8b0f66f2e46ce1f423ae9 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 20 Aug 2026 20:43:18 +0200 Subject: [PATCH 1/8] feat(tables): add tree support --- .changeset/tables-grow-branches.md | 5 + .size-limit.cjs | 12 +- .../data/DataTable/DataTable.docs.mdx | 45 +++ .../data/DataTable/DataTable.stories.tsx | 48 +++ .../data/DataTable/DataTable.tree.test.tsx | 174 +++++++++ src/components/data/DataTable/DataTable.tsx | 202 +++++++++- src/components/data/DataTable/types.ts | 4 +- .../data/ItemTable/ItemTable.browser.test.tsx | 74 ++++ .../data/ItemTable/ItemTable.docs.mdx | 52 +++ .../data/ItemTable/ItemTable.stories.tsx | 52 +++ .../data/ItemTable/ItemTable.tree.test.tsx | 298 +++++++++++++++ src/components/data/ItemTable/ItemTable.tsx | 273 ++++++++++++-- src/components/data/ItemTable/types.ts | 9 +- src/components/data/TableBase/TableRow.tsx | 82 +++- src/components/data/TableBase/TableView.tsx | 354 ++++++++++++++---- src/components/data/TableBase/index.ts | 3 + src/components/data/TableBase/styled.ts | 60 +++ .../data/TableBase/table-tree.test.ts | 116 ++++++ src/components/data/TableBase/table-tree.ts | 209 +++++++++++ src/components/data/TableBase/types.ts | 37 ++ .../data/TableBase/use-table-search.ts | 6 +- .../data/TableBase/use-table-selection.ts | 233 ++++++++++-- .../data/TableBase/use-table-tree-state.tsx | 196 ++++++++++ src/components/data/index.ts | 3 + src/eslint-plugin/defaults.generated.ts | 1 + src/i18n/locales/de-DE/uikit.json | 1 + src/i18n/locales/en-US/uikit.json | 1 + src/i18n/locales/es-ES/uikit.json | 1 + src/i18n/locales/es-MX/uikit.json | 1 + src/i18n/locales/fr-FR/uikit.json | 1 + src/i18n/locales/it-IT/uikit.json | 1 + src/i18n/locales/ja-JP/uikit.json | 1 + src/i18n/locales/nb-NO/uikit.json | 1 + src/i18n/locales/pt-BR/uikit.json | 1 + src/i18n/locales/pt-PT/uikit.json | 1 + src/i18n/locales/sv-SE/uikit.json | 1 + src/i18n/locales/vi-VN/uikit.json | 1 + src/stories/Usage.docs.mdx | 18 + 38 files changed, 2422 insertions(+), 156 deletions(-) create mode 100644 .changeset/tables-grow-branches.md create mode 100644 src/components/data/DataTable/DataTable.tree.test.tsx create mode 100644 src/components/data/ItemTable/ItemTable.tree.test.tsx create mode 100644 src/components/data/TableBase/table-tree.test.ts create mode 100644 src/components/data/TableBase/table-tree.ts create mode 100644 src/components/data/TableBase/use-table-tree-state.tsx diff --git a/.changeset/tables-grow-branches.md b/.changeset/tables-grow-branches.md new file mode 100644 index 000000000..7b797c1c5 --- /dev/null +++ b/.changeset/tables-grow-branches.md @@ -0,0 +1,5 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Add accessible nested tree rows, expansion state, hierarchy-aware operations, and cascading ItemTable selection to ItemTable and DataTable. diff --git a/.size-limit.cjs b/.size-limit.cjs index c5fea7c52..4135f4d8c 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -20,7 +20,15 @@ module.exports = [ }), ); }, - // 505 kB, raised from 501 kB, which the palette branch exceeded by 596 B + // 510 kB, raised from 505 kB for ItemTable/DataTable tree rows. The shared + // immutable hierarchy model, recursive operations, React Aria treegrid + // wiring, cascading selection and disclosure renderer add 2.97 kB locally + // (507.97 kB total). The Button-only budget below is unchanged, confirming + // that consumers which do not import the tables tree-shake the feature. + // Rounded to the next 5 kB step for the macOS/Linux zlib difference noted + // below. + // + // Previously 505 kB, raised from 501 kB, which the palette branch exceeded by 596 B // locally. Measured with both sides rebuilt on the same machine: `main` at // 500.29 kB, that branch at 501.60 kB, so the palette work itself is // +1.31 kB — the color-seeded accent arrangement, `color-seed.ts`, and @@ -110,7 +118,7 @@ module.exports = [ // // Note when checking locally: `size-limit` bundles the built `./dist`, it // does not build. Run `pnpm build` first or you will measure a stale bundle. - limit: '505kB', + limit: '510kB', }, { name: 'Tree shaking (just a Button)', diff --git a/src/components/data/DataTable/DataTable.docs.mdx b/src/components/data/DataTable/DataTable.docs.mdx index 892c06ea7..4b09fddcf 100644 --- a/src/components/data/DataTable/DataTable.docs.mdx +++ b/src/components/data/DataTable/DataTable.docs.mdx @@ -52,6 +52,15 @@ and everything it knows about the query, in Cloud. - **`columns`** `CubeDataTableColumn[]` — Same shape as `ItemTable`'s, plus `dataType`. - **`rowKey`** `string` (default: `'id'`) — Property to read the row identity from. - **`getRowKey`** `(row: T, index: number) => Key` — Wins over `rowKey`. +- **`getRowChildren`** `(row: T) => readonly T[] | undefined` — Enables nested + treegrid rows; `data` contains roots. +- **`treeColumnKey`** `string` — Indented disclosure column. Defaults to the + first visible data column. +- **`expandedKeys`** `Key[]` — Controlled expansion. +- **`defaultExpandedKeys`** `Key[]` — Initial expansion. Nothing is expanded by + default. +- **`onExpand`** `(keys, info) => void` — Reports the resulting keys and toggled + row metadata. - **`pinnedTopRows`** `readonly T[]` — Rows stuck to the top of the scroller. Ordinary rows as far as the columns are concerned. - **`pinnedBottomRows`** `readonly T[]` — The same, at the bottom. This is where totals go. @@ -144,6 +153,9 @@ see [Column Colors](#column-colors) for the last two. - **`getRowProps`** `(ctx: CubeTableRowContext) => Record` — Per-row mods, styles and height. - **`storageKey`** `string` — Persists page size, column widths and column order. - **`ariaLabel`** `string` +- **`isChecked`** `boolean` — Base checked-state modifier. +- **`place`** `string` — Tasty placement shorthand on the root frame. +- **`scrollMargin`** `string | number` — Scroll margin on the root frame. #### `dataType` @@ -212,6 +224,39 @@ body into a scroller, pins the header, and lets virtualization engage at all. Cell-level, in addition to `ItemTable`'s: `cell-selected`, `last-column`, `pinned`. +## Tree Rows + +Tree mode uses the same nested-data contract as `ItemTable`: + +```jsx + row.children} + treeColumnKey="region" + defaultExpandedKeys={['americas']} +/> +``` + +Multi-sort is recursive: every sibling collection is sorted by the same +precedence. Client pagination slices roots and keeps complete subtrees together; +server pages are rendered exactly as supplied and `total` counts roots. Pinned +rows remain ordinary non-hierarchical totals. + +Only visible descendants participate in cell ranges and virtualization. +Collapsing a range endpoint makes the effective range inactive; a controlled or +uncontrolled range is restored when that endpoint becomes visible again. Client +row numbers remain continuous across root pages, while server tree pages restart +at 1 because preceding descendant counts are unknown. + +The native table exposes `role="treegrid"`, `aria-level`, `aria-expanded`, +`aria-posinset` and `aria-setsize`. Right/Left expand, collapse or move to the +parent; Up/Down traverse visible rows, and typeahead reads `treeColumnKey`. +Customize the hierarchy chrome with `styles.TreeContent`, `styles.TreeToggle` +and `styles.TreeValue`. + + + ## Examples ### Multi-Column Sorting diff --git a/src/components/data/DataTable/DataTable.stories.tsx b/src/components/data/DataTable/DataTable.stories.tsx index 9d80e305e..f486aeb26 100644 --- a/src/components/data/DataTable/DataTable.stories.tsx +++ b/src/components/data/DataTable/DataTable.stories.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import { NumberIcon, @@ -30,6 +31,8 @@ interface ResultRow { conversion: number; } +type TreeResultRow = ResultRow & { children?: TreeResultRow[] }; + const REGIONS = ['us-east-1', 'us-west-2', 'eu-central-1', 'ap-south-1']; const CHANNELS = ['organic', 'paid', 'email', 'referral']; @@ -42,6 +45,27 @@ const ROWS: ResultRow[] = Array.from({ length: 240 }, (_, i) => ({ conversion: (((i * 13) % 780) + 40) / 1000, })); +const TREE_ROWS: TreeResultRow[] = [ + { + ...ROWS[0], + id: 'americas', + region: 'Americas', + children: [ + { + ...ROWS[1], + id: 'north-america', + region: 'North America', + children: [ + { ...ROWS[2], id: 'us-east', region: 'US East' }, + { ...ROWS[3], id: 'us-west', region: 'US West' }, + ], + }, + { ...ROWS[4], id: 'south-america', region: 'South America' }, + ], + }, + { ...ROWS[5], id: 'emea', region: 'EMEA' }, +]; + const COLUMNS: CubeDataTableColumn[] = [ { key: 'region', title: 'Region', minWidth: 140 }, { key: 'channel', title: 'Channel', minWidth: 120 }, @@ -160,6 +184,30 @@ type Story = StoryObj>; */ export const Default: Story = {}; +/** Nested result rows retain DataTable's multi-sort and cell-range behavior. */ +export const TreeRows: Story = { + args: { + data: TREE_ROWS, + getRowChildren: (row) => (row as TreeResultRow).children, + treeColumnKey: 'region', + paginationMode: 'off', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByRole('button', { name: /^Expand Americas/ }), + ); + + await waitFor(() => { + expect(canvas.getByText('North America')).toBeVisible(); + expect( + canvas.getByRole('button', { name: /^Collapse Americas/ }), + ).toBeVisible(); + }); + }, +}; + /** * Sorting is **multi-column**, which is the main behavioural difference from * `ItemTable`. Click a second header and it joins the sort rather than diff --git a/src/components/data/DataTable/DataTable.tree.test.tsx b/src/components/data/DataTable/DataTable.tree.test.tsx new file mode 100644 index 000000000..397e0176e --- /dev/null +++ b/src/components/data/DataTable/DataTable.tree.test.tsx @@ -0,0 +1,174 @@ +import { renderWithRoot, screen, userEvent, waitFor } from '../../../test'; + +import { DataTable } from './DataTable'; + +import type { CubeDataTableColumn } from './types'; + +interface Row { + id: string; + name: string; + value: number; + children?: Row[]; +} + +const DATA: Row[] = [ + { + id: 'z-root', + name: 'Zulu', + value: 2, + children: [ + { id: 'z-b', name: 'Zulu B', value: 3 }, + { id: 'z-a', name: 'Zulu A', value: 1 }, + ], + }, + { + id: 'a-root', + name: 'Alpha', + value: 1, + children: [{ id: 'a-child', name: 'Alpha child', value: 4 }], + }, +]; + +const COLUMNS: CubeDataTableColumn[] = [ + { key: 'name', title: 'Name', isSortable: true }, + { key: 'value', title: 'Value', dataType: 'number', isSortable: true }, +]; + +const treeProps = { + data: DATA, + columns: COLUMNS, + getRowChildren: (row: Row) => row.children, + ariaLabel: 'Results tree', +}; + +const grid = () => screen.getByRole('treegrid', { name: 'Results tree' }); +const rows = () => + Array.from( + grid().querySelectorAll( + 'tbody tr[data-element="Row"]', + ), + ); +const keys = () => rows().map((item) => item.dataset.key); +const row = (key: string) => + grid().querySelector(`tbody tr[data-key="${key}"]`)!; +const cell = (rowKey: string, columnKey: string) => + row(rowKey).querySelector(`[data-key="${columnKey}"]`)!; + +describe('DataTable tree rows', () => { + it('applies multi-sort recursively to every sibling collection', () => { + renderWithRoot( + , + ); + + expect(keys()).toEqual(['a-root', 'a-child', 'z-root', 'z-a', 'z-b']); + }); + + it('paginates roots and keeps a complete expanded subtree on its page', async () => { + renderWithRoot( + , + ); + + expect(keys()).toEqual(['z-root', 'z-b', 'z-a']); + expect(row('z-root')).toHaveAttribute('aria-posinset', '1'); + expect(row('z-root')).toHaveAttribute('aria-setsize', '1'); + + await userEvent.click(screen.getByRole('button', { name: 'Page 2' })); + await waitFor(() => expect(keys()).toEqual(['a-root'])); + }); + + it('keeps pinned rows outside the hierarchy', () => { + const total: Row = { id: 'total', name: 'Total', value: 11 }; + + renderWithRoot( + , + ); + + const pinned = grid().querySelector( + 'tr[data-pinned="top"]', + )!; + expect(pinned).not.toHaveAttribute('aria-level'); + expect(pinned.querySelector('[data-element="TreeContent"]')).toBeNull(); + expect(row('z-root')).toHaveAttribute('aria-level', '1'); + }); + + it('deactivates a controlled cell range while an endpoint is collapsed and restores it', async () => { + const range = { + fromRowKey: 'z-b', + toRowKey: 'z-a', + fromColumnKey: 'name', + toColumnKey: 'value', + }; + const { rerender } = renderWithRoot( + , + ); + + expect(grid().querySelectorAll('[data-cell-selected]')).toHaveLength(4); + + rerender( + , + ); + expect(grid().querySelectorAll('[data-cell-selected]')).toHaveLength(0); + + rerender( + , + ); + expect(grid().querySelectorAll('[data-cell-selected]')).toHaveLength(4); + }); + + it('numbers client root pages continuously and server tree pages from one', async () => { + const { rerender } = renderWithRoot( + , + ); + + expect(cell('z-root', '__cube-row-number__')).toHaveTextContent('1'); + await userEvent.click(screen.getByRole('button', { name: 'Page 2' })); + await waitFor(() => + expect(cell('a-root', '__cube-row-number__')).toHaveTextContent('4'), + ); + + rerender( + , + ); + expect(cell('a-root', '__cube-row-number__')).toHaveTextContent('1'); + }); +}); diff --git a/src/components/data/DataTable/DataTable.tsx b/src/components/data/DataTable/DataTable.tsx index 05cc617f0..009611723 100644 --- a/src/components/data/DataTable/DataTable.tsx +++ b/src/components/data/DataTable/DataTable.tsx @@ -1,10 +1,11 @@ +import { useCollator } from '@react-aria/i18n'; import { SelectionManager } from '@react-stately/selection'; import { useControlledState } from '@react-stately/utils'; import { CONTAINER_STYLES } from '@tenphi/tasty'; import { forwardRef, useMemo, useRef, useState } from 'react'; import { useMultipleSelectionState } from 'react-stately'; -import { useEvent } from '../../../_internal/hooks'; +import { useEvent, useWarn } from '../../../_internal/hooks'; import { useI18n } from '../../../i18n'; import { useCombinedRefs } from '../../../utils/react'; import { extractStyles } from '../../../utils/styles'; @@ -12,6 +13,12 @@ import { clampPage, getPageInfo } from '../../navigation/Pagination'; import { DraggableCollection } from '../../shared/DraggableCollection'; import { ItemTableFooter } from '../ItemTable/ItemTableFooter'; import { RowCollection } from '../TableBase/RowCollection'; +import { + buildTableTree, + flattenTableTree, + reindexTableTree, + sortTableTree, +} from '../TableBase/table-tree'; import { TableView } from '../TableBase/TableView'; import { selectionRowKey } from '../TableBase/types'; import { useCellSelection } from '../TableBase/use-cell-selection'; @@ -23,11 +30,14 @@ import { import { useContainerWidth } from '../TableBase/use-container-width'; import { freezeColumnWidths, + getColumnText, useTableColumns, } from '../TableBase/use-table-columns'; import { ROW_NUMBER_COLUMN_KEY } from '../TableBase/use-table-selection'; +import { compareByColumn } from '../TableBase/use-table-sort'; import { useTableSorts } from '../TableBase/use-table-sorts'; import { useTableStorage } from '../TableBase/use-table-storage'; +import { useTableTreeState } from '../TableBase/use-table-tree-state'; import type { Key } from '@react-types/shared'; import type { ForwardedRef, ReactElement } from 'react'; @@ -72,6 +82,11 @@ function DataTable( columns, rowKey = 'id', getRowKey, + getRowChildren, + treeColumnKey, + expandedKeys, + defaultExpandedKeys, + onExpand, pinnedTopRows, pinnedBottomRows, isLoading = false, @@ -153,6 +168,34 @@ function DataTable( const [scrollerEl, setScrollerEl] = useState(null); const containerWidth = useContainerWidth(scrollerEl); + const resolvedGetRowKey = useMemo( + () => getRowKey ?? defaultGetRowKey(rowKey), + [getRowKey, rowKey], + ); + + const treeModel = useMemo( + () => + getRowChildren + ? buildTableTree(data, getRowChildren, resolvedGetRowKey) + : null, + [data, getRowChildren, resolvedGetRowKey], + ); + + useWarn(treeModel != null && treeModel.duplicateKeys.length > 0, { + key: ['data-table-tree-duplicate-keys'], + args: [ + 'DataTable:', + 'Tree row keys must be unique across the complete hierarchy. Duplicate rows were ignored.', + ], + }); + useWarn(treeModel != null && treeModel.cyclicKeys.length > 0, { + key: ['data-table-tree-cyclic-keys'], + args: [ + 'DataTable:', + 'Tree data contains a cycle. Cyclic descendants were ignored.', + ], + }); + const storage = useTableStorage(storageKey); /** @@ -193,6 +236,27 @@ function DataTable( }); const orderedColumns = columnOrderState.columns; + const resolvedTreeColumnKey = useMemo(() => { + if (!treeModel) return undefined; + const visibleColumns = orderedColumns.filter((column) => !column.isHidden); + return visibleColumns.some((column) => column.key === treeColumnKey) + ? treeColumnKey + : visibleColumns[0]?.key; + }, [treeModel, orderedColumns, treeColumnKey]); + + useWarn( + treeModel != null && + treeColumnKey != null && + resolvedTreeColumnKey !== treeColumnKey, + { + key: ['data-table-tree-column-invalid', treeColumnKey], + args: [ + 'DataTable:', + '`treeColumnKey` must identify a visible data column. Falling back to the first visible column.', + ], + }, + ); + const { sorts, sortedRows, @@ -201,13 +265,47 @@ function DataTable( mode: resolvedSortMode, } = useTableSorts({ columns: orderedColumns, - rows: data, - mode: sortMode, + rows: treeModel ? treeModel.roots.map((node) => node.row) : data, + mode: treeModel ? 'server' : sortMode, sorts: sortsProp, defaultSorts, onSortsChange, }); + const collator = useCollator({ numeric: true, sensitivity: 'base' }); + const treeSortMode = treeModel + ? sortMode ?? + (orderedColumns.some((column) => column.isSortable) ? 'client' : 'off') + : resolvedSortMode; + const processedTreeRoots = useMemo(() => { + const roots = treeModel?.roots ?? []; + if (treeSortMode !== 'client' || !sorts.length) return roots; + + const active = sorts + .map((sort) => ({ + sort, + column: orderedColumns.find((column) => column.key === sort.columnKey), + })) + .filter((entry) => entry.column != null); + + return sortTableTree(roots, (a, b) => { + for (const { sort, column } of active) { + const result = compareByColumn( + column!, + collator, + a.row, + a.sourceIndex, + b.row, + b.sourceIndex, + ); + if (result !== 0) { + return result * (sort.direction === 'asc' ? 1 : -1); + } + } + return a.siblingIndex - b.siblingIndex; + }); + }, [treeModel, treeSortMode, sorts, orderedColumns, collator]); + const [pageSize, setPageSizeState] = useControlledState( pageSizeProp as number, (pageSizeProp === undefined && storage.has('pageSize') @@ -223,7 +321,11 @@ function DataTable( const isPaginated = paginationMode !== 'off'; const isServerPaginated = paginationMode === 'server'; - const total = isServerPaginated ? totalProp ?? 0 : sortedRows.length; + const total = isServerPaginated + ? totalProp ?? 0 + : treeModel + ? processedTreeRoots.length + : sortedRows.length; const pageInfo = getPageInfo({ page, @@ -242,7 +344,7 @@ function DataTable( if (pageSizeProp === undefined) storage.write({ pageSize: next }); }); - const visibleRows = + const flatVisibleRows = paginationMode === 'client' ? sortedRows.slice( (pageInfo.page - 1) * pageSize, @@ -250,6 +352,44 @@ function DataTable( ) : sortedRows; + const pageTreeRoots = useMemo( + () => + reindexTableTree( + paginationMode === 'client' + ? processedTreeRoots.slice( + (pageInfo.page - 1) * pageSize, + pageInfo.page * pageSize, + ) + : processedTreeRoots, + ), + [processedTreeRoots, paginationMode, pageInfo.page, pageSize], + ); + + const treeState = useTableTreeState({ + roots: pageTreeRoots, + allNodesByKey: treeModel?.byKey ?? new Map(), + expandedKeys, + defaultExpandedKeys, + getTextValue: (node) => { + const column = orderedColumns.find( + (entry) => entry.key === resolvedTreeColumnKey, + ); + return column + ? getColumnText(column, node.row, node.sourceIndex) ?? String(node.key) + : String(node.key); + }, + onExpand, + ariaLabel, + }); + + const visibleTreeEntries = treeModel ? treeState.visibleEntries : []; + const visibleRows = treeModel + ? visibleTreeEntries.map((entry) => entry.row) + : flatVisibleRows; + const visibleRowKeys = treeModel + ? visibleTreeEntries.map((entry) => entry.key) + : undefined; + const [ownColumnWidths, setOwnColumnWidths] = useState< Record >( @@ -317,11 +457,6 @@ function DataTable( const { t } = useI18n(); - const resolvedGetRowKey = useMemo( - () => getRowKey ?? defaultGetRowKey(rowKey), - [getRowKey, rowKey], - ); - // Every row a range can reach, in the order they appear on screen. Pinned rows // are in it: a total is a figure like any other, and having to copy it // separately from the column it sums is the wrong trade. @@ -345,12 +480,21 @@ function DataTable( ...(pinnedTopRows ?? []).map((row, index) => selectionRowKey('pinnedTop', resolvedGetRowKey(row, index)), ), - ...visibleRows.map(resolvedGetRowKey), + ...visibleRows.map( + (row, index) => + visibleRowKeys?.[index] ?? resolvedGetRowKey(row, index), + ), ...(pinnedBottomRows ?? []).map((row, index) => selectionRowKey('pinnedBottom', resolvedGetRowKey(row, index)), ), ], - [pinnedTopRows, visibleRows, pinnedBottomRows, resolvedGetRowKey], + [ + pinnedTopRows, + visibleRows, + visibleRowKeys, + pinnedBottomRows, + resolvedGetRowKey, + ], ); // The hook works in one flat row order; the section a row belongs to is a @@ -456,6 +600,15 @@ function DataTable( const isColumnDragEnabled = isColumnReorderable && draggableColumnKeys.length > 1; + const treeRowNumberOffset = treeModel + ? paginationMode === 'client' + ? flattenTableTree( + processedTreeRoots.slice(0, (pageInfo.page - 1) * pageSize), + treeState.expandedKeys, + ).length + : 0 + : null; + const renderTable = ( columnDragState?: any, columnDropState?: any, @@ -466,6 +619,7 @@ function DataTable( rootRef={rootRef} qa={qa || 'DataTable'} rows={visibleRows} + rowKeys={visibleRowKeys} pinnedTopRows={pinnedTopRows} pinnedBottomRows={pinnedBottomRows} // Continuous across pages: row 101 is row 101, not row 1 of page two. @@ -474,15 +628,20 @@ function DataTable( // same, and `rows` is that page. Only `'off'` starts at 1, because then // there is no page to be on. rowNumberOffset={ - paginationMode === 'off' ? 0 : (pageInfo.page - 1) * pageSize + treeRowNumberOffset ?? + (paginationMode === 'off' ? 0 : (pageInfo.page - 1) * pageSize) } // The same offset drives `aria-rowindex`, which is document-absolute by // contract — a screen reader on page 3 should hear "row 51 of 240", not // "row 1 of 25". rowIndexOffset={ - paginationMode === 'off' ? 0 : (pageInfo.page - 1) * pageSize + treeModel + ? 0 + : paginationMode === 'off' + ? 0 + : (pageInfo.page - 1) * pageSize } - totalRowCount={total} + totalRowCount={treeModel ? visibleRows.length : total} layout={layout} // Always on, unlike `ItemTable`. A result grid is read down a column, and // once the values are wide and right-aligned the rule is what keeps a @@ -519,7 +678,7 @@ function DataTable( // legibility. The header takes the medium weight of the same step. contentPreset="t4" headerPreset="t4m" - sortMode={resolvedSortMode} + sortMode={treeSortMode} sorts={sorts} onColumnSort={toggleSort} onColumnSortChange={setColumnSort} @@ -548,6 +707,17 @@ function DataTable( headCollectionProps={headCollectionProps} headRowRef={headRowRef} onColumnFocus={handleColumnFocus} + tree={ + treeModel + ? { + state: treeState.state, + ariaProps: treeState.ariaProps, + nodes: treeState.visibleNodes, + entries: visibleTreeEntries, + columnKey: resolvedTreeColumnKey!, + } + : undefined + } footer={ hasFooter ? ( extends CubeTableColumn { export interface CubeDataTableProps extends BaseProps, - ContainerStyleProps { + ContainerStyleProps, + CubeTableTreeProps { /* ── data ─────────────────────────────────────────────────────────── */ data: readonly T[]; columns: CubeDataTableColumn[]; diff --git a/src/components/data/ItemTable/ItemTable.browser.test.tsx b/src/components/data/ItemTable/ItemTable.browser.test.tsx index 460ee98fb..13cfd9385 100644 --- a/src/components/data/ItemTable/ItemTable.browser.test.tsx +++ b/src/components/data/ItemTable/ItemTable.browser.test.tsx @@ -1,3 +1,6 @@ +import { act } from 'react'; +import { userEvent as realInput } from 'vitest/browser'; + import { DatabaseIcon } from '../../../icons'; import { renderWithRoot, screen } from '../../../test'; import { Button } from '../../actions/Button'; @@ -619,3 +622,74 @@ describe('infinite scroll prefetch distance', () => { await vi.waitFor(() => expect(onLoadMore).toHaveBeenCalled()); }); }); + +describe('treegrid focus and virtualization', () => { + interface TreeRow { + id: string; + name: string; + children?: TreeRow[]; + } + + const treeColumns: CubeItemTableColumn[] = [ + { key: 'name', title: 'Name', isRowHeader: true }, + ]; + const treeData: TreeRow[] = Array.from({ length: 60 }, (_, index) => ({ + id: `root-${index}`, + name: `Root ${index}`, + children: + index === 0 + ? [ + { + id: 'branch', + name: 'Branch', + children: [{ id: 'leaf', name: 'Leaf' }], + }, + ] + : undefined, + })); + + const treegrid = () => screen.getByRole('treegrid'); + const treeRow = (key: string) => + treegrid().querySelector(`tr[data-key="${key}"]`)!; + + it('moves real focus through three visible levels', async () => { + renderWithRoot( + row.children} + />, + ); + + treeRow('root-0').focus(); + await act(() => + realInput.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}'), + ); + + await vi.waitFor(() => expect(treeRow('leaf')).toHaveFocus()); + }); + + it('adds expanded children to a virtualized visible window', async () => { + renderWithRoot( + row.children} + height="260px" + isVirtualized + overscan={2} + paginationMode="off" + />, + ); + + await vi.waitFor(() => expect(treeRow('root-0')).toBeInTheDocument()); + treeRow('root-0').focus(); + await act(() => realInput.keyboard('{ArrowRight}')); + + await vi.waitFor(() => expect(treeRow('branch')).toBeInTheDocument()); + expect(treegrid()).toHaveAttribute('aria-rowcount', '62'); + expect( + treegrid().querySelectorAll('tbody tr[data-element="Row"]').length, + ).toBeLessThan(61); + }); +}); diff --git a/src/components/data/ItemTable/ItemTable.docs.mdx b/src/components/data/ItemTable/ItemTable.docs.mdx index b5ba7f699..6d9166a52 100644 --- a/src/components/data/ItemTable/ItemTable.docs.mdx +++ b/src/components/data/ItemTable/ItemTable.docs.mdx @@ -38,6 +38,14 @@ pivots, totals. Use `ListBox` when there is only one column. - **`columns`** `CubeItemTableColumn[]` — Column definitions. See below. - **`rowKey`** `string` (default: `'id'`) — Property used as the row key. - **`getRowKey`** `(row: T, index: number) => Key` — Wins over `rowKey`. +- **`getRowChildren`** `(row: T) => readonly T[] | undefined` — Enables tree + mode. `data` then contains top-level rows and children are read recursively. +- **`treeColumnKey`** `string` — Column containing indentation and the + disclosure control. Defaults to the first visible data column. +- **`expandedKeys`** `Key[]` — Controlled expanded rows. +- **`defaultExpandedKeys`** `Key[]` — Initially expanded rows. Defaults to none. +- **`onExpand`** `(keys, info) => void` — Reports the complete expanded-key list + and the toggled row, key, zero-based level, parent key and resulting state. - **`isLoading`** `boolean` (default: `false`) — Marks a fetch in flight. What the user sees depends on `loadingIndicator`. - **`loadingIndicator`** `'overlay' | 'skeleton' | 'none'` (default: `'overlay'`) @@ -124,6 +132,10 @@ pivots, totals. Use `ListBox` when there is only one column. - **`selectionTooltip`** `string | ((row: T) => string | undefined)` — Explains an inert checkbox. - **`disabledKeys`** `Key[]` — Rows that cannot be interacted with at all. +- **`treeSelectionBehavior`** `'cascade' | 'independent'` (default: `'cascade'` + in tree mode) — Multiple selection only. Cascade selects eligible descendants + and derives checked or indeterminate ancestors; single selection is always + independent. - **`rowLink`** `(row: T, index: number) => NavigateArg | undefined` — Turns the row-header cell into a stretched link covering the whole row. Return `undefined` for a row that should not navigate. @@ -214,6 +226,9 @@ pivots, totals. Use `ListBox` when there is only one column. - **`getRowProps`** `(ctx) => { isDimmed?, mods?, styles?, height?, qa?, tooltip? }` — Per-row visuals. - **`ariaLabel`** `string` — Labels the grid. +- **`isChecked`** `boolean` — Base checked-state modifier. +- **`place`** `string` — Tasty placement shorthand on the root frame. +- **`scrollMargin`** `string | number` — Scroll margin on the root frame. #### `CubeItemTableColumn` @@ -298,6 +313,8 @@ reachable from this one prop — that is the whole styling contract. - `Table` — the `` - `Head`, `HeadRow`, `HeaderCell` — the column header - `Body`, `Row`, `Cell` — the data rows +- `TreeContent`, `TreeToggle`, `TreeValue` — indentation, disclosure/leaf + placeholder, and the consumer-rendered value in the tree column - `SortIndicator` — the sort arrow, inside the header `Item`'s suffix - `Foot`, `FootRow`, `FootCell` — pinned totals - `StateContent` — the empty / no-results / error content @@ -399,6 +416,41 @@ Row-level modifiers, set on each ``: `odd`, `selected`, `focused`, `dimmed`, +### Tree Rows + +Supplying `getRowChildren` changes the root to `role="treegrid"`. Rows keep their +original objects; keys must be unique across the complete hierarchy. + +```jsx + row.children} + treeColumnKey="name" + defaultExpandedKeys={['production']} + selectionMode="multiple" +/> +``` + +Client sorting runs independently within every sibling collection. Client +search retains ancestor paths; a matching parent keeps its complete subtree, +and required branches open temporarily without changing `expandedKeys`. Client +pagination counts and slices roots, keeping each subtree on its parent's page. +Server modes treat the supplied nested page as already processed, and `total` +counts roots. + +The chevron and Right/Left arrows expand or collapse. Up/Down traverse visible +rows, typeahead uses the tree column, and Left on a collapsed child moves to its +parent. Ordinary row clicks still activate `onRowAction`/`rowLink`; the +disclosure does not. Select-all includes collapsed descendants. Disabled and +unselectable descendants are excluded from cascade. + +Tree mode ignores `isReorderable`, because a flat reordered array cannot encode +parents. `dropOnRow` remains available for folder moves; self/descendant cycles +are rejected and selected descendants are collapsed under a dragged ancestor. + + + ### Custom Cells ```jsx diff --git a/src/components/data/ItemTable/ItemTable.stories.tsx b/src/components/data/ItemTable/ItemTable.stories.tsx index b7a901b63..18e7768c6 100644 --- a/src/components/data/ItemTable/ItemTable.stories.tsx +++ b/src/components/data/ItemTable/ItemTable.stories.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import { DatabaseIcon, FolderIcon, PlusIcon, UserIcon } from '../../../icons'; import { Button, Menu } from '../../actions'; @@ -25,6 +26,8 @@ interface Deployment { queries: number; } +type TreeDeployment = Deployment & { children?: TreeDeployment[] }; + const DEPLOYMENTS: Deployment[] = [ { id: 'd1', @@ -68,6 +71,26 @@ const DEPLOYMENTS: Deployment[] = [ }, ]; +const TREE_DEPLOYMENTS: TreeDeployment[] = [ + { + ...DEPLOYMENTS[0], + id: 'production', + name: 'Production', + children: [ + { + ...DEPLOYMENTS[1], + id: 'analytics', + name: 'Analytics', + children: [ + { ...DEPLOYMENTS[2], id: 'billing', name: 'Billing pipeline' }, + ], + }, + { ...DEPLOYMENTS[3], id: 'growth', name: 'Growth marts' }, + ], + }, + { ...DEPLOYMENTS[4], id: 'sandbox', name: 'Sandbox' }, +]; + const STATUS_THEME = { running: 'success', stopped: 'default', @@ -262,6 +285,35 @@ type Story = StoryObj; export const Default: Story = {}; +/** + * Nested source rows opt into a native treegrid. The disclosure is independent + * from row activation, and multiple selection cascades through descendants. + */ +export const TreeRows: Story = { + args: { + data: TREE_DEPLOYMENTS, + getRowChildren: (row) => (row as TreeDeployment).children, + treeColumnKey: 'name', + selectionMode: 'multiple', + paginationMode: 'off', + shape: 'card', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click( + canvas.getByRole('button', { name: /^Expand Production/ }), + ); + + await waitFor(() => { + expect(canvas.getByText('Analytics')).toBeVisible(); + expect( + canvas.getByRole('button', { name: /^Collapse Production/ }), + ).toBeVisible(); + }); + }, +}; + /** * `shape="card"` frames the table, `isStriped` bands the rows, and `size` drives * both row and header height. diff --git a/src/components/data/ItemTable/ItemTable.tree.test.tsx b/src/components/data/ItemTable/ItemTable.tree.test.tsx new file mode 100644 index 000000000..a32937124 --- /dev/null +++ b/src/components/data/ItemTable/ItemTable.tree.test.tsx @@ -0,0 +1,298 @@ +import { fireEvent } from '@testing-library/react'; + +import { + renderWithRoot, + screen, + userEvent, + waitFor, + within, +} from '../../../test'; + +import { ItemTable } from './ItemTable'; + +import type { CubeItemTableColumn } from './types'; + +interface Row { + id: string; + name: string; + children?: Row[]; +} + +const DATA: Row[] = [ + { + id: 'root', + name: 'Root', + children: [ + { + id: 'branch', + name: 'Branch', + children: [{ id: 'leaf', name: 'Needle leaf' }], + }, + { id: 'sibling', name: 'Sibling' }, + ], + }, + { id: 'other', name: 'Other' }, +]; + +const COLUMNS: CubeItemTableColumn[] = [ + { key: 'name', title: 'Name', isRowHeader: true, isSortable: true }, +]; + +const treeProps = { + data: DATA, + columns: COLUMNS, + getRowChildren: (row: Row) => row.children, + ariaLabel: 'Hierarchy', +}; + +const grid = () => screen.getByRole('treegrid', { name: 'Hierarchy' }); +const bodyRows = () => + Array.from( + grid().querySelectorAll( + 'tbody tr[data-element="Row"]', + ), + ); +const row = (key: string) => + grid().querySelector(`tbody tr[data-key="${key}"]`)!; +const rowBox = (key: string) => + row(key).querySelector('input[type="checkbox"]')!; + +describe('ItemTable tree rows', () => { + it('uses treegrid semantics and exposes hierarchy metadata', () => { + const contexts: unknown[] = []; + + renderWithRoot( + { + contexts.push(ctx.tree); + return {}; + }} + />, + ); + + expect(screen.queryByRole('grid')).toBeNull(); + expect(grid()).toHaveAttribute('aria-rowcount', '6'); + expect(row('root')).toHaveAttribute('aria-level', '1'); + expect(row('root')).toHaveAttribute('aria-expanded', 'true'); + expect(row('root')).toHaveAttribute('aria-posinset', '1'); + expect(row('branch')).toHaveAttribute('aria-level', '2'); + expect(row('branch')).toHaveAttribute('aria-posinset', '1'); + expect(row('branch')).toHaveAttribute('aria-setsize', '2'); + expect(row('leaf')).toHaveAttribute('aria-level', '3'); + expect(contexts).toContainEqual({ + level: 2, + parentKey: 'branch', + hasChildren: false, + isExpanded: false, + }); + }); + + it('expands and collapses without activating the row', async () => { + const onExpand = vi.fn(); + const onRowAction = vi.fn(); + + renderWithRoot( + , + ); + + await userEvent.click( + within(row('root')).getByRole('button', { name: /^Expand/ }), + ); + + expect(row('branch')).toBeInTheDocument(); + expect(onRowAction).not.toHaveBeenCalled(); + expect(onExpand).toHaveBeenLastCalledWith( + ['root'], + expect.objectContaining({ + rowKey: 'root', + level: 0, + parentKey: null, + expanded: true, + }), + ); + + await userEvent.click( + within(row('root')).getByRole('button', { name: /^Collapse/ }), + ); + expect(row('branch')).not.toBeInTheDocument(); + }); + + it('reports controlled expansion but waits for the prop to change', async () => { + const onExpand = vi.fn(); + + renderWithRoot( + , + ); + + await userEvent.click( + within(row('root')).getByRole('button', { name: /^Expand/ }), + ); + + expect(onExpand).toHaveBeenCalled(); + expect(row('branch')).not.toBeInTheDocument(); + }); + + it('navigates three levels with arrow keys', async () => { + renderWithRoot(); + row('root').focus(); + + await userEvent.keyboard('{ArrowRight}'); + expect(row('root')).toHaveAttribute('aria-expanded', 'true'); + + await userEvent.keyboard('{ArrowRight}'); + expect(row('branch')).toHaveFocus(); + await userEvent.keyboard('{ArrowRight}'); + await userEvent.keyboard('{ArrowRight}'); + expect(row('leaf')).toHaveFocus(); + + await userEvent.keyboard('{ArrowLeft}'); + expect(row('branch')).toHaveFocus(); + await userEvent.keyboard('{ArrowLeft}'); + expect(row('branch')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('searches recursively, keeps ancestor paths, and restores expansion', async () => { + const { rerender } = renderWithRoot( + , + ); + + await waitFor(() => + expect(bodyRows().map((item) => item.dataset.key)).toEqual([ + 'root', + 'branch', + 'leaf', + ]), + ); + + rerender( + , + ); + + await waitFor(() => + expect(bodyRows().map((item) => item.dataset.key)).toEqual([ + 'root', + 'other', + ]), + ); + }); + + it('cascades multiple selection and derives an indeterminate parent', async () => { + const onSelectionChange = vi.fn(); + + renderWithRoot( + , + ); + + fireEvent.click(rowBox('root')); + await waitFor(() => + expect(onSelectionChange).toHaveBeenLastCalledWith( + ['root', 'branch', 'leaf', 'sibling'], + expect.arrayContaining([ + expect.objectContaining({ id: 'root' }), + expect.objectContaining({ id: 'leaf' }), + ]), + ), + ); + expect(rowBox('leaf')).toBeChecked(); + + await userEvent.click(rowBox('leaf')); + expect(rowBox('root').indeterminate).toBe(true); + expect(rowBox('branch').indeterminate).toBe(false); + expect(rowBox('leaf')).not.toBeChecked(); + }); + + it('excludes disabled descendants from cascade and collapsed select-all', async () => { + const onSelectionChange = vi.fn(); + + renderWithRoot( + , + ); + + await userEvent.click(within(grid()).getAllByRole('checkbox')[0]); + + await waitFor(() => { + const keys = onSelectionChange.mock.lastCall?.[0] as string[]; + expect(keys).toEqual(['root', 'branch', 'sibling', 'other']); + expect(keys).not.toContain('leaf'); + }); + }); + + it('keeps single and independent multiple selection non-cascading', async () => { + const { rerender } = renderWithRoot( + , + ); + + await userEvent.click(rowBox('root')); + expect(rowBox('root')).toBeChecked(); + expect(rowBox('branch')).not.toBeChecked(); + + rerender( + , + ); + await userEvent.click(rowBox('root')); + expect(rowBox('root')).toBeChecked(); + expect(rowBox('branch')).not.toBeChecked(); + }); + + it('warns and ignores flat reordering in tree mode', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + renderWithRoot( + {}} + treeColumnKey="missing" + />, + ); + + expect(warn).toHaveBeenCalledWith( + 'CubeUIKit:', + 'ItemTable:', + '`isReorderable` is ignored in tree mode. Use `dropOnRow` for folder-style moves.', + ); + expect(warn).toHaveBeenCalledWith( + 'CubeUIKit:', + 'ItemTable:', + '`treeColumnKey` must identify a visible data column. Falling back to the first visible column.', + ); + expect(row('root')).not.toHaveAttribute('draggable'); + warn.mockRestore(); + }); +}); diff --git a/src/components/data/ItemTable/ItemTable.tsx b/src/components/data/ItemTable/ItemTable.tsx index 80d7ae28e..47b34366d 100644 --- a/src/components/data/ItemTable/ItemTable.tsx +++ b/src/components/data/ItemTable/ItemTable.tsx @@ -1,8 +1,9 @@ +import { useCollator } from '@react-aria/i18n'; import { useControlledState } from '@react-stately/utils'; import { CONTAINER_STYLES } from '@tenphi/tasty'; import { forwardRef, useMemo, useRef, useState } from 'react'; -import { useEvent } from '../../../_internal/hooks'; +import { useEvent, useWarn } from '../../../_internal/hooks'; import { useI18n } from '../../../i18n'; import { useCombinedRefs } from '../../../utils/react'; import { extractStyles } from '../../../utils/styles'; @@ -12,20 +13,33 @@ import { ROW_MENU_COLUMN_KEY, ROW_MENU_COLUMN_WIDTH, } from '../TableBase/row-menu'; +import { + buildTableTree, + filterTableTree, + flattenTableTree, + isTableTreeDescendant, + reindexTableTree, + sortTableTree, +} from '../TableBase/table-tree'; import { TableView } from '../TableBase/TableView'; import { useContainerWidth } from '../TableBase/use-container-width'; import { freezeColumnWidths, + getColumnText, useTableColumns, } from '../TableBase/use-table-columns'; -import { useTableSearch } from '../TableBase/use-table-search'; +import { + matchesTableSearch, + useTableSearch, +} from '../TableBase/use-table-search'; import { SELECTION_COLUMN_KEY, SELECTION_COLUMN_WIDTH, useTableSelection, } from '../TableBase/use-table-selection'; -import { useTableSort } from '../TableBase/use-table-sort'; +import { compareByColumn, useTableSort } from '../TableBase/use-table-sort'; import { useTableStorage } from '../TableBase/use-table-storage'; +import { useTableTreeState } from '../TableBase/use-table-tree-state'; import { ItemTableBulkBar } from './ItemTableBulkBar'; import { ItemTableDragPreview } from './ItemTableDragPreview'; @@ -65,6 +79,11 @@ function ItemTable( columns, rowKey = 'id', getRowKey, + getRowChildren, + treeColumnKey, + expandedKeys, + defaultExpandedKeys, + onExpand, isLoading = false, loadingIndicator = 'overlay', selectionMode, @@ -84,6 +103,7 @@ function ItemTable( isRowSelectable, selectionTooltip, disabledKeys, + treeSelectionBehavior = 'cascade', skeletonRowCount = 6, emptyLabel, noResultsLabel, @@ -183,15 +203,74 @@ function ItemTable( // is what turns the body into a scroller and pins the header. const rootStyles = { ...extractStyles(props, CONTAINER_STYLES), ...styles }; + const resolvedGetRowKey = useMemo( + () => getRowKey ?? defaultGetRowKey(rowKey), + [getRowKey, rowKey], + ); + + const treeModel = useMemo( + () => + getRowChildren + ? buildTableTree(data, getRowChildren, resolvedGetRowKey) + : null, + [data, getRowChildren, resolvedGetRowKey], + ); + + useWarn(treeModel != null && treeModel.duplicateKeys.length > 0, { + key: ['item-table-tree-duplicate-keys'], + args: [ + 'ItemTable:', + 'Tree row keys must be unique across the complete hierarchy. Duplicate rows were ignored.', + ], + }); + useWarn(treeModel != null && treeModel.cyclicKeys.length > 0, { + key: ['item-table-tree-cyclic-keys'], + args: [ + 'ItemTable:', + 'Tree data contains a cycle. Cyclic descendants were ignored.', + ], + }); + useWarn(treeModel != null && isReorderable, { + key: ['item-table-tree-reorder-unsupported'], + args: [ + 'ItemTable:', + '`isReorderable` is ignored in tree mode. Use `dropOnRow` for folder-style moves.', + ], + }); + + const resolvedTreeColumnKey = useMemo(() => { + if (!treeModel) return undefined; + const visibleColumns = columns.filter((column) => !column.isHidden); + return visibleColumns.some((column) => column.key === treeColumnKey) + ? treeColumnKey + : visibleColumns[0]?.key; + }, [treeModel, columns, treeColumnKey]); + + useWarn( + treeModel != null && + treeColumnKey != null && + resolvedTreeColumnKey !== treeColumnKey, + { + key: ['item-table-tree-column-invalid', treeColumnKey], + args: [ + 'ItemTable:', + '`treeColumnKey` must identify a visible data column. Falling back to the first visible column.', + ], + }, + ); + const { searchValue, setSearchValue, searchedRows, + query, isFiltered: isSearching, } = useTableSearch({ columns, rows: data, - mode: searchMode, + // Tree filtering needs to preserve ancestors and descendants, so the flat + // hook owns only the controlled/debounced value in this mode. + mode: treeModel ? 'server' : searchMode, value: searchValueProp, defaultValue: defaultSearchValue, onChange: onSearchChange, @@ -199,6 +278,19 @@ function ItemTable( filter: searchFilter, }); + const searchedTree = useMemo(() => { + if (!treeModel) return null; + if (searchMode !== 'client' || !query) { + return { roots: treeModel.roots, forcedExpandedKeys: new Set() }; + } + + return filterTableTree(treeModel.roots, (node) => + searchFilter + ? searchFilter(node.row, query) + : matchesTableSearch(columns, node.row, node.sourceIndex, query), + ); + }, [treeModel, searchMode, query, searchFilter, columns]); + const storage = useTableStorage(storageKey, persist); const { @@ -209,8 +301,10 @@ function ItemTable( mode: resolvedSortMode, } = useTableSort({ columns, - rows: searchedRows, - mode: sortMode, + rows: treeModel + ? searchedTree?.roots.map((node) => node.row) ?? [] + : searchedRows, + mode: treeModel ? 'server' : sortMode, sort: sortProp, // Only restore what the table owns: a controlled `sort` belongs to the page, // and overriding it here would fight the page's own source of truth. @@ -224,6 +318,30 @@ function ItemTable( }), }); + const collator = useCollator({ numeric: true, sensitivity: 'base' }); + const treeSortMode = treeModel + ? sortMode ?? + (columns.some((column) => column.isSortable) ? 'client' : 'off') + : resolvedSortMode; + const processedTreeRoots = useMemo(() => { + const roots = searchedTree?.roots ?? []; + if (treeSortMode !== 'client' || !sort) return roots; + const column = columns.find((entry) => entry.key === sort.columnKey); + if (!column) return roots; + + return sortTableTree(roots, (a, b) => { + const result = compareByColumn( + column, + collator, + a.row, + a.sourceIndex, + b.row, + b.sourceIndex, + ); + return result * (sort.direction === 'asc' ? 1 : -1); + }); + }, [searchedTree, treeSortMode, sort, columns, collator]); + // Search → sort → paginate. Paging last, so a page always reflects the rows // the user is actually looking at. // @@ -247,7 +365,11 @@ function ItemTable( // Infinite scroll replaces the page control rather than adding to it. const isPaginated = paginationMode !== 'off' && !isInfinite; const isServerPaginated = paginationMode === 'server'; - const total = isServerPaginated ? totalProp ?? 0 : sortedRows.length; + const total = isServerPaginated + ? totalProp ?? 0 + : treeModel + ? processedTreeRoots.length + : sortedRows.length; const pageInfo = getPageInfo({ page, @@ -269,7 +391,7 @@ function ItemTable( setPageState(1); }); - const visibleRows = + const flatVisibleRows = paginationMode === 'client' ? sortedRows.slice( (pageInfo.page - 1) * pageSize, @@ -277,11 +399,50 @@ function ItemTable( ) : sortedRows; - const resolvedGetRowKey = useMemo( - () => getRowKey ?? defaultGetRowKey(rowKey), - [getRowKey, rowKey], + const pageTreeRoots = useMemo( + () => + reindexTableTree( + paginationMode === 'client' + ? processedTreeRoots.slice( + (pageInfo.page - 1) * pageSize, + pageInfo.page * pageSize, + ) + : processedTreeRoots, + ), + [processedTreeRoots, paginationMode, pageInfo.page, pageSize], ); + const treeState = useTableTreeState({ + roots: pageTreeRoots, + allNodesByKey: treeModel?.byKey ?? new Map(), + expandedKeys, + defaultExpandedKeys, + forcedExpandedKeys: searchedTree?.forcedExpandedKeys, + disabledKeys, + getTextValue: (node) => { + const column = columns.find( + (entry) => entry.key === resolvedTreeColumnKey, + ); + return column + ? getColumnText(column, node.row, node.sourceIndex) ?? String(node.key) + : String(node.key); + }, + onExpand, + ariaLabel, + }); + + const visibleTreeEntries = treeModel ? treeState.visibleEntries : []; + const visibleRows = treeModel + ? visibleTreeEntries.map((entry) => entry.row) + : flatVisibleRows; + const visibleRowKeys = treeModel + ? visibleTreeEntries.map((entry) => entry.key) + : undefined; + const pageTreeEntries = treeModel ? flattenTableTree(pageTreeRoots) : []; + const filteredTreeEntries = treeModel + ? flattenTableTree(processedTreeRoots) + : []; + // A bulk action with no way to select rows is a contradiction, so supplying // any implies multiple selection unless the consumer says otherwise. const resolvedSelectionMode = @@ -289,10 +450,30 @@ function ItemTable( const selection = useTableSelection({ rows: visibleRows, + rowKeys: visibleRowKeys, + pageRows: treeModel + ? pageTreeEntries.map((entry) => entry.row) + : visibleRows, + pageRowKeys: treeModel + ? pageTreeEntries.map((entry) => entry.key) + : undefined, // The wider set the header checkbox can reach under // `selectAllMode="filtered"`. In server mode the client only ever holds one // page, so the two coincide. - filteredRows: paginationMode === 'client' ? sortedRows : visibleRows, + filteredRows: treeModel + ? (paginationMode === 'client' + ? filteredTreeEntries + : pageTreeEntries + ).map((entry) => entry.row) + : paginationMode === 'client' + ? sortedRows + : visibleRows, + filteredRowKeys: treeModel + ? (paginationMode === 'client' + ? filteredTreeEntries + : pageTreeEntries + ).map((entry) => entry.key) + : undefined, getRowKey: resolvedGetRowKey, selectionMode: resolvedSelectionMode, selectedKeys: selectedKeysProp, @@ -301,6 +482,14 @@ function ItemTable( selectAllMode, isRowSelectable, disabledKeys, + tree: treeModel + ? { + rootKeys: treeModel.roots.map((node) => node.key), + childrenOf: treeModel.childrenOf, + parentOf: treeModel.parentOf, + behavior: treeSelectionBehavior, + } + : undefined, }); // A `ReactNode` menu applies to every row; a function decides per row. The @@ -523,8 +712,10 @@ function ItemTable( const rowKeys = useMemo( () => - visibleRows.map((row, index) => String(resolvedGetRowKey(row, index))), - [visibleRows, resolvedGetRowKey], + visibleRows.map((row, index) => + String(visibleRowKeys?.[index] ?? resolvedGetRowKey(row, index)), + ), + [visibleRows, visibleRowKeys, resolvedGetRowKey], ); const handleReorder = useEvent((nextKeys: string[]) => { @@ -565,13 +756,16 @@ function ItemTable( bodyRef={bodyRef} qa={qa || 'ItemTable'} rows={visibleRows} + rowKeys={visibleRowKeys} // `aria-rowindex` is document-absolute by contract: on page 3 a screen // reader should hear "row 51 of 240", not "row 1 of 25". Infinite scroll // never offsets — every loaded row is already in `visibleRows`. // `isPaginated` already excludes infinite scroll, where every loaded row // is in `visibleRows` and there is no page to offset by. - rowIndexOffset={isPaginated ? (pageInfo.page - 1) * pageSize : 0} - totalRowCount={total} + rowIndexOffset={ + treeModel ? 0 : isPaginated ? (pageInfo.page - 1) * pageSize : 0 + } + totalRowCount={treeModel ? visibleRows.length : total} getRowKey={resolvedGetRowKey} layout={layout} onScrollerRef={setScrollerEl} @@ -613,7 +807,7 @@ function ItemTable( toolbar={toolbarNode} footer={footerNode} isFiltered={isFiltered ?? isSearching} - sortMode={resolvedSortMode} + sortMode={treeSortMode} sort={sort} onColumnSort={toggleSort} onColumnSortChange={setColumnSort} @@ -632,6 +826,17 @@ function ItemTable( cellStyles={cellStyles} mods={mods} overlay={bulkBarPlacement === 'floating' ? bulkBar : null} + tree={ + treeModel + ? { + state: treeState.state, + ariaProps: treeState.ariaProps, + nodes: treeState.visibleNodes, + entries: visibleTreeEntries, + columnKey: resolvedTreeColumnKey!, + } + : undefined + } /> ); @@ -639,11 +844,14 @@ function ItemTable( const map = new Map(); visibleRows.forEach((row, index) => - map.set(String(resolvedGetRowKey(row, index)), row), + map.set( + String(visibleRowKeys?.[index] ?? resolvedGetRowKey(row, index)), + row, + ), ); return map; - }, [visibleRows, resolvedGetRowKey]); + }, [visibleRows, visibleRowKeys, resolvedGetRowKey]); const handleItemDrop = useEvent((targetKey: Key, draggedKeys: Key[]) => { if (!dropOnRow) return; @@ -652,7 +860,28 @@ function ItemTable( if (!target) return; - const dragged = draggedKeys + const draggedKeySet = new Set(draggedKeys); + const topmostDraggedKeys = treeModel + ? draggedKeys.filter((key) => { + let parent = treeModel.parentOf.get(key); + while (parent != null) { + if (draggedKeySet.has(parent)) return false; + parent = treeModel.parentOf.get(parent); + } + return true; + }) + : draggedKeys; + + if ( + treeModel && + topmostDraggedKeys.some((key) => + isTableTreeDescendant(treeModel, targetKey, key), + ) + ) { + return; + } + + const dragged = topmostDraggedKeys .map((key) => rowByKeyForDrop.get(String(key))) // A row cannot be dropped on itself. .filter((row): row is T => row !== undefined && row !== target); @@ -679,7 +908,7 @@ function ItemTable( }); // Dropping onto a row and reordering both need the drag machinery. - const isDragEnabled = isReorderable || dropOnRow != null; + const isDragEnabled = (!treeModel && isReorderable) || dropOnRow != null; const table = isDragEnabled ? ( ( listRef={bodyRef} orderedKeys={rowKeys} orientation="vertical" - onReorder={isReorderable ? handleReorder : undefined} + onReorder={!treeModel && isReorderable ? handleReorder : undefined} onItemDrop={dropOnRow ? handleItemDrop : undefined} shouldAcceptItemDrop={dropOnRow ? shouldAcceptItemDrop : undefined} renderPreview={getItemDragInfo ? renderDragPreview : undefined} diff --git a/src/components/data/ItemTable/types.ts b/src/components/data/ItemTable/types.ts index 3ad5b70c1..701238c07 100644 --- a/src/components/data/ItemTable/types.ts +++ b/src/components/data/ItemTable/types.ts @@ -13,6 +13,7 @@ import type { CubeTableSelectAllMode, CubeTableSelectionMode, CubeTableSort, + CubeTableTreeProps, } from '../TableBase/types'; import type { CubeTablePersistKey } from '../TableBase/use-table-storage'; @@ -20,7 +21,8 @@ export type CubeItemTableColumn = CubeTableColumn; export interface CubeItemTableProps extends BaseProps, - ContainerStyleProps { + ContainerStyleProps, + CubeTableTreeProps { /* ── data ─────────────────────────────────────────────────────────── */ data: readonly T[]; columns: CubeItemTableColumn[]; @@ -198,6 +200,11 @@ export interface CubeItemTableProps selectionTooltip?: string | ((row: T) => string | undefined); /** Rows that cannot be interacted with at all. */ disabledKeys?: Key[]; + /** + * Multiple-selection behavior in tree mode. `cascade` selects eligible + * descendants and derives indeterminate ancestors. @default 'cascade' + */ + treeSelectionBehavior?: 'cascade' | 'independent'; /* ── bulk actions ─────────────────────────────────────────────────── */ /** diff --git a/src/components/data/TableBase/TableRow.tsx b/src/components/data/TableBase/TableRow.tsx index 39f525098..14b213ae4 100644 --- a/src/components/data/TableBase/TableRow.tsx +++ b/src/components/data/TableBase/TableRow.tsx @@ -1,40 +1,60 @@ import { useRef } from 'react'; -import { useDraggableItem, useDropIndicator } from 'react-aria'; +import { useDraggableItem, useDropIndicator, useTreeItem } from 'react-aria'; -import { mergeProps } from '../../../utils/react'; +import { mergeProps, mergeRefs } from '../../../utils/react'; -import type { Key } from '@react-types/shared'; +import type { Key, Node } from '@react-types/shared'; import type { ReactNode, Ref } from 'react'; import type { DraggableCollectionState, DroppableCollectionState, + TreeState, } from 'react-stately'; +import type { TableTreeNode } from './table-tree'; -export interface TableRowProps { +export interface TableRowTreeProps { + state: TreeState>; + node: Node>; + entry: TableTreeNode; + isVirtualized: boolean; +} + +export interface TableRowProps { rowKey: Key; /** Everything the renderer already computed: mods, ARIA, handlers. */ rowProps: Record; height?: number; /** Set on the virtualized path so the virtualizer can measure the row. */ measureRef?: Ref; + /** Internal React Aria row ref in tree mode. */ + rowRef?: Ref; index?: number; dragState?: DraggableCollectionState; dropState?: DroppableCollectionState; - children: ReactNode; + tree?: TableRowTreeProps; + children: + | ReactNode + | ((treeItemAria: ReturnType | undefined) => ReactNode); } -function Row(props: TableRowProps & { extra?: Record }) { - const { rowProps, height, measureRef, index, extra, children } = props; +function Row( + props: TableRowProps & { + extra?: Record; + rowRef?: Ref; + }, +) { + const { rowProps, height, measureRef, rowRef, index, extra, children } = + props; return ( - {children} + {typeof children === 'function' ? children(undefined) : children} ); } @@ -81,7 +101,49 @@ function DraggableRow(props: TableRowProps) { ); } -export function TableRow(props: TableRowProps) { +function TreeRow(props: TableRowProps) { + const { tree } = props; + const rowRef = useRef(null); + const treeItemAria = useTreeItem( + { + node: tree!.node, + hasChildItems: tree!.entry.children.length > 0, + isVirtualized: tree!.isVirtualized, + }, + tree!.state, + rowRef, + ); + + const rowProps = mergeProps(treeItemAria.rowProps, props.rowProps); + + // Table geometry and ItemTable selection are separate from React Aria's + // internal focus-only selection manager, so the renderer's values win. + if (props.rowProps['aria-rowindex'] !== undefined) { + rowProps['aria-rowindex'] = props.rowProps['aria-rowindex']; + } + if (props.rowProps['aria-selected'] !== undefined) { + rowProps['aria-selected'] = props.rowProps['aria-selected']; + } + rowProps['aria-level'] = tree!.entry.level + 1; + rowProps['aria-posinset'] = tree!.entry.siblingIndex + 1; + rowProps['aria-setsize'] = tree!.entry.siblingCount; + + const children = + typeof props.children === 'function' + ? props.children(treeItemAria) + : props.children; + const next = { ...props, rowProps, rowRef, children }; + + return props.dragState && props.dropState ? ( + + ) : ( + + ); +} + +export function TableRow(props: TableRowProps) { + if (props.tree) return ; + return props.dragState && props.dropState ? ( ) : ( diff --git a/src/components/data/TableBase/TableView.tsx b/src/components/data/TableBase/TableView.tsx index 346a940f5..b147ee723 100644 --- a/src/components/data/TableBase/TableView.tsx +++ b/src/components/data/TableBase/TableView.tsx @@ -7,18 +7,20 @@ import { useRef, useState, } from 'react'; -import { VisuallyHidden } from 'react-aria'; +import { useTree, useTreeItem, VisuallyHidden } from 'react-aria'; import { useEvent } from '../../../_internal/hooks'; import { useI18n } from '../../../i18n'; import { ArrowNarrowDownIcon, ArrowNarrowUpIcon, + DirectionIcon, MoreIcon, } from '../../../icons'; import { getColorTheme, useColorTheme } from '../../../tokens/color-theme'; import { usePaletteVersion } from '../../../tokens/palette-config'; import { SIZE_NAME_TO_KEY, SIZES } from '../../../tokens/sizes'; +import { mergeProps } from '../../../utils/react'; import { Action } from '../../actions/Action/Action'; import { ItemAction } from '../../actions/ItemAction/ItemAction'; import { Menu, MenuTrigger } from '../../actions/Menu'; @@ -42,7 +44,12 @@ import { normalizeMenuAction, ROW_MENU_COLUMN_KEY, } from './row-menu'; -import { TableElement, TableHeaderItem } from './styled'; +import { + TableElement, + TableHeaderItem, + TableTreeToggle, + TableTreeTogglePlaceholder, +} from './styled'; import { TableHeaderCell } from './TableHeaderCell'; import { TableRow, TableRowDropIndicator } from './TableRow'; import { selectionRowKey } from './types'; @@ -56,7 +63,7 @@ import { SELECTION_COLUMN_KEY, } from './use-table-selection'; -import type { Key } from '@react-types/shared'; +import type { Key, Node } from '@react-types/shared'; import type { Styles } from '@tenphi/tasty'; import type { CSSProperties, @@ -66,9 +73,11 @@ import type { PointerEvent as ReactPointerEvent, RefObject, } from 'react'; +import type { TreeState } from 'react-stately'; import type { NavigateArg } from '../../../providers/navigation.types'; import type { ColorThemeConfig } from '../../../tokens/color-theme'; import type { CubeColumnMenuContext } from './column-menu'; +import type { TableTreeNode } from './table-tree'; import type { CubeResolvedColumn, CubeTableCellContext, @@ -97,6 +106,8 @@ export interface CubeTableRowRenderProps { export interface TableViewProps { qa?: string; rows: readonly T[]; + /** Stable keys for `rows`; required when a tree was flattened for display. */ + rowKeys?: readonly Key[]; getRowKey: (row: T, index: number) => Key; layout: CubeTableColumnLayout; /** @@ -210,6 +221,14 @@ export interface TableViewProps { */ rowIndexOffset?: number; totalRowCount?: number; + /** React Aria treegrid state and the model entries matching `rows`. */ + tree?: { + state: TreeState>; + ariaProps: Record; + nodes: Node>[]; + entries: TableTreeNode[]; + columnKey: string; + }; isReorderable?: boolean; /** Provided by `DraggableCollection` when reordering is on. */ dragState?: any; @@ -397,6 +416,97 @@ function pinStyle(column: CubeResolvedColumn): CSSProperties | undefined { return { ['--pin-offset' as any]: `${column.pinOffset}px` }; } +/** + * Hook boundary for the native table. Flat tables deliberately avoid this + * component so their DOM and keyboard behaviour remain exactly as before. + */ +function TreeGridTable(props: { + tree: NonNullable['tree']>; + tableProps: Record; + style?: CSSProperties; + children: ReactNode; +}) { + const { tree, tableProps, style, children } = props; + const ref = useRef(null); + const { gridProps } = useTree(tree.ariaProps as any, tree.state, ref); + const handleKeyDownCapture = ( + event: ReactKeyboardEvent, + ) => { + const row = (event.target as HTMLElement).closest( + 'tbody tr[data-element="Row"][data-key]', + ); + + // Inputs, links and menus embedded in a row retain their own shortcuts. + if (!row || event.target !== row) { + (gridProps as any).onKeyDownCapture?.(event); + return; + } + + const index = tree.entries.findIndex( + (entry) => String(entry.key) === row.dataset.key, + ); + const entry = tree.entries[index]; + if (!entry) return; + + const focusEntry = (next: TableTreeNode | undefined) => { + if (!next) return false; + tree.state.selectionManager.setFocusedKey(next.key); + const target = Array.from( + ref.current?.querySelectorAll( + 'tbody tr[data-element="Row"][data-key]', + ) ?? [], + ).find((element) => element.dataset.key === String(next.key)); + target?.focus(); + return true; + }; + + const hasChildren = entry.children.length > 0; + const isExpanded = tree.state.expandedKeys.has(entry.key); + let handled = false; + + if (event.key === 'ArrowRight' && hasChildren) { + if (isExpanded) handled = focusEntry(entry.children[0]); + else { + tree.state.toggleKey(entry.key); + handled = true; + } + } else if (event.key === 'ArrowLeft') { + if (hasChildren && isExpanded) { + tree.state.toggleKey(entry.key); + handled = true; + } else if (entry.parentKey != null) { + handled = focusEntry( + tree.entries.find((candidate) => candidate.key === entry.parentKey), + ); + } + } else if (event.key === 'ArrowDown') { + handled = focusEntry(tree.entries[index + 1]); + } else if (event.key === 'ArrowUp') { + handled = focusEntry(tree.entries[index - 1]); + } + + if (handled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + (gridProps as any).onKeyDownCapture?.(event); + }; + + return ( +
+ {children} +
+ ); +} + /** * The one place table DOM exists. Both `ItemTable` and `DataTable` resolve their * props into the shape above and hand it here, so the markup, the ARIA @@ -406,6 +516,7 @@ export function TableView(props: TableViewProps) { const { qa, rows, + rowKeys, getRowKey, layout, onScrollerRef, @@ -445,6 +556,7 @@ export function TableView(props: TableViewProps) { rowNumberOffset = 0, rowIndexOffset = 0, totalRowCount, + tree, isReorderable = false, dragState, dropState, @@ -570,6 +682,20 @@ export function TableView(props: TableViewProps) { /** 1-based `aria-rowindex` for the i-th body row of the current page. */ const bodyRowIndex = (rowIndex: number) => headerRowCount + pinnedTopCount + rowIndexOffset + rowIndex + 1; + const bodyRowKey = (row: T, rowIndex: number) => + rowKeys?.[rowIndex] ?? getRowKey(row, rowIndex); + const treeRowState = (rowIndex: number) => { + const entry = tree?.entries[rowIndex]; + + return entry + ? { + level: entry.level, + parentKey: entry.parentKey, + hasChildren: entry.children.length > 0, + isExpanded: tree.state.expandedKeys.has(entry.key), + } + : undefined; + }; /** 1-based `aria-rowindex` for the i-th pinned row at `edge`. */ const pinnedRowIndex = (index: number, edge: 'top' | 'bottom') => edge === 'top' @@ -883,6 +1009,7 @@ export function TableView(props: TableViewProps) { selection!.toggleRow(rowKey)} /> @@ -1268,6 +1395,7 @@ export function TableView(props: TableViewProps) { // borders and the fill, and a sub-element cannot ask about its DOM parent's // mods — state keys inside `Cell` resolve against the table root. pinnedEdge?: 'top' | 'bottom', + treeItemAria?: ReturnType, ) { // Section-qualified, because the same record is routinely pinned at both // edges. `rowKey` below stays the consumer's own — this identity is the @@ -1337,6 +1465,7 @@ export function TableView(props: TableViewProps) { // Cell-range membership is a different question and has its own answer in // `data-cell-selected`. isSelected: selection?.isSelected(rowKey) ?? false, + tree: pinnedEdge == null ? treeRowState(rowIndex) : undefined, }; const value = column.isStructural @@ -1375,6 +1504,43 @@ export function TableView(props: TableViewProps) { (rendered as ReactNode) ); + const treeEntry = pinnedEdge == null ? tree?.entries[rowIndex] : undefined; + const displayContent = + treeEntry && column.key === tree?.columnKey ? ( +
+ {treeEntry.children.length > 0 ? ( + + + + ) : ( +
+ ) : ( + content + ); + const resolvedCellStyles = typeof column.cellStyles === 'function' ? column.cellStyles(ctx) @@ -1422,7 +1588,7 @@ export function TableView(props: TableViewProps) { }} {...column.cellProps?.(ctx)} > - {content} + {displayContent} {link !== undefined ? ( (props: TableViewProps) { } /** The cells of one body row. Shared by both render paths. */ - function renderRowCells(row: T, rowIndex: number) { - const rowKey = getRowKey(row, rowIndex); + function renderRowCells( + row: T, + rowIndex: number, + treeItemAria?: ReturnType, + ) { + const rowKey = bodyRowKey(row, rowIndex); return columns.map((column) => - renderCell(column, row, rowIndex, rowKey, rowIndex === rows.length - 1), + renderCell( + column, + row, + rowIndex, + rowKey, + rowIndex === rows.length - 1, + undefined, + treeItemAria, + ), ); } @@ -1479,7 +1657,7 @@ export function TableView(props: TableViewProps) { * props rather than the element. */ function getRowElementProps(row: T, rowIndex: number) { - const rowKey = getRowKey(row, rowIndex); + const rowKey = bodyRowKey(row, rowIndex); const isSelected = selection?.isSelected(rowKey) ?? false; const ctx: CubeTableRowContext = { row, @@ -1487,6 +1665,7 @@ export function TableView(props: TableViewProps) { rowIndex, section: 'body', isSelected, + tree: treeRowState(rowIndex), }; const extra = getRowProps?.(ctx); @@ -1504,11 +1683,12 @@ export function TableView(props: TableViewProps) { role: 'row', // Only the resting row takes a tab stop; the rest are reached with the // arrow keys. Off entirely when nothing on the row is operable. - tabIndex: isReorderable - ? rowIndex === focusedRowIndex - ? 0 - : -1 - : undefined, + tabIndex: + !tree && isReorderable + ? rowIndex === focusedRowIndex + ? 0 + : -1 + : undefined, // Only meaningful in a selectable grid; announcing "not selected" on // every row of a plain table is noise. 'aria-selected': selection?.isEnabled ? isSelected : undefined, @@ -1549,7 +1729,9 @@ export function TableView(props: TableViewProps) { function renderRow(row: T, rowIndex: number, isVirtual = false) { const { height, ...rowProps } = getRowElementProps(row, rowIndex); - const rowKey = getRowKey(row, rowIndex); + const rowKey = bodyRowKey(row, rowIndex); + const treeNode = tree?.nodes[rowIndex]; + const treeEntry = tree?.entries[rowIndex]; return ( (props: TableViewProps) { measureRef={isVirtual ? virtualizer.measureElement : undefined} dragState={isReorderable ? dragState : undefined} dropState={isReorderable ? dropState : undefined} + tree={ + treeNode && treeEntry + ? { + state: tree.state, + node: treeNode, + entry: treeEntry, + isVirtualized: isVirtual, + } + : undefined + } > - {renderRowCells(row, rowIndex)} + {(treeItemAria) => renderRowCells(row, rowIndex, treeItemAria)} ); } @@ -1585,7 +1777,7 @@ export function TableView(props: TableViewProps) { * is merely *capable* of dragging keeps its ordinary structure. */ function withDropIndicators(row: T, index: number, isVirtual = false) { - const rowKey = getRowKey(row, index); + const rowKey = bodyRowKey(row, index); const rowNode = renderRow(row, index, isVirtual); if (!isReorderable || !dropState) return rowNode; @@ -1644,7 +1836,8 @@ export function TableView(props: TableViewProps) { const virtualizer = useVirtualizer({ count: shouldVirtualize ? rows.length : 0, getScrollElement: () => scrollerRef.current, - getItemKey: (index) => String(getRowKey(rowsRef.current[index], index)), + getItemKey: (index) => + String(rowKeys?.[index] ?? getRowKey(rowsRef.current[index], index)), estimateSize: () => (rowHeight ?? (rowSizeKey @@ -1662,6 +1855,16 @@ export function TableView(props: TableViewProps) { // first exists, so the virtualizer re-reads `getScrollElement` and attaches. void scrollerEl; + const focusedTreeKey = tree?.state.selectionManager.focusedKey; + useEffect(() => { + if (!shouldVirtualize || focusedTreeKey == null || !tree) return; + + const index = tree.entries.findIndex( + (entry) => entry.key === focusedTreeKey, + ); + if (index >= 0) virtualizer.scrollToIndex(index, { align: 'auto' }); + }, [focusedTreeKey, shouldVirtualize, tree, virtualizer]); + const scrollability = useScrollability(scrollerEl); /** @@ -1994,7 +2197,9 @@ export function TableView(props: TableViewProps) { const row = rows[index]; - return row === undefined ? null : { row, index }; + return row === undefined + ? null + : { row, index, key: bodyRowKey(row, index) }; } const virtualItems = shouldVirtualize ? virtualizer.getVirtualItems() : []; @@ -2040,7 +2245,7 @@ export function TableView(props: TableViewProps) { const tableProps = { 'data-element': 'Table', - role: 'grid', + role: tree ? 'treegrid' : 'grid', 'aria-label': ariaLabel, 'aria-busy': isLoading || undefined, 'aria-rowcount': @@ -2074,6 +2279,33 @@ export function TableView(props: TableViewProps) { bodyContent = rows.map((row, index) => withDropIndicators(row, index)); } + const tableContent = ( + <> + {colGroup} + {isHeaderHidden ? null : {headerRow}} + + {pinnedTopRows?.map((row, index) => renderPinnedRow(row, index, 'top'))} + {bodyContent} + {pinnedBottomRows?.map((row, index) => + renderPinnedRow(row, index, 'bottom'), + )} + {isLoadingMore + ? renderSkeletonRows(loadMoreSkeletonCount, 'load-more') + : null} + {onLoadMore && hasRows ? ( + + +
+ + + ) : null} + + + ); + // Before the first measure there are no columns to lay out yet — show the // generic table skeleton rather than an empty frame. // @@ -2137,12 +2369,7 @@ export function TableView(props: TableViewProps) { if (found) { event.stopPropagation(); - openContextMenuFor( - found.row, - found.index, - getRowKey(found.row, found.index), - event, - ); + openContextMenuFor(found.row, found.index, found.key, event); } return; @@ -2158,13 +2385,14 @@ export function TableView(props: TableViewProps) { 'a, button, input, select, textarea, [role="button"]', ) ) { - onRowAction(found.row, getRowKey(found.row, found.index)); + onRowAction(found.row, found.key); } return; } if ( + !tree && isReorderable && (event.key === 'ArrowDown' || event.key === 'ArrowUp') ) { @@ -2234,56 +2462,30 @@ export function TableView(props: TableViewProps) { }); }} > - - {colGroup} - {isHeaderHidden ? null : ( - {headerRow} - )} - - {pinnedTopRows?.map((row, index) => - renderPinnedRow(row, index, 'top'), - )} - {bodyContent} - {pinnedBottomRows?.map((row, index) => - renderPinnedRow(row, index, 'bottom'), - )} - {/* - Skeleton rows rather than a spinner: they keep the row grid, say - "more rows of this shape are coming", and grow the scroll height - smoothly instead of a block that appears and vanishes. It also - matches what the first load already shows. - - Sized to the batch that is coming (see `loadMoreSkeletonCount`), - not to a fixed few: the point is that the list keeps its length - through the load, so the user can carry on scrolling into it and - nothing lurches when the rows arrive. - */} - {isLoadingMore - ? renderSkeletonRows(loadMoreSkeletonCount, 'load-more') - : null} - {onLoadMore && hasRows ? ( - - ` or ` - - ) : null} - -
` - itself: an `IntersectionObserver` given a zero-area table - part produced no entries at all — not even the initial one - every observer is supposed to emit on `observe`. - */} -
-
+ {tree ? ( + + {tableContent} + + ) : ( + + {tableContent} +
+ )}
{/* A refresh is shown by sweeping the table itself, not by parking a diff --git a/src/components/data/TableBase/index.ts b/src/components/data/TableBase/index.ts index 17e4dac33..d87472901 100644 --- a/src/components/data/TableBase/index.ts +++ b/src/components/data/TableBase/index.ts @@ -31,4 +31,7 @@ export type { CubeTableRowSize, CubeTableSort, CubeTableSortDirection, + CubeTableTreeProps, + CubeTableTreeRowState, + CubeTableRowExpandInfo, } from './types'; diff --git a/src/components/data/TableBase/styled.ts b/src/components/data/TableBase/styled.ts index 369da6f52..978b3794e 100644 --- a/src/components/data/TableBase/styled.ts +++ b/src/components/data/TableBase/styled.ts @@ -1,5 +1,6 @@ import { keyframes, tasty } from '@tenphi/tasty'; +import { Action } from '../../actions/Action/Action'; import { Item } from '../../content/Item'; import type { Styles } from '@tenphi/tasty'; @@ -624,6 +625,37 @@ export const TableElement = tasty({ height: '100%', }, + /** + * Hierarchy chrome inside the consumer-selected tree column. The wrapper + * owns indentation so custom renderers, links and text truncation all move + * together as one row label. + */ + TreeContent: { + $: '> Scroller > Table > Body > Row > Cell >', + display: 'flex', + alignItems: 'center', + gap: '.5x', + minWidth: 0, + padding: 'left ($tree-indent * 2x)', + '$tree-indent': '($tree-level, 0)', + }, + + TreeValue: { + $: '> Scroller > Table > Body > Row > Cell > TreeContent >', + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + minWidth: 0, + overflow: 'hidden', + }, + + TreeToggle: { + $: '> Scroller > Table > Body > Row > Cell > TreeContent >', + flexGrow: 0, + flexShrink: 0, + flexBasis: 'auto', + }, + SelectionBox: { $: '> Scroller > Table', display: 'flex', @@ -921,6 +953,34 @@ export const TableElement = tasty({ }, }); +const TREE_TOGGLE_STYLES: Styles = { + display: 'grid', + placeItems: 'center', + placeContent: 'center', + flexGrow: 0, + flexShrink: 0, + flexBasis: 'auto', + width: '3x', + height: '3x', + radius: true, + transition: 'theme', +}; + +/** Disclosure control rendered in the configured tree column. */ +export const TableTreeToggle = tasty(Action, { + qa: 'TableTreeToggle', + styles: { + ...TREE_TOGGLE_STYLES, + color: { '': '#dark-02', ':hover': '#dark' }, + fill: { '': '#clear', ':hover': '#dark.04' }, + }, +}); + +/** Leaf placeholder matching the disclosure control's footprint. */ +export const TableTreeTogglePlaceholder = tasty({ + styles: TREE_TOGGLE_STYLES, +}); + /** * The header cell's content. `Item` lives INSIDE the ``, never as the `` * itself — it emits `aria-selected` unconditionally (`Item.tsx:964`), which on a diff --git a/src/components/data/TableBase/table-tree.test.ts b/src/components/data/TableBase/table-tree.test.ts new file mode 100644 index 000000000..6c104b8dc --- /dev/null +++ b/src/components/data/TableBase/table-tree.test.ts @@ -0,0 +1,116 @@ +import { + buildTableTree, + filterTableTree, + flattenTableTree, + isTableTreeDescendant, + reindexTableTree, + sortTableTree, +} from './table-tree'; + +interface Row { + id: string; + name: string; + children?: Row[]; +} + +const DATA: Row[] = [ + { + id: 'b', + name: 'Beta', + children: [ + { + id: 'b2', + name: 'Beta two', + children: [{ id: 'b21', name: 'Needle' }], + }, + { id: 'b1', name: 'Beta one' }, + ], + }, + { id: 'a', name: 'Alpha', children: [{ id: 'a1', name: 'Alpha one' }] }, +]; + +const build = (rows: readonly Row[] = DATA) => + buildTableTree( + rows, + (row) => row.children, + (row) => row.id, + ); +const keys = (nodes: ReturnType['roots']) => + flattenTableTree(nodes).map((node) => node.key); + +describe('table tree model', () => { + it('indexes deep nesting without cloning or mutating rows', () => { + const model = build(); + + expect(keys(model.roots)).toEqual(['b', 'b2', 'b21', 'b1', 'a', 'a1']); + expect(model.byKey.get('b21')).toMatchObject({ + row: DATA[0].children![0].children![0], + parentKey: 'b2', + level: 2, + sourceIndex: 2, + }); + expect(model.parentOf.get('a1')).toBe('a'); + expect(model.childrenOf.get('b')).toEqual(['b2', 'b1']); + }); + + it('reports duplicate and cyclic keys and ignores the repeated branch', () => { + const cycle: Row = { id: 'cycle', name: 'Cycle' }; + cycle.children = [cycle]; + const model = build([ + cycle, + { id: 'duplicate', name: 'First' }, + { id: 'duplicate', name: 'Second' }, + ]); + + expect(model.cyclicKeys).toEqual(['cycle']); + expect(model.duplicateKeys).toEqual(['duplicate']); + expect(keys(model.roots)).toEqual(['cycle', 'duplicate']); + }); + + it('sorts each sibling collection independently', () => { + const sorted = sortTableTree(build().roots, (a, b) => + a.row.name.localeCompare(b.row.name), + ); + + expect(keys(sorted)).toEqual(['a', 'a1', 'b', 'b1', 'b2', 'b21']); + expect(sorted[1].children.map((node) => node.siblingIndex)).toEqual([0, 1]); + }); + + it('retains ancestor paths and forces search branches open', () => { + const filtered = filterTableTree(build().roots, (node) => + node.row.name.includes('Needle'), + ); + + expect(keys(filtered.roots)).toEqual(['b', 'b2', 'b21']); + expect(filtered.forcedExpandedKeys).toEqual(new Set(['b', 'b2'])); + }); + + it('keeps a matching parent complete', () => { + const filtered = filterTableTree(build().roots, (node) => node.key === 'b'); + + expect(keys(filtered.roots)).toEqual(['b', 'b2', 'b21', 'b1']); + expect(filtered.forcedExpandedKeys).toEqual(new Set(['b', 'b2'])); + }); + + it('flattens only open branches and reindexes a root page', () => { + const model = build(); + + expect(flattenTableTree(model.roots, new Set(['b']))).toMatchObject([ + { key: 'b' }, + { key: 'b2' }, + { key: 'b1' }, + { key: 'a' }, + ]); + expect(reindexTableTree(model.roots.slice(1))).toMatchObject([ + { key: 'a', siblingIndex: 0, siblingCount: 1 }, + ]); + }); + + it('recognizes descendants but not self or ancestors', () => { + const model = build(); + + expect(isTableTreeDescendant(model, 'b21', 'b')).toBe(true); + expect(isTableTreeDescendant(model, 'b', 'b')).toBe(false); + expect(isTableTreeDescendant(model, 'b', 'b21')).toBe(false); + }); +}); diff --git a/src/components/data/TableBase/table-tree.ts b/src/components/data/TableBase/table-tree.ts new file mode 100644 index 000000000..11a86dbd2 --- /dev/null +++ b/src/components/data/TableBase/table-tree.ts @@ -0,0 +1,209 @@ +import type { Key } from '@react-types/shared'; + +export interface TableTreeNode { + key: Key; + row: T; + parentKey: Key | null; + level: number; + siblingIndex: number; + siblingCount: number; + sourceIndex: number; + children: TableTreeNode[]; +} + +export interface TableTreeModel { + roots: TableTreeNode[]; + byKey: Map>; + parentOf: Map; + childrenOf: Map; + duplicateKeys: Key[]; + cyclicKeys: Key[]; +} + +/** Build identity and parent/child indexes without modifying consumer rows. */ +export function buildTableTree( + rows: readonly T[], + getChildren: (row: T) => readonly T[] | undefined, + getKey: (row: T, sourceIndex: number) => Key, +): TableTreeModel { + const byKey = new Map>(); + const parentOf = new Map(); + const childrenOf = new Map(); + const duplicateKeys: Key[] = []; + const cyclicKeys: Key[] = []; + const visiting = new Set(); + const visitingRows = new Set(); + let sourceIndex = 0; + + const visit = ( + input: readonly T[], + parentKey: Key | null, + level: number, + ): TableTreeNode[] => { + const output: TableTreeNode[] = []; + + input.forEach((row, siblingIndex) => { + const currentSourceIndex = sourceIndex++; + const key = getKey(row, currentSourceIndex); + + if (visiting.has(key) || visitingRows.has(row)) { + cyclicKeys.push(key); + return; + } + + if (byKey.has(key)) { + duplicateKeys.push(key); + return; + } + + const node: TableTreeNode = { + key, + row, + parentKey, + level, + siblingIndex, + siblingCount: input.length, + sourceIndex: currentSourceIndex, + children: [], + }; + + byKey.set(key, node); + parentOf.set(key, parentKey); + visiting.add(key); + visitingRows.add(row); + node.children = visit(getChildren(row) ?? [], key, level + 1); + visiting.delete(key); + visitingRows.delete(row); + childrenOf.set( + key, + node.children.map((child) => child.key), + ); + output.push(node); + }); + + return output; + }; + + const roots = visit(rows, null, 0); + + return { + roots, + byKey, + parentOf, + childrenOf, + duplicateKeys, + cyclicKeys, + }; +} + +export function sortTableTree( + nodes: readonly TableTreeNode[], + compare: (a: TableTreeNode, b: TableTreeNode) => number, +): TableTreeNode[] { + const sorted = nodes + .map((node) => ({ + ...node, + children: sortTableTree(node.children, compare), + })) + .sort(compare); + + return sorted.map((node, siblingIndex) => ({ + ...node, + siblingIndex, + siblingCount: sorted.length, + })); +} + +export interface FilteredTableTree { + roots: TableTreeNode[]; + forcedExpandedKeys: Set; +} + +/** + * Keep matching rows, their ancestor paths, and the complete subtree of a row + * that matches itself. Every retained branch is opened while filtering so a + * match can never remain hidden behind a collapsed ancestor. + */ +export function filterTableTree( + nodes: readonly TableTreeNode[], + matches: (node: TableTreeNode) => boolean, +): FilteredTableTree { + const forcedExpandedKeys = new Set(); + + const cloneComplete = (node: TableTreeNode): TableTreeNode => { + const children = node.children.map(cloneComplete); + if (children.length) forcedExpandedKeys.add(node.key); + + return { ...node, children: reindexTableTree(children) }; + }; + + const visit = (node: TableTreeNode): TableTreeNode | null => { + if (matches(node)) return cloneComplete(node); + + const children = node.children + .map(visit) + .filter((child): child is TableTreeNode => child != null); + + if (!children.length) return null; + + forcedExpandedKeys.add(node.key); + return { ...node, children: reindexTableTree(children) }; + }; + + return { + roots: reindexTableTree( + nodes.map(visit).filter((node): node is TableTreeNode => node != null), + ), + forcedExpandedKeys, + }; +} + +/** Re-number a transformed sibling collection for its rendered treegrid. */ +export function reindexTableTree( + nodes: readonly TableTreeNode[], +): TableTreeNode[] { + return nodes.map((node, siblingIndex) => ({ + ...node, + siblingIndex, + siblingCount: nodes.length, + children: reindexTableTree(node.children), + })); +} + +export function flattenTableTree( + nodes: readonly TableTreeNode[], + expandedKeys?: ReadonlySet, +): TableTreeNode[] { + const output: TableTreeNode[] = []; + + const visit = (items: readonly TableTreeNode[]) => { + for (const node of items) { + output.push(node); + if (expandedKeys == null || expandedKeys.has(node.key)) { + visit(node.children); + } + } + }; + + visit(nodes); + return output; +} + +export function tableTreeDescendantKeys(node: TableTreeNode): Key[] { + return flattenTableTree(node.children).map((child) => child.key); +} + +export function isTableTreeDescendant( + model: Pick, 'parentOf'>, + possibleDescendant: Key, + ancestor: Key, +) { + let parent = model.parentOf.get(possibleDescendant); + + while (parent != null) { + if (parent === ancestor) return true; + parent = model.parentOf.get(parent); + } + + return false; +} diff --git a/src/components/data/TableBase/types.ts b/src/components/data/TableBase/types.ts index 959be2b84..03a8527a9 100644 --- a/src/components/data/TableBase/types.ts +++ b/src/components/data/TableBase/types.ts @@ -51,6 +51,39 @@ export interface CubeTableCellRange { toColumnKey: string; } +export interface CubeTableTreeRowState { + /** Zero-based depth in the hierarchy. */ + level: number; + parentKey: Key | null; + hasChildren: boolean; + isExpanded: boolean; +} + +export interface CubeTableRowExpandInfo { + row: T; + rowKey: Key; + level: number; + parentKey: Key | null; + expanded: boolean; +} + +/** Shared opt-in hierarchy contract used by both table adapters. */ +export interface CubeTableTreeProps { + /** + * Returns a row's already-loaded children. Supplying this enables tree mode + * and makes `data` the top-level row collection. + */ + getRowChildren?: (row: T) => readonly T[] | undefined; + /** Column that owns the indentation and disclosure control. */ + treeColumnKey?: string; + /** Controlled expanded row keys. */ + expandedKeys?: Key[]; + /** Initially expanded row keys. */ + defaultExpandedKeys?: Key[]; + /** Called after a user expands or collapses a row. */ + onExpand?: (keys: Key[], info: CubeTableRowExpandInfo) => void; +} + export interface CubeTableSort { /** `key` of the sorted column. */ columnKey: string; @@ -80,6 +113,8 @@ export interface CubeTableCellContext { */ isRowFocused?: boolean; isDropTarget?: boolean; + /** Present only when `getRowChildren` enables tree mode. */ + tree?: CubeTableTreeRowState; } export interface CubeTableRowContext { @@ -91,6 +126,8 @@ export interface CubeTableRowContext { /** Not yet wired — always absent. See `CubeTableCellContext` for why. */ isFocused?: boolean; isDropTarget?: boolean; + /** Present only when `getRowChildren` enables tree mode. */ + tree?: CubeTableTreeRowState; } export interface CubeTableHeaderContext { diff --git a/src/components/data/TableBase/use-table-search.ts b/src/components/data/TableBase/use-table-search.ts index cabcc9b08..6eb555adf 100644 --- a/src/components/data/TableBase/use-table-search.ts +++ b/src/components/data/TableBase/use-table-search.ts @@ -46,7 +46,7 @@ export interface UseTableSearchResult { * value. `getColumnText` returns `null` for anything it cannot honestly turn * into text, and those columns are skipped rather than coerced. */ -function defaultMatcher( +export function matchesTableSearch( columns: CubeTableColumn[], row: T, rowIndex: number, @@ -126,7 +126,9 @@ export function useTableSearch({ if (mode !== 'client' || !query) return rows; return rows.filter((row, index) => - filter ? filter(row, query) : defaultMatcher(columns, row, index, query), + filter + ? filter(row, query) + : matchesTableSearch(columns, row, index, query), ); }, [mode, query, rows, columns, filter]); diff --git a/src/components/data/TableBase/use-table-selection.ts b/src/components/data/TableBase/use-table-selection.ts index bc61a84ec..7d44e7147 100644 --- a/src/components/data/TableBase/use-table-selection.ts +++ b/src/components/data/TableBase/use-table-selection.ts @@ -31,12 +31,20 @@ export const SELECTION_COLUMN_WIDTH: Record = { export interface UseTableSelectionOptions { /** Rows currently on screen — one page, or everything when unpaginated. */ rows: readonly T[]; + /** Stable keys parallel to `rows`, used by tree mode. */ + rowKeys?: readonly Key[]; + /** Full current-page subtree; defaults to `rows`. */ + pageRows?: readonly T[]; + /** Stable keys parallel to `pageRows`. */ + pageRowKeys?: readonly Key[]; /** * Every row passing the current search/filter, across pages. Only differs * from `rows` under client pagination, and only `selectAllMode="filtered"` * reads it. */ filteredRows: readonly T[]; + /** Stable keys parallel to `filteredRows`. */ + filteredRowKeys?: readonly Key[]; getRowKey: (row: T, index: number) => Key; selectionMode: CubeTableSelectionMode; selectedKeys?: Key[] | 'all'; @@ -45,6 +53,12 @@ export interface UseTableSelectionOptions { selectAllMode: CubeTableSelectAllMode; isRowSelectable?: (row: T) => boolean; disabledKeys?: Key[]; + tree?: { + rootKeys: readonly Key[]; + childrenOf: ReadonlyMap; + parentOf: ReadonlyMap; + behavior: 'cascade' | 'independent'; + }; } export type CubeTableSelectAllState = 'none' | 'some' | 'all'; @@ -55,10 +69,74 @@ function toSelection(keys: Key[] | 'all' | undefined): Selection | undefined { return keys === 'all' ? 'all' : new Set(keys); } +function deriveTreeSelection( + source: ReadonlySet, + rootKeys: readonly Key[], + childrenOf: ReadonlyMap, + isEligible: (key: Key) => boolean, +) { + const checked = new Set(source); + const indeterminate = new Set(); + + const visit = ( + key: Key, + inheritedSelection = false, + ): { all: boolean; any: boolean; eligible: boolean } => { + const children = childrenOf.get(key) ?? []; + const eligible = isEligible(key); + const selectsBranch = inheritedSelection || (eligible && source.has(key)); + + if (eligible && selectsBranch) checked.add(key); + + if (!children.length) { + const selected = eligible && checked.has(key); + if (!eligible) checked.delete(key); + return { all: !eligible || selected, any: selected, eligible }; + } + + let all = true; + let any = false; + let hasEligible = false; + + for (const child of children) { + const result = visit(child, selectsBranch); + if (result.eligible) hasEligible = true; + if (!result.all) all = false; + if (result.any) any = true; + } + + if (!eligible) { + checked.delete(key); + indeterminate.delete(key); + } else if (hasEligible && all) { + checked.add(key); + indeterminate.delete(key); + } else if (hasEligible && any) { + checked.delete(key); + indeterminate.add(key); + } else if (!checked.has(key)) { + indeterminate.delete(key); + } + + return { + all: !eligible || (checked.has(key) && all), + any: any || checked.has(key), + eligible: eligible || hasEligible, + }; + }; + + rootKeys.forEach((key) => visit(key)); + return { checked, indeterminate }; +} + export function useTableSelection(options: UseTableSelectionOptions) { const { rows, + rowKeys, + pageRows = rows, + pageRowKeys, filteredRows, + filteredRowKeys, getRowKey, selectionMode, selectedKeys: selectedKeysProp, @@ -67,6 +145,7 @@ export function useTableSelection(options: UseTableSelectionOptions) { selectAllMode, isRowSelectable, disabledKeys, + tree, } = options; const isEnabled = selectionMode !== 'none'; @@ -95,16 +174,29 @@ export function useTableSelection(options: UseTableSelectionOptions) { // Scanned over the filtered set, not just the page: a select-all that // reaches beyond the page must respect it there too. filteredRows.forEach((row, index) => { - if (!isRowSelectable(row)) keys.add(getRowKey(row, index)); + if (!isRowSelectable(row)) { + keys.add(filteredRowKeys?.[index] ?? getRowKey(row, index)); + } }); } return keys; - }, [hardDisabledKeys, isRowSelectable, filteredRows, getRowKey]); + }, [ + hardDisabledKeys, + isRowSelectable, + filteredRows, + filteredRowKeys, + getRowKey, + ]); + + const visibleGetRowKey = useCallback( + (row: T, index: number) => rowKeys?.[index] ?? getRowKey(row, index), + [rowKeys, getRowKey], + ); const collection = useMemo( - () => new RowCollection(rows, getRowKey, unselectableKeys), - [rows, getRowKey, unselectableKeys], + () => new RowCollection(rows, visibleGetRowKey, unselectableKeys), + [rows, visibleGetRowKey, unselectableKeys], ); const rowByKey = useMemo(() => { @@ -112,11 +204,15 @@ export function useTableSelection(options: UseTableSelectionOptions) { // The page first, then the wider filtered set, so a key present in both // resolves to the row the user can actually see. - filteredRows.forEach((row, index) => map.set(getRowKey(row, index), row)); - rows.forEach((row, index) => map.set(getRowKey(row, index), row)); + filteredRows.forEach((row, index) => + map.set(filteredRowKeys?.[index] ?? getRowKey(row, index), row), + ); + rows.forEach((row, index) => + map.set(rowKeys?.[index] ?? getRowKey(row, index), row), + ); return map; - }, [rows, filteredRows, getRowKey]); + }, [rows, rowKeys, filteredRows, filteredRowKeys, getRowKey]); const rowByKeyRef = useRef(rowByKey); @@ -187,6 +283,33 @@ export function useTableSelection(options: UseTableSelectionOptions) { const selectedKeys = state.selectedKeys; const isAll = selectedKeys === 'all'; + const isCascade = + tree?.behavior === 'cascade' && selectionMode === 'multiple'; + + const derivedSelection = useMemo(() => { + if (isAll) { + return { + checked: new Set( + [...rowByKey.keys()].filter((key) => !unselectableKeys.has(key)), + ), + indeterminate: new Set(), + }; + } + + if (!isCascade || !tree) { + return { + checked: new Set(selectedKeys as Set), + indeterminate: new Set(), + }; + } + + return deriveTreeSelection( + selectedKeys as Set, + tree.rootKeys, + tree.childrenOf, + (key) => !unselectableKeys.has(key), + ); + }, [isAll, isCascade, selectedKeys, tree, rowByKey, unselectableKeys]); const canSelect = useCallback( (key: Key) => isEnabled && !unselectableKeys.has(key), @@ -194,26 +317,33 @@ export function useTableSelection(options: UseTableSelectionOptions) { ); const isSelected = useCallback( - (key: Key) => - isAll ? canSelect(key) : (selectedKeys as Set).has(key), - [isAll, selectedKeys, canSelect], + (key: Key) => (isAll ? canSelect(key) : derivedSelection.checked.has(key)), + [isAll, derivedSelection, canSelect], + ); + + const isIndeterminate = useCallback( + (key: Key) => derivedSelection.indeterminate.has(key), + [derivedSelection], ); /** Keys the header checkbox acts on, which is what `selectAllMode` decides. */ const scopeKeys = useMemo(() => { if (!isEnabled || selectionMode === 'single') return []; - const source = selectAllMode === 'page' ? rows : filteredRows; + const source = selectAllMode === 'page' ? pageRows : filteredRows; + const sourceKeys = selectAllMode === 'page' ? pageRowKeys : filteredRowKeys; return source - .map((row, index) => getRowKey(row, index)) + .map((row, index) => sourceKeys?.[index] ?? getRowKey(row, index)) .filter((key) => !unselectableKeys.has(key)); }, [ isEnabled, selectionMode, selectAllMode, - rows, + pageRows, + pageRowKeys, filteredRows, + filteredRowKeys, getRowKey, unselectableKeys, ]); @@ -222,13 +352,13 @@ export function useTableSelection(options: UseTableSelectionOptions) { if (isAll) return 'all'; if (!scopeKeys.length) return 'none'; - const set = selectedKeys as Set; + const set = derivedSelection.checked; let count = 0; for (const key of scopeKeys) if (set.has(key)) count++; return count === 0 ? 'none' : count === scopeKeys.length ? 'all' : 'some'; - }, [isAll, selectedKeys, scopeKeys]); + }, [isAll, derivedSelection, scopeKeys]); const toggleSelectAll = useEvent(() => { anchorRef.current = null; @@ -249,10 +379,19 @@ export function useTableSelection(options: UseTableSelectionOptions) { return; } - const next = new Set(isAll ? [] : (selectedKeys as Set)); + const next = new Set(isAll ? [] : derivedSelection.checked); scopeKeys.forEach((key) => next.add(key)); - state.setSelectedKeys(next); + state.setSelectedKeys( + isCascade && tree + ? deriveTreeSelection( + next, + tree.rootKeys, + tree.childrenOf, + (key) => !unselectableKeys.has(key), + ).checked + : next, + ); }); const clearSelection = useEvent(() => { @@ -301,39 +440,80 @@ export function useTableSelection(options: UseTableSelectionOptions) { return; } + const applyCascade = (set: Set, target: Key, value: boolean) => { + if (!canSelect(target)) return; + if (value) set.add(target); + else { + set.delete(target); + + // A checked ancestor in the normalized public key set means its whole + // branch is selected. Remove that derived marker before normalizing a + // partial deselection, or it would immediately select the child again. + if (isCascade && tree) { + let parent = tree.parentOf.get(target); + while (parent != null) { + set.delete(parent); + parent = tree.parentOf.get(parent); + } + } + } + if (!isCascade || !tree) return; + (tree.childrenOf.get(target) ?? []).forEach((child) => + applyCascade(set, child, value), + ); + }; + + const normalizeCascade = (set: Set) => + isCascade && tree + ? deriveTreeSelection( + set, + tree.rootKeys, + tree.childrenOf, + (candidate) => !unselectableKeys.has(candidate), + ).checked + : set; + const anchor = anchorRef.current; if (shiftRef.current && anchor != null && collection.indexOf(anchor) >= 0) { - const next = new Set(isAll ? scopeKeys : (selectedKeys as Set)); + const next = new Set(isAll ? scopeKeys : derivedSelection.checked); // Drop the range this shift-click supersedes before drawing the new one, // so clicking back toward the anchor shrinks the selection. if (extentRef.current != null) { - keysBetween(anchor, extentRef.current).forEach((k) => next.delete(k)); + keysBetween(anchor, extentRef.current).forEach((k) => + applyCascade(next, k, false), + ); } keysBetween(anchor, key).forEach((k) => { - if (canSelect(k)) next.add(k); + applyCascade(next, k, true); }); extentRef.current = key; - state.setSelectedKeys(next); + state.setSelectedKeys(normalizeCascade(next)); return; } anchorRef.current = key; extentRef.current = null; - selectionManager.toggleSelection(key); + if (isCascade) { + const next = new Set(derivedSelection.checked); + applyCascade(next, key, !derivedSelection.checked.has(key)); + state.setSelectedKeys(normalizeCascade(next)); + } else { + selectionManager.toggleSelection(key); + } }); const selectedRows = useMemo(() => { if (isAll) return [...rowByKey.values()]; - return [...(selectedKeys as Set)] + return [...derivedSelection.checked] .map((key) => rowByKey.get(key)) .filter((row): row is T => row !== undefined); - }, [isAll, selectedKeys, rowByKey]); + }, [isAll, derivedSelection, rowByKey]); return { isEnabled, @@ -341,6 +521,7 @@ export function useTableSelection(options: UseTableSelectionOptions) { selectionManager, collection, isSelected, + isIndeterminate, canSelect, isRowDisabled: useCallback( (key: Key) => hardDisabledKeys.has(key), @@ -352,8 +533,8 @@ export function useTableSelection(options: UseTableSelectionOptions) { toggleSelectAll, clearSelection, selectedRows, - selectedCount: isAll ? rowByKey.size : (selectedKeys as Set).size, - selectedKeys: (isAll ? 'all' : [...(selectedKeys as Set)]) as + selectedCount: isAll ? rowByKey.size : derivedSelection.checked.size, + selectedKeys: (isAll ? 'all' : [...derivedSelection.checked]) as | Key[] | 'all', }; diff --git a/src/components/data/TableBase/use-table-tree-state.tsx b/src/components/data/TableBase/use-table-tree-state.tsx new file mode 100644 index 000000000..d47665c8e --- /dev/null +++ b/src/components/data/TableBase/use-table-tree-state.tsx @@ -0,0 +1,196 @@ +import { useControlledState } from '@react-stately/utils'; +import { useMemo } from 'react'; +import { Item, useTreeState } from 'react-stately'; + +import { useEvent } from '../../../_internal/hooks'; +import { useI18n } from '../../../i18n'; + +import type { Key, Node } from '@react-types/shared'; +import type { ReactElement } from 'react'; +import type { TreeState } from 'react-stately'; +import type { TableTreeNode } from './table-tree'; +import type { CubeTableRowExpandInfo } from './types'; + +export interface UseTableTreeStateOptions { + roots: TableTreeNode[]; + allNodesByKey: Map>; + expandedKeys?: Key[]; + defaultExpandedKeys?: Key[]; + forcedExpandedKeys?: ReadonlySet; + disabledKeys?: Key[]; + getTextValue: (node: TableTreeNode) => string; + onExpand?: (keys: Key[], info: CubeTableRowExpandInfo) => void; + ariaLabel?: string; +} + +export interface TableTreeStateResult { + state: TreeState>; + /** Props consumed by `useTree` on the actual ``. */ + ariaProps: Record; + visibleNodes: Node>[]; + visibleEntries: TableTreeNode[]; + expandedKeys: Set; +} + +export function useTableTreeState( + options: UseTableTreeStateOptions, +): TableTreeStateResult { + const { + roots, + allNodesByKey, + expandedKeys: controlledExpandedKeys, + defaultExpandedKeys, + forcedExpandedKeys, + disabledKeys, + getTextValue, + onExpand, + ariaLabel, + } = options; + const { t } = useI18n(); + + const controlledSet = useMemo( + () => + controlledExpandedKeys === undefined + ? undefined + : new Set(controlledExpandedKeys), + [controlledExpandedKeys], + ); + const defaultSet = useMemo( + () => new Set(defaultExpandedKeys ?? []), + [defaultExpandedKeys], + ); + const [baseExpandedKeys, setBaseExpandedKeys] = useControlledState>( + controlledSet as Set, + defaultSet, + ); + + const effectiveExpandedKeys = useMemo(() => { + const keys = new Set(baseExpandedKeys); + forcedExpandedKeys?.forEach((key) => keys.add(key)); + return keys; + }, [baseExpandedKeys, forcedExpandedKeys]); + + const handleExpandedChange = useEvent((next: Set) => { + let toggledKey: Key | null = null; + let expanded = false; + + for (const key of next) { + if (!effectiveExpandedKeys.has(key)) { + toggledKey = key; + expanded = true; + break; + } + } + + if (toggledKey == null) { + for (const key of effectiveExpandedKeys) { + if (!next.has(key)) { + toggledKey = key; + expanded = false; + break; + } + } + } + + if (toggledKey == null) return; + + // Search-owned expansions are derived and cannot be collapsed until the + // search clears. Crucially, they never leak into the consumer's state. + if (!expanded && forcedExpandedKeys?.has(toggledKey)) return; + + const nextBase = new Set(baseExpandedKeys); + if (expanded) nextBase.add(toggledKey); + else nextBase.delete(toggledKey); + setBaseExpandedKeys(nextBase); + + const node = allNodesByKey.get(toggledKey); + if (!node) return; + + onExpand?.([...nextBase], { + row: node.row, + rowKey: node.key, + level: node.level, + parentKey: node.parentKey, + expanded, + }); + }); + + const renderItem = useEvent( + (node: TableTreeNode): ReactElement => ( + + {getTextValue(node)} + + ), + ); + + const ariaProps = useMemo( + () => ({ + items: roots, + children: renderItem as any, + selectionMode: 'none' as const, + expandedKeys: effectiveExpandedKeys, + onExpandedChange: handleExpandedChange, + disabledKeys, + disabledBehavior: 'all' as const, + // Prevent `useTreeItem` from treating a row press as an implicit toggle. + // Tables keep row activation and expansion as separate interactions. + onAction: () => {}, + 'aria-label': ariaLabel ?? t('itemTable.table', 'Table'), + }), + [ + roots, + renderItem, + effectiveExpandedKeys, + handleExpandedChange, + disabledKeys, + ariaLabel, + t, + ], + ); + + const baseState = useTreeState>(ariaProps); + + // Current React Stately's legacy TreeCollection omits `getChildren`, while + // `useTreeItem` needs it for level/set-size/expanded metadata. + const state = useMemo(() => { + const collection = baseState.collection; + if (typeof (collection as any).getChildren === 'function') return baseState; + + const patched = Object.create(collection); + patched.getChildren = (key: Key) => { + const node = collection.getItem(key); + return node ? Array.from(node.childNodes) : []; + }; + + return { ...baseState, collection: patched } as typeof baseState; + }, [baseState]); + + const visibleNodes = useMemo(() => { + const output: Node>[] = []; + for (const key of state.collection.getKeys()) { + const node = state.collection.getItem(key); + if (node?.type === 'item') output.push(node); + } + return output; + }, [state.collection]); + + const visibleEntries = useMemo( + () => + visibleNodes + .map((node) => node.value) + .filter((node): node is TableTreeNode => node != null), + [visibleNodes], + ); + + return { + state, + ariaProps, + visibleNodes, + visibleEntries, + expandedKeys: effectiveExpandedKeys, + }; +} diff --git a/src/components/data/index.ts b/src/components/data/index.ts index f7ee12550..308bb45c7 100644 --- a/src/components/data/index.ts +++ b/src/components/data/index.ts @@ -21,5 +21,8 @@ export type { CubeTableRowSize, CubeTableSort, CubeTableSortDirection, + CubeTableTreeProps, + CubeTableTreeRowState, + CubeTableRowExpandInfo, } from './TableBase'; export type { CubeTableRowRenderProps } from './TableBase'; diff --git a/src/eslint-plugin/defaults.generated.ts b/src/eslint-plugin/defaults.generated.ts index ad332ea28..97258ed1f 100644 --- a/src/eslint-plugin/defaults.generated.ts +++ b/src/eslint-plugin/defaults.generated.ts @@ -462,6 +462,7 @@ export const DEFAULTS: DefaultsRegistry = { shape: { kind: 'default', value: 'plain' }, skeletonRowCount: { kind: 'default', value: 6 }, sortMode: { kind: 'default', value: 'client' }, + treeSelectionBehavior: { kind: 'default', value: 'cascade' }, virtualizeThreshold: { kind: 'default', value: 50 }, }, }, diff --git a/src/i18n/locales/de-DE/uikit.json b/src/i18n/locales/de-DE/uikit.json index a8af91239..0da221f64 100644 --- a/src/i18n/locales/de-DE/uikit.json +++ b/src/i18n/locales/de-DE/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} von {{total}}" }, "itemTable": { + "table": "Tabelle", "noItems": "Keine Einträge", "noResults": "Keine Ergebnisse gefunden", "search": "Suchen", diff --git a/src/i18n/locales/en-US/uikit.json b/src/i18n/locales/en-US/uikit.json index f4fac7a1b..286d509a0 100644 --- a/src/i18n/locales/en-US/uikit.json +++ b/src/i18n/locales/en-US/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} of {{total}}" }, "itemTable": { + "table": "Table", "noItems": "No items", "noResults": "No results found", "search": "Search", diff --git a/src/i18n/locales/es-ES/uikit.json b/src/i18n/locales/es-ES/uikit.json index 501b3f779..871e402f7 100644 --- a/src/i18n/locales/es-ES/uikit.json +++ b/src/i18n/locales/es-ES/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} de {{total}}" }, "itemTable": { + "table": "Tabla", "noItems": "Sin elementos", "noResults": "No se encontraron resultados", "search": "Buscar", diff --git a/src/i18n/locales/es-MX/uikit.json b/src/i18n/locales/es-MX/uikit.json index 728c05e96..6b1653283 100644 --- a/src/i18n/locales/es-MX/uikit.json +++ b/src/i18n/locales/es-MX/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} de {{total}}" }, "itemTable": { + "table": "Tabla", "noItems": "Sin elementos", "noResults": "No se encontraron resultados", "search": "Buscar", diff --git a/src/i18n/locales/fr-FR/uikit.json b/src/i18n/locales/fr-FR/uikit.json index 3333e339c..13d2fb0ec 100644 --- a/src/i18n/locales/fr-FR/uikit.json +++ b/src/i18n/locales/fr-FR/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} sur {{total}}" }, "itemTable": { + "table": "Tableau", "noItems": "Aucun élément", "noResults": "Aucun résultat trouvé", "search": "Rechercher", diff --git a/src/i18n/locales/it-IT/uikit.json b/src/i18n/locales/it-IT/uikit.json index 764a6ce45..606bc6bed 100644 --- a/src/i18n/locales/it-IT/uikit.json +++ b/src/i18n/locales/it-IT/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} di {{total}}" }, "itemTable": { + "table": "Tabella", "noItems": "Nessun elemento", "noResults": "Nessun risultato trovato", "search": "Cerca", diff --git a/src/i18n/locales/ja-JP/uikit.json b/src/i18n/locales/ja-JP/uikit.json index 8703cecb9..f08d01f0b 100644 --- a/src/i18n/locales/ja-JP/uikit.json +++ b/src/i18n/locales/ja-JP/uikit.json @@ -160,6 +160,7 @@ "summary": "{{total}} 件中 {{from}}–{{to}} 件" }, "itemTable": { + "table": "テーブル", "noItems": "項目がありません", "noResults": "結果が見つかりません", "search": "検索", diff --git a/src/i18n/locales/nb-NO/uikit.json b/src/i18n/locales/nb-NO/uikit.json index df76256df..808445030 100644 --- a/src/i18n/locales/nb-NO/uikit.json +++ b/src/i18n/locales/nb-NO/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} av {{total}}" }, "itemTable": { + "table": "Tabell", "noItems": "Ingen elementer", "noResults": "Ingen resultater funnet", "search": "Søk", diff --git a/src/i18n/locales/pt-BR/uikit.json b/src/i18n/locales/pt-BR/uikit.json index 63f635bb9..15f317807 100644 --- a/src/i18n/locales/pt-BR/uikit.json +++ b/src/i18n/locales/pt-BR/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} de {{total}}" }, "itemTable": { + "table": "Tabela", "noItems": "Nenhum item", "noResults": "Nenhum resultado encontrado", "search": "Pesquisar", diff --git a/src/i18n/locales/pt-PT/uikit.json b/src/i18n/locales/pt-PT/uikit.json index a4d547aa5..bec3c2af5 100644 --- a/src/i18n/locales/pt-PT/uikit.json +++ b/src/i18n/locales/pt-PT/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} de {{total}}" }, "itemTable": { + "table": "Tabela", "noItems": "Nenhum item", "noResults": "Nenhum resultado encontrado", "search": "Pesquisar", diff --git a/src/i18n/locales/sv-SE/uikit.json b/src/i18n/locales/sv-SE/uikit.json index ee07c2217..7a859ac88 100644 --- a/src/i18n/locales/sv-SE/uikit.json +++ b/src/i18n/locales/sv-SE/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} av {{total}}" }, "itemTable": { + "table": "Tabell", "noItems": "Inga objekt", "noResults": "Inga resultat hittades", "search": "Sök", diff --git a/src/i18n/locales/vi-VN/uikit.json b/src/i18n/locales/vi-VN/uikit.json index 92bfc2efd..e8edabdf4 100644 --- a/src/i18n/locales/vi-VN/uikit.json +++ b/src/i18n/locales/vi-VN/uikit.json @@ -160,6 +160,7 @@ "summary": "{{from}}–{{to}} trên {{total}}" }, "itemTable": { + "table": "Bảng", "noItems": "Không có mục nào", "noResults": "Không tìm thấy kết quả", "search": "Tìm kiếm", diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx index c550c0f28..898fafe55 100644 --- a/src/stories/Usage.docs.mdx +++ b/src/stories/Usage.docs.mdx @@ -406,6 +406,24 @@ tokens (`#loading-face-1` … `#loading-face-3`) rather than a `currentColor` glyph, so it ignores `color` and belongs at illustration sizes (`size="8x"` and up). +## Hierarchical Table Rows + +`ItemTable` and `DataTable` opt into native treegrid behavior with +`getRowChildren`. Pass roots in `data`; the callback returns already-loaded +children, and row keys must be globally unique. Expansion is uncontrolled with +`defaultExpandedKeys` or controlled with `expandedKeys` / `onExpand`. + +The shared table engine recursively sorts siblings, paginates roots with their +complete subtrees, and flattens only expanded rows for rendering and +virtualization. ItemTable client search preserves ancestor paths and temporarily +opens branches containing matches. Server modes do not transform nested data. + +Use `treeColumnKey` to choose the disclosure/indent column. Its hierarchy chrome +is customizable through the `TreeContent`, `TreeToggle`, and `TreeValue` +sub-elements. ItemTable multiple selection cascades by default; DataTable cell +ranges contain visible rows only. See each component's docs for pagination, +numbering, selection, and drag/drop limits. + ## Form System ### Form Component From 45b6215073811ea74c67b7a9957fce0ee79f2358 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 20 Aug 2026 21:11:45 +0200 Subject: [PATCH 2/8] fix(tables): address tree review feedback --- .../data/ItemTable/ItemTable.browser.test.tsx | 27 ++++++++++++ .../data/ItemTable/ItemTable.tree.test.tsx | 42 +++++++++++++++++++ src/components/data/ItemTable/ItemTable.tsx | 33 +++++++++++---- src/components/data/TableBase/TableView.tsx | 24 ++++++++++- .../data/TableBase/use-table-selection.ts | 14 +++++-- 5 files changed, 127 insertions(+), 13 deletions(-) diff --git a/src/components/data/ItemTable/ItemTable.browser.test.tsx b/src/components/data/ItemTable/ItemTable.browser.test.tsx index 13cfd9385..4e457fa7f 100644 --- a/src/components/data/ItemTable/ItemTable.browser.test.tsx +++ b/src/components/data/ItemTable/ItemTable.browser.test.tsx @@ -692,4 +692,31 @@ describe('treegrid focus and virtualization', () => { treegrid().querySelectorAll('tbody tr[data-element="Row"]').length, ).toBeLessThan(61); }); + + it('moves focus after a virtualized arrow destination mounts', async () => { + renderWithRoot( + row.children} + height="140px" + isVirtualized + overscan={0} + paginationMode="off" + />, + ); + + await vi.waitFor(() => expect(treeRow('root-0')).toBeInTheDocument()); + treeRow('root-0').focus(); + + for (let index = 1; index <= 12; index++) { + await act(() => realInput.keyboard('{ArrowDown}')); + await vi.waitFor(() => expect(treeRow(`root-${index}`)).toHaveFocus()); + } + + expect( + document.querySelector('[data-element="Scroller"]')! + .scrollTop, + ).toBeGreaterThan(0); + }); }); diff --git a/src/components/data/ItemTable/ItemTable.tree.test.tsx b/src/components/data/ItemTable/ItemTable.tree.test.tsx index a32937124..6b2f07c99 100644 --- a/src/components/data/ItemTable/ItemTable.tree.test.tsx +++ b/src/components/data/ItemTable/ItemTable.tree.test.tsx @@ -222,6 +222,48 @@ describe('ItemTable tree rows', () => { expect(rowBox('leaf')).not.toBeChecked(); }); + it('cascades only through rows retained by client search', async () => { + const onSelectionChange = vi.fn(); + + const { rerender } = renderWithRoot( + , + ); + + await waitFor(() => expect(row('leaf')).toBeInTheDocument()); + await userEvent.click(rowBox('root')); + + await waitFor(() => { + const keys = onSelectionChange.mock.lastCall?.[0] as string[]; + expect(keys).toEqual(['root', 'branch', 'leaf']); + expect(keys).not.toContain('sibling'); + expect(keys).not.toContain('other'); + }); + + rerender( + , + ); + + await waitFor(() => expect(row('sibling')).toBeInTheDocument()); + expect(rowBox('sibling')).not.toBeChecked(); + expect(rowBox('root').indeterminate).toBe(true); + }); + it('excludes disabled descendants from cascade and collapsed select-all', async () => { const onSelectionChange = vi.fn(); diff --git a/src/components/data/ItemTable/ItemTable.tsx b/src/components/data/ItemTable/ItemTable.tsx index 47b34366d..89aa40595 100644 --- a/src/components/data/ItemTable/ItemTable.tsx +++ b/src/components/data/ItemTable/ItemTable.tsx @@ -442,6 +442,30 @@ function ItemTable( const filteredTreeEntries = treeModel ? flattenTableTree(processedTreeRoots) : []; + const selectionTree = useMemo(() => { + if (!treeModel) return undefined; + + // Selection follows the tree the user can currently act on. In + // particular, a search that retains only an ancestor path must not let a + // checked ancestor reach siblings that the search removed. + const childrenOf = new Map(); + const parentOf = new Map(); + + flattenTableTree(processedTreeRoots).forEach((node) => { + childrenOf.set( + node.key, + node.children.map((child) => child.key), + ); + parentOf.set(node.key, node.parentKey); + }); + + return { + rootKeys: processedTreeRoots.map((node) => node.key), + childrenOf, + parentOf, + behavior: treeSelectionBehavior, + }; + }, [treeModel, processedTreeRoots, treeSelectionBehavior]); // A bulk action with no way to select rows is a contradiction, so supplying // any implies multiple selection unless the consumer says otherwise. @@ -482,14 +506,7 @@ function ItemTable( selectAllMode, isRowSelectable, disabledKeys, - tree: treeModel - ? { - rootKeys: treeModel.roots.map((node) => node.key), - childrenOf: treeModel.childrenOf, - parentOf: treeModel.parentOf, - behavior: treeSelectionBehavior, - } - : undefined, + tree: selectionTree, }); // A `ReactNode` menu applies to every row; a function decides per row. The diff --git a/src/components/data/TableBase/TableView.tsx b/src/components/data/TableBase/TableView.tsx index b147ee723..bb4a95f7a 100644 --- a/src/components/data/TableBase/TableView.tsx +++ b/src/components/data/TableBase/TableView.tsx @@ -428,7 +428,28 @@ function TreeGridTable(props: { }) { const { tree, tableProps, style, children } = props; const ref = useRef(null); + const pendingFocusKey = useRef(null); const { gridProps } = useTree(tree.ariaProps as any, tree.state, ref); + + // A virtualized destination may not exist until the focused key makes the + // parent virtualizer scroll and render another window. Retry after each + // render until that row mounts, then complete the keyboard focus move. + useEffect(() => { + const key = pendingFocusKey.current; + if (key == null) return; + + const target = Array.from( + ref.current?.querySelectorAll( + 'tbody tr[data-element="Row"][data-key]', + ) ?? [], + ).find((element) => element.dataset.key === String(key)); + + if (target) { + pendingFocusKey.current = null; + target.focus(); + } + }); + const handleKeyDownCapture = ( event: ReactKeyboardEvent, ) => { @@ -456,7 +477,8 @@ function TreeGridTable(props: { 'tbody tr[data-element="Row"][data-key]', ) ?? [], ).find((element) => element.dataset.key === String(next.key)); - target?.focus(); + if (target) target.focus(); + else pendingFocusKey.current = next.key; return true; }; diff --git a/src/components/data/TableBase/use-table-selection.ts b/src/components/data/TableBase/use-table-selection.ts index 7d44e7147..11b6d1d6e 100644 --- a/src/components/data/TableBase/use-table-selection.ts +++ b/src/components/data/TableBase/use-table-selection.ts @@ -80,13 +80,12 @@ function deriveTreeSelection( const visit = ( key: Key, - inheritedSelection = false, ): { all: boolean; any: boolean; eligible: boolean } => { const children = childrenOf.get(key) ?? []; const eligible = isEligible(key); - const selectsBranch = inheritedSelection || (eligible && source.has(key)); + const explicitlySelected = eligible && source.has(key); - if (eligible && selectsBranch) checked.add(key); + if (explicitlySelected) checked.add(key); if (!children.length) { const selected = eligible && checked.has(key); @@ -99,7 +98,7 @@ function deriveTreeSelection( let hasEligible = false; for (const child of children) { - const result = visit(child, selectsBranch); + const result = visit(child); if (result.eligible) hasEligible = true; if (!result.all) all = false; if (result.any) any = true; @@ -114,6 +113,13 @@ function deriveTreeSelection( } else if (hasEligible && any) { checked.delete(key); indeterminate.add(key); + } else if (hasEligible) { + // Branch keys in the public set are a normalized reflection of their + // descendants, not an enduring wildcard. This matters when a search + // hid siblings: restoring the full tree must not make those siblings + // selected merely because the filtered parent had been checked. + checked.delete(key); + indeterminate.delete(key); } else if (!checked.has(key)) { indeterminate.delete(key); } From a8e5a2b3a266c4a10f94c65740dd88d8041b2f05 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 20 Aug 2026 21:31:31 +0200 Subject: [PATCH 3/8] fix(tables): stabilize virtual tree focus --- .../data/ItemTable/ItemTable.browser.test.tsx | 51 +++++++++++++++++-- src/components/data/TableBase/TableView.tsx | 44 ++++++++++++++-- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/components/data/ItemTable/ItemTable.browser.test.tsx b/src/components/data/ItemTable/ItemTable.browser.test.tsx index 4e457fa7f..ad9e262f0 100644 --- a/src/components/data/ItemTable/ItemTable.browser.test.tsx +++ b/src/components/data/ItemTable/ItemTable.browser.test.tsx @@ -693,7 +693,7 @@ describe('treegrid focus and virtualization', () => { ).toBeLessThan(61); }); - it('moves focus after a virtualized arrow destination mounts', async () => { + it('moves rapid arrow focus across virtualized windows', async () => { renderWithRoot( { await vi.waitFor(() => expect(treeRow('root-0')).toBeInTheDocument()); treeRow('root-0').focus(); - for (let index = 1; index <= 12; index++) { - await act(() => realInput.keyboard('{ArrowDown}')); - await vi.waitFor(() => expect(treeRow(`root-${index}`)).toHaveFocus()); - } + // Do not yield between presses. Once the next row is outside the mounted + // window, the old DOM row still receives keys while logical focus advances. + await act(() => { + for (let index = 0; index < 12; index++) { + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + } + }); + + await vi.waitFor(() => expect(treeRow('root-12')).toHaveFocus()); expect( document.querySelector('[data-element="Scroller"]')! .scrollTop, ).toBeGreaterThan(0); }); + + it('does not restore pending virtual focus after focus leaves the treegrid', async () => { + renderWithRoot( + <> + row.children} + height="140px" + isVirtualized + overscan={0} + paginationMode="off" + /> + + , + ); + + await vi.waitFor(() => expect(treeRow('root-0')).toBeInTheDocument()); + treeRow('root-0').focus(); + + await act(() => { + for (let index = 0; index < 12; index++) { + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + } + screen.getByRole('button', { name: 'Outside control' }).focus(); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect( + screen.getByRole('button', { name: 'Outside control' }), + ).toHaveFocus(); + }); }); diff --git a/src/components/data/TableBase/TableView.tsx b/src/components/data/TableBase/TableView.tsx index bb4a95f7a..2b709cab5 100644 --- a/src/components/data/TableBase/TableView.tsx +++ b/src/components/data/TableBase/TableView.tsx @@ -67,6 +67,7 @@ import type { Key, Node } from '@react-types/shared'; import type { Styles } from '@tenphi/tasty'; import type { CSSProperties, + FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, @@ -438,6 +439,19 @@ function TreeGridTable(props: { const key = pendingFocusKey.current; if (key == null) return; + // A real focus move wins over the delayed virtual-row focus. Losing the + // old row to virtualization leaves focus on , whereas tabbing or + // clicking elsewhere leaves a concrete active element we must respect. + const activeElement = document.activeElement; + if ( + activeElement && + activeElement !== document.body && + !ref.current?.contains(activeElement) + ) { + pendingFocusKey.current = null; + return; + } + const target = Array.from( ref.current?.querySelectorAll( 'tbody tr[data-element="Row"][data-key]', @@ -450,6 +464,23 @@ function TreeGridTable(props: { } }); + const handleBlurCapture = (event: ReactFocusEvent) => { + const pendingKey = pendingFocusKey.current; + const nextTarget = event.relatedTarget; + + if (pendingKey != null && nextTarget instanceof Element) { + const nextRow = nextTarget.closest( + 'tbody tr[data-element="Row"][data-key]', + ); + + if (nextRow?.dataset.key !== String(pendingKey)) { + pendingFocusKey.current = null; + } + } + + (gridProps as any).onBlurCapture?.(event); + }; + const handleKeyDownCapture = ( event: ReactKeyboardEvent, ) => { @@ -463,8 +494,12 @@ function TreeGridTable(props: { return; } + // While a virtual destination is still mounting, subsequent key presses + // continue from that logical key rather than repeatedly targeting the old + // DOM row that still owns focus. + const currentKey = pendingFocusKey.current ?? row.dataset.key; const index = tree.entries.findIndex( - (entry) => String(entry.key) === row.dataset.key, + (entry) => String(entry.key) === String(currentKey), ); const entry = tree.entries[index]; if (!entry) return; @@ -477,8 +512,10 @@ function TreeGridTable(props: { 'tbody tr[data-element="Row"][data-key]', ) ?? [], ).find((element) => element.dataset.key === String(next.key)); - if (target) target.focus(); - else pendingFocusKey.current = next.key; + if (target) { + pendingFocusKey.current = null; + target.focus(); + } else pendingFocusKey.current = next.key; return true; }; @@ -522,6 +559,7 @@ function TreeGridTable(props: { ref={ref} role="treegrid" style={style} + onBlurCapture={handleBlurCapture} onKeyDownCapture={handleKeyDownCapture} > {children} From 8b84560222af1942a53add1f40a9f2caa2db2dc5 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 20 Aug 2026 21:49:17 +0200 Subject: [PATCH 4/8] fix(tables): consume pending tree keys --- .../data/ItemTable/ItemTable.browser.test.tsx | 48 +++++++++++++++++++ src/components/data/TableBase/TableView.tsx | 13 +++++ 2 files changed, 61 insertions(+) diff --git a/src/components/data/ItemTable/ItemTable.browser.test.tsx b/src/components/data/ItemTable/ItemTable.browser.test.tsx index ad9e262f0..b32a5b44f 100644 --- a/src/components/data/ItemTable/ItemTable.browser.test.tsx +++ b/src/components/data/ItemTable/ItemTable.browser.test.tsx @@ -760,4 +760,52 @@ describe('treegrid focus and virtualization', () => { screen.getByRole('button', { name: 'Outside control' }), ).toHaveFocus(); }); + + it('does not apply an unhandled pending key to the stale DOM row', async () => { + const onExpand = vi.fn(); + const branchesBeforeTarget: TreeRow[] = Array.from( + { length: 20 }, + (_, index) => ({ + id: `pending-${index}`, + name: `Pending ${index}`, + children: + index < 12 + ? [{ id: `pending-child-${index}`, name: `Child ${index}` }] + : undefined, + }), + ); + + renderWithRoot( + row.children} + height="140px" + isVirtualized + overscan={0} + paginationMode="off" + onExpand={onExpand} + />, + ); + + await vi.waitFor(() => expect(treeRow('pending-0')).toBeInTheDocument()); + treeRow('pending-0').focus(); + + await act(() => { + for (let index = 0; index < 12; index++) { + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + } + + // pending-12 is a leaf, so this has no logical tree action. The mounted + // stale row is a branch and must not receive the key as a fallback. + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); + }); + + await vi.waitFor(() => expect(treeRow('pending-12')).toHaveFocus()); + expect(onExpand).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/data/TableBase/TableView.tsx b/src/components/data/TableBase/TableView.tsx index 2b709cab5..81313c828 100644 --- a/src/components/data/TableBase/TableView.tsx +++ b/src/components/data/TableBase/TableView.tsx @@ -550,6 +550,19 @@ function TreeGridTable(props: { return; } + // React Aria still sees the DOM row that owned focus before the virtual + // destination mounted. If a logical navigation key has no action there + // (a boundary, leaf, or collapsed root), consuming it avoids falling + // through and applying that key to the stale row instead. + if ( + pendingFocusKey.current != null && + ['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft'].includes(event.key) + ) { + event.preventDefault(); + event.stopPropagation(); + return; + } + (gridProps as any).onKeyDownCapture?.(event); }; From b67358be764c3b0a5a3f529e0a2b8dc0169a45d6 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 21 Aug 2026 10:03:03 +0200 Subject: [PATCH 5/8] docs(DataTable): add pivot story --- .../data/DataTable/DataTable.stories.tsx | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/components/data/DataTable/DataTable.stories.tsx b/src/components/data/DataTable/DataTable.stories.tsx index f486aeb26..583e28022 100644 --- a/src/components/data/DataTable/DataTable.stories.tsx +++ b/src/components/data/DataTable/DataTable.stories.tsx @@ -128,6 +128,88 @@ const TOTALS: ResultRow[] = [ }, ]; +interface CrossTabRow { + id: string; + region: string; + organic: number; + paid: number; + email: number; + referral: number; + total: number; +} + +const CROSS_TAB_ROWS: CrossTabRow[] = REGIONS.map((region) => { + const revenueByChannel = Object.fromEntries( + CHANNELS.map((channel) => [ + channel, + ROWS.filter( + (row) => row.region === region && row.channel === channel, + ).reduce((sum, row) => sum + row.revenue, 0), + ]), + ) as Record<(typeof CHANNELS)[number], number>; + + return { + id: region, + region, + organic: revenueByChannel.organic, + paid: revenueByChannel.paid, + email: revenueByChannel.email, + referral: revenueByChannel.referral, + total: Object.values(revenueByChannel).reduce( + (sum, value) => sum + value, + 0, + ), + }; +}); + +const formatCurrency = (value: number) => + value.toLocaleString(undefined, { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }); + +const CROSS_TAB_COLUMNS: CubeDataTableColumn[] = [ + { + key: 'region', + title: 'Region', + minWidth: 150, + pin: 'start', + isSortable: true, + }, + ...CHANNELS.map( + (channel): CubeDataTableColumn => ({ + key: channel, + title: channel[0].toUpperCase() + channel.slice(1), + dataType: 'number', + minWidth: 140, + isSortable: true, + format: formatCurrency, + }), + ), + { + key: 'total', + title: 'Total', + dataType: 'number', + minWidth: 150, + isSortable: true, + color: 'note', + format: formatCurrency, + }, +]; + +const CROSS_TAB_TOTALS: CrossTabRow[] = [ + { + id: 'grand-total', + region: 'Grand total', + organic: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.organic, 0), + paid: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.paid, 0), + email: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.email, 0), + referral: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.referral, 0), + total: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.total, 0), + }, +]; + const meta: Meta = { title: 'Data/DataTable', component: DataTable, @@ -176,6 +258,7 @@ const meta: Meta = { export default meta; type Story = StoryObj>; +type PivotStory = StoryObj>; /** * The defaults are an analytical grid's rather than a list's: `t4` type, banded @@ -184,6 +267,23 @@ type Story = StoryObj>; */ export const Default: Story = {}; +/** + * DataTable renders an already-shaped pivot result as an ordinary flat table: + * the row field stays pinned, generated values become columns, and totals are + * regular pinned rows. The data/query layer owns the pivot calculation; the + * table keeps sorting, resizing, cell ranges and copy behavior generic. + */ +export const Pivot: PivotStory = { + args: { + data: CROSS_TAB_ROWS, + columns: CROSS_TAB_COLUMNS, + pinnedBottomRows: CROSS_TAB_TOTALS, + paginationMode: 'off', + ariaLabel: 'Revenue by region and channel', + height: '260px', + }, +}; + /** Nested result rows retain DataTable's multi-sort and cell-range behavior. */ export const TreeRows: Story = { args: { From 656aa29e5c651ac677d78507df293a757fbdeed2 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 21 Aug 2026 10:26:25 +0200 Subject: [PATCH 6/8] feat(DataTable): support grouped headers --- .changeset/tables-grow-branches.md | 2 +- .../data/DataTable/DataTable.docs.mdx | 61 +++++- .../data/DataTable/DataTable.stories.tsx | 141 ++++++++----- .../data/DataTable/DataTable.test.tsx | 106 +++++++++- src/components/data/DataTable/DataTable.tsx | 82 +++++++- src/components/data/DataTable/index.ts | 7 +- src/components/data/DataTable/types.ts | 21 +- src/components/data/TableBase/TableView.tsx | 188 +++++++++++++++--- src/components/data/TableBase/styled.ts | 14 ++ src/components/data/TableBase/types.ts | 6 + src/components/data/index.ts | 7 +- src/stories/Usage.docs.mdx | 5 + 12 files changed, 538 insertions(+), 102 deletions(-) diff --git a/.changeset/tables-grow-branches.md b/.changeset/tables-grow-branches.md index 7b797c1c5..d6ec69965 100644 --- a/.changeset/tables-grow-branches.md +++ b/.changeset/tables-grow-branches.md @@ -2,4 +2,4 @@ '@cube-dev/ui-kit': minor --- -Add accessible nested tree rows, expansion state, hierarchy-aware operations, and cascading ItemTable selection to ItemTable and DataTable. +Add accessible nested tree rows, expansion state, hierarchy-aware operations, cascading ItemTable selection, and grouped DataTable headers for pivoted results. diff --git a/src/components/data/DataTable/DataTable.docs.mdx b/src/components/data/DataTable/DataTable.docs.mdx index 4b09fddcf..32806699e 100644 --- a/src/components/data/DataTable/DataTable.docs.mdx +++ b/src/components/data/DataTable/DataTable.docs.mdx @@ -36,9 +36,9 @@ API keys. That is the one with selection, bulk actions, row menus and row links. ## It knows nothing about Cube Measures, dimensions, pivots, drill-downs and calculated fields never reach this -component as concepts. They arrive as ordinary columns, `render` output and -`column.header.menu` content — which is what keeps Cloud's column-header menu, -and everything it knows about the query, in Cloud. +component as query concepts. They arrive as leaf columns, presentational column +groups, `render` output and `column.header.menu` content — which is what keeps +Cloud's column-header menu, and everything it knows about the query, in Cloud. @@ -49,7 +49,8 @@ and everything it knows about the query, in Cloud. #### Data - **`data`** `readonly T[]` — The rows. -- **`columns`** `CubeDataTableColumn[]` — Same shape as `ItemTable`'s, plus `dataType`. +- **`columns`** `CubeDataTableColumnDefinition[]` — Leaf columns use the same + shape as `ItemTable`'s, plus `dataType`; group nodes contain nested `children`. - **`rowKey`** `string` (default: `'id'`) — Property to read the row identity from. - **`getRowKey`** `(row: T, index: number) => Key` — Wins over `rowKey`. - **`getRowChildren`** `(row: T) => readonly T[] | undefined` — Enables nested @@ -80,7 +81,7 @@ and everything it knows about the query, in Cloud. - **`size`** `SizeName` (default: `'small'`) — An analytical grid packs more rows than a list. - **`rowSize`** `'small' | 'medium' | 'large'` — Row height as a named step: 28px / 32px / 40px. Rows only; the header keeps answering to `size`. Unset, the height comes from `size`. - **`rowHeight`** `number` — An exact height, when none of the named steps is the answer. Wins over both `rowSize` and `size`. -- **`headerHeight`** `number` +- **`headerHeight`** `number` — Exact height of each header row in px. - **`isStriped`** `boolean` (default: `true`) — Banding is what makes a wide row readable across. - **`isHeaderHidden`** `boolean` - **`isHeaderSticky`** `boolean` (default: `true`) @@ -179,9 +180,10 @@ Customizes the root frame. Every internal part of the grid is a **sub-element** reachable from this one prop — the same contract, and the same sub-element names, as [ItemTable](/docs/data-itemtable--docs#styling-properties). -`DataTable` adds one: `StateCell`, the full-span cell that carries the empty, -no-results and error content. It is deliberately not a `Cell`, so it takes none -of the row's banding or hover paint. +`DataTable` adds `StateCell`, the full-span cell that carries the empty, +no-results and error content, and `HeaderGroupCell`, a spanning cell in a +multi-row header. `StateCell` is deliberately not a `Cell`, so it takes none of +the row's banding or hover paint. #### Targeted slots @@ -224,6 +226,43 @@ body into a scroller, pins the header, and lets virtualization engage at all. Cell-level, in addition to `ItemTable`'s: `cell-selected`, `last-column`, `pinned`. +## Pivoted Results and Column Groups + +Pass group nodes in `columns` to render real multi-row headers. Groups may nest +to any depth; their `children` eventually resolve to ordinary leaf columns, and +only those leaves participate in sorting, resizing, ranges and copying. + +```jsx +const columns = [ + { key: 'region', title: 'Region', pin: 'start' }, + { + key: 'organic', + title: 'Organic', + children: [ + { key: 'organic_orders', title: 'Orders', dataType: 'number' }, + { key: 'organic_revenue', title: 'Revenue', dataType: 'number' }, + ], + }, + { + key: 'paid', + title: 'Paid', + children: [ + { key: 'paid_orders', title: 'Orders', dataType: 'number' }, + { key: 'paid_revenue', title: 'Revenue', dataType: 'number' }, + ], + }, +]; + +; +``` + +The data/query layer still owns the pivot calculation. DataTable only renders +the already-shaped rows and generated column hierarchy. Leaf resizing and +sorting remain available; drag column reordering is ignored while groups are +present because moving one leaf cannot preserve an arbitrary hierarchy. + + + ## Tree Rows Tree mode uses the same nested-data contract as `ItemTable`: @@ -474,8 +513,10 @@ elsewhere. ## Accessibility The table is a `role="grid"` with document-absolute `aria-rowindex` / -`aria-colindex`, and `aria-rowcount` counts pinned rows. Every sorted column -carries `aria-sort`, not just the first. +`aria-colindex`, and `aria-rowcount` counts pinned rows and every visible header +row. Group cells use native `scope="colgroup"` / `colSpan`; ungrouped leaf +headers span the remaining rows. Every sorted leaf column carries `aria-sort`, +not just the first. A header cell takes a tab stop when it is interactive — sortable, carrying a menu, or reorderable. `Shift`+`F10` opens the column menu from the keyboard, and diff --git a/src/components/data/DataTable/DataTable.stories.tsx b/src/components/data/DataTable/DataTable.stories.tsx index 583e28022..3d8ac5142 100644 --- a/src/components/data/DataTable/DataTable.stories.tsx +++ b/src/components/data/DataTable/DataTable.stories.tsx @@ -20,7 +20,10 @@ import { DataTable } from './DataTable'; import type { Meta, StoryObj } from '@storybook/react-vite'; import type { CubeTableCellRange, CubeTableSort } from '../TableBase/types'; -import type { CubeDataTableColumn } from './types'; +import type { + CubeDataTableColumn, + CubeDataTableColumnDefinition, +} from './types'; interface ResultRow { id: string; @@ -34,7 +37,7 @@ interface ResultRow { type TreeResultRow = ResultRow & { children?: TreeResultRow[] }; const REGIONS = ['us-east-1', 'us-west-2', 'eu-central-1', 'ap-south-1']; -const CHANNELS = ['organic', 'paid', 'email', 'referral']; +const CHANNELS = ['organic', 'paid', 'email', 'referral'] as const; const ROWS: ResultRow[] = Array.from({ length: 240 }, (_, i) => ({ id: `r${i}`, @@ -128,37 +131,45 @@ const TOTALS: ResultRow[] = [ }, ]; -interface CrossTabRow { +type Channel = (typeof CHANNELS)[number]; +type PivotMetric = 'orders' | 'revenue'; +type PivotValueKey = `${Channel}_${PivotMetric}`; + +type CrossTabRow = Record & { id: string; region: string; - organic: number; - paid: number; - email: number; - referral: number; - total: number; -} + total_orders: number; + total_revenue: number; +}; + +const PIVOT_VALUE_KEYS = CHANNELS.flatMap((channel) => [ + `${channel}_orders` as const, + `${channel}_revenue` as const, +]); const CROSS_TAB_ROWS: CrossTabRow[] = REGIONS.map((region) => { - const revenueByChannel = Object.fromEntries( - CHANNELS.map((channel) => [ - channel, - ROWS.filter( - (row) => row.region === region && row.channel === channel, - ).reduce((sum, row) => sum + row.revenue, 0), - ]), - ) as Record<(typeof CHANNELS)[number], number>; + const sourceRows = ROWS.filter((row) => row.region === region); + const pivotValues = Object.fromEntries( + PIVOT_VALUE_KEYS.map((key) => { + const separator = key.lastIndexOf('_'); + const channel = key.slice(0, separator) as Channel; + const metric = key.slice(separator + 1) as PivotMetric; + + return [ + key, + sourceRows + .filter((row) => row.channel === channel) + .reduce((sum, row) => sum + row[metric], 0), + ]; + }), + ) as Record; return { id: region, region, - organic: revenueByChannel.organic, - paid: revenueByChannel.paid, - email: revenueByChannel.email, - referral: revenueByChannel.referral, - total: Object.values(revenueByChannel).reduce( - (sum, value) => sum + value, - 0, - ), + ...pivotValues, + total_orders: sourceRows.reduce((sum, row) => sum + row.orders, 0), + total_revenue: sourceRows.reduce((sum, row) => sum + row.revenue, 0), }; }); @@ -169,7 +180,21 @@ const formatCurrency = (value: number) => maximumFractionDigits: 0, }); -const CROSS_TAB_COLUMNS: CubeDataTableColumn[] = [ +const formatInteger = (value: number) => value.toLocaleString(); + +const metricColumn = ( + channel: Channel | 'total', + metric: PivotMetric, +): CubeDataTableColumn => ({ + key: `${channel}_${metric}`, + title: metric === 'orders' ? 'Orders' : 'Revenue', + dataType: 'number', + minWidth: metric === 'orders' ? 115 : 140, + isSortable: true, + format: metric === 'orders' ? formatInteger : formatCurrency, +}); + +const CROSS_TAB_COLUMNS: CubeDataTableColumnDefinition[] = [ { key: 'region', title: 'Region', @@ -177,24 +202,27 @@ const CROSS_TAB_COLUMNS: CubeDataTableColumn[] = [ pin: 'start', isSortable: true, }, - ...CHANNELS.map( - (channel): CubeDataTableColumn => ({ - key: channel, - title: channel[0].toUpperCase() + channel.slice(1), - dataType: 'number', - minWidth: 140, - isSortable: true, - format: formatCurrency, - }), - ), + ...CHANNELS.map((channel) => ({ + key: channel, + title: channel[0].toUpperCase() + channel.slice(1), + children: [ + metricColumn(channel, 'orders'), + metricColumn(channel, 'revenue'), + ], + })), { key: 'total', title: 'Total', - dataType: 'number', - minWidth: 150, - isSortable: true, - color: 'note', - format: formatCurrency, + children: [ + { + ...metricColumn('total', 'orders'), + color: 'note', + }, + { + ...metricColumn('total', 'revenue'), + color: 'note', + }, + ], }, ]; @@ -202,11 +230,20 @@ const CROSS_TAB_TOTALS: CrossTabRow[] = [ { id: 'grand-total', region: 'Grand total', - organic: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.organic, 0), - paid: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.paid, 0), - email: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.email, 0), - referral: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.referral, 0), - total: CROSS_TAB_ROWS.reduce((sum, row) => sum + row.total, 0), + ...(Object.fromEntries( + PIVOT_VALUE_KEYS.map((key) => [ + key, + CROSS_TAB_ROWS.reduce((sum, row) => sum + row[key], 0), + ]), + ) as Record), + total_orders: CROSS_TAB_ROWS.reduce( + (sum, row) => sum + row.total_orders, + 0, + ), + total_revenue: CROSS_TAB_ROWS.reduce( + (sum, row) => sum + row.total_revenue, + 0, + ), }, ]; @@ -268,10 +305,10 @@ type PivotStory = StoryObj>; export const Default: Story = {}; /** - * DataTable renders an already-shaped pivot result as an ordinary flat table: - * the row field stays pinned, generated values become columns, and totals are - * regular pinned rows. The data/query layer owns the pivot calculation; the - * table keeps sorting, resizing, cell ranges and copy behavior generic. + * Nested column definitions turn generated pivot values into real multi-row + * headers. The rows are still an already-shaped result: the data/query layer + * owns the pivot calculation while the table keeps sorting, resizing, cell + * ranges and copy behavior generic. */ export const Pivot: PivotStory = { args: { @@ -279,8 +316,8 @@ export const Pivot: PivotStory = { columns: CROSS_TAB_COLUMNS, pinnedBottomRows: CROSS_TAB_TOTALS, paginationMode: 'off', - ariaLabel: 'Revenue by region and channel', - height: '260px', + ariaLabel: 'Orders and revenue by region and channel', + height: '300px', }, }; diff --git a/src/components/data/DataTable/DataTable.test.tsx b/src/components/data/DataTable/DataTable.test.tsx index 03d13e956..8e14a3bba 100644 --- a/src/components/data/DataTable/DataTable.test.tsx +++ b/src/components/data/DataTable/DataTable.test.tsx @@ -2,7 +2,10 @@ import { renderWithRoot, screen, userEvent, waitFor } from '../../../test'; import { DataTable } from './DataTable'; -import type { CubeDataTableColumn } from './types'; +import type { + CubeDataTableColumn, + CubeDataTableColumnDefinition, +} from './types'; interface Row { id: string; @@ -59,6 +62,107 @@ describe('DataTable', () => { ); }); + describe('column groups', () => { + const GROUPED_COLUMNS: CubeDataTableColumnDefinition[] = [ + { key: 'region', title: 'Region', isSortable: true }, + { + key: 'metrics', + title: 'Metrics', + children: [ + { + key: 'orders', + title: 'Orders', + dataType: 'number', + isSortable: true, + }, + { + key: 'orders-copy', + title: 'Orders copy', + getValue: (row) => row.orders, + dataType: 'number', + }, + ], + }, + ]; + + it('renders native spanning headers and counts both header rows', () => { + renderWithRoot(); + + expect(grid().querySelectorAll('thead tr')).toHaveLength(2); + expect( + screen.getByRole('columnheader', { name: 'Metrics' }), + ).toHaveAttribute('colspan', '2'); + expect( + screen.getByRole('columnheader', { name: 'Metrics' }), + ).toHaveAttribute('scope', 'colgroup'); + expect( + screen.getByRole('columnheader', { name: /^Region/ }), + ).toHaveAttribute('rowspan', '2'); + expect(grid()).toHaveAttribute('aria-rowcount', '6'); + }); + + it('keeps sorting on the generated leaf columns', async () => { + renderWithRoot(); + + await userEvent.click( + screen.getByRole('columnheader', { name: /^Orders Resize/ }), + ); + + expect(cellText('orders')).toEqual(['10', '20', '30', '40']); + }); + + it('supports nested header bands', () => { + const columns: CubeDataTableColumnDefinition[] = [ + { key: 'region', title: 'Region' }, + { + key: 'metrics', + title: 'Metrics', + children: [ + { + key: 'volume', + title: 'Volume', + children: [ + { key: 'orders', title: 'Orders' }, + { + key: 'orders-copy', + title: 'Orders copy', + getValue: (row) => row.orders, + }, + ], + }, + ], + }, + ]; + + renderWithRoot(); + + expect(grid().querySelectorAll('thead tr')).toHaveLength(3); + expect( + screen.getByRole('columnheader', { name: /^Region/ }), + ).toHaveAttribute('rowspan', '3'); + expect( + screen.getByRole('columnheader', { name: 'Volume' }), + ).toHaveAttribute('colspan', '2'); + }); + + it('warns and leaves grouped leaf columns out of drag reordering', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + renderWithRoot( + , + ); + + expect(warn).toHaveBeenCalledWith( + 'CubeUIKit:', + 'DataTable:', + '`isColumnReorderable` is ignored when columns contain header groups.', + ); + expect(grid().querySelector('[data-draggable]')).toBeNull(); + + warn.mockRestore(); + }); + }); + describe('multi-column sorting', () => { it('adds a column to the sort rather than replacing it', async () => { const onSortsChange = vi.fn(); diff --git a/src/components/data/DataTable/DataTable.tsx b/src/components/data/DataTable/DataTable.tsx index 009611723..cbf145029 100644 --- a/src/components/data/DataTable/DataTable.tsx +++ b/src/components/data/DataTable/DataTable.tsx @@ -42,16 +42,62 @@ import { useTableTreeState } from '../TableBase/use-table-tree-state'; import type { Key } from '@react-types/shared'; import type { ForwardedRef, ReactElement } from 'react'; import type { + CubeTableColumnGroupHeader, CubeTableColumnLayout, CubeTableRowSection, } from '../TableBase/types'; -import type { CubeDataTableColumn, CubeDataTableProps } from './types'; +import type { + CubeDataTableColumn, + CubeDataTableColumnDefinition, + CubeDataTableColumnGroup, + CubeDataTableProps, +} from './types'; /** Wide enough for five digits at the dense default. */ const ROW_NUMBER_WIDTH = 56; const EMPTY_WIDTHS: Record = {}; +interface DataTableColumnModel { + columns: CubeDataTableColumn[]; + headerPaths: Map; +} + +function isColumnGroup( + column: CubeDataTableColumnDefinition, +): column is CubeDataTableColumnGroup { + return 'children' in column; +} + +/** Flattens the public column tree while retaining each leaf's header path. */ +function buildColumnModel( + definitions: readonly CubeDataTableColumnDefinition[], +): DataTableColumnModel { + const columns: CubeDataTableColumn[] = []; + const headerPaths = new Map(); + + function visit( + entries: readonly CubeDataTableColumnDefinition[], + path: readonly CubeTableColumnGroupHeader[], + ) { + for (const entry of entries) { + if (isColumnGroup(entry)) { + visit(entry.children, [ + ...path, + { key: entry.key, title: entry.title }, + ]); + } else { + columns.push(entry); + if (path.length) headerPaths.set(entry.key, path); + } + } + } + + visit(definitions, []); + + return { columns, headerPaths }; +} + function defaultGetRowKey(rowKey: string) { return (row: T, index: number): Key => { const value = (row as any)?.[rowKey]; @@ -69,9 +115,9 @@ function defaultGetRowKey(rowKey: string) { * rows pinned for totals. * * It knows nothing about Cube. Measures, dimensions, pivots and drill-downs - * reach it as ordinary columns, `render` output and `column.header.menu` - * content, which is what keeps the ~34 kB of Cloud's column-header menu in - * Cloud. + * reach it as leaf columns, presentational column groups, `render` output and + * `column.header.menu` content, which is what keeps the ~34 kB of Cloud's + * column-header menu in Cloud. */ function DataTable( props: CubeDataTableProps, @@ -198,6 +244,9 @@ function DataTable( const storage = useTableStorage(storageKey); + const columnModel = useMemo(() => buildColumnModel(columns), [columns]); + const hasColumnGroups = columnModel.headerPaths.size > 0; + /** * `dataType` is presentational, so it is folded into the column here rather * than reaching the renderer: `number` right-aligns and takes tabular figures @@ -205,7 +254,7 @@ function DataTable( */ const resolvedColumns = useMemo( () => - columns.map((column) => { + columnModel.columns.map((column) => { const isNumeric = column.dataType === 'number'; return { @@ -221,7 +270,7 @@ function DataTable( : column.cellStyles, } as CubeDataTableColumn; }), - [columns], + [columnModel.columns], ); // Applied to the SOURCE columns, before `useTableColumns`, so hidden-column @@ -555,16 +604,26 @@ function DataTable( /* ── column reordering ────────────────────────────────────────────────── */ + useWarn(isColumnReorderable && hasColumnGroups, { + key: ['data-table-grouped-column-reorder'], + args: [ + 'DataTable:', + '`isColumnReorderable` is ignored when columns contain header groups.', + ], + }); + + const canReorderColumns = isColumnReorderable && !hasColumnGroups; + const headRowRef = useRef(null); const draggableColumnKeys = useMemo( - () => getDraggableColumnKeys(layout.columns, isColumnReorderable), - [layout.columns, isColumnReorderable], + () => getDraggableColumnKeys(layout.columns, canReorderColumns), + [layout.columns, canReorderColumns], ); const columnCollection = useMemo( () => new RowCollection( layout.columns.filter((column) => - isColumnDraggable(column, isColumnReorderable), + isColumnDraggable(column, canReorderColumns), ), (column) => column.key, new Set(), @@ -573,7 +632,7 @@ function DataTable( (column) => typeof column.title === 'string' ? column.title : column.key, ), - [layout.columns, isColumnReorderable], + [layout.columns, canReorderColumns], ); /** * `'single'`, not `'none'`. @@ -598,7 +657,7 @@ function DataTable( // One draggable column cannot be reordered, so the machinery stays unmounted // and the header keeps its exact DOM. const isColumnDragEnabled = - isColumnReorderable && draggableColumnKeys.length > 1; + canReorderColumns && draggableColumnKeys.length > 1; const treeRowNumberOffset = treeModel ? paginationMode === 'client' @@ -643,6 +702,7 @@ function DataTable( } totalRowCount={treeModel ? visibleRows.length : total} layout={layout} + columnHeaderPaths={columnModel.headerPaths} // Always on, unlike `ItemTable`. A result grid is read down a column, and // once the values are wide and right-aligned the rule is what keeps a // figure attached to its column — which is why Cloud's ag-grid theme sets diff --git a/src/components/data/DataTable/index.ts b/src/components/data/DataTable/index.ts index 6efe01b73..a751861e5 100644 --- a/src/components/data/DataTable/index.ts +++ b/src/components/data/DataTable/index.ts @@ -1,2 +1,7 @@ export { DataTable } from './DataTable'; -export type { CubeDataTableProps, CubeDataTableColumn } from './types'; +export type { + CubeDataTableProps, + CubeDataTableColumn, + CubeDataTableColumnGroup, + CubeDataTableColumnDefinition, +} from './types'; diff --git a/src/components/data/DataTable/types.ts b/src/components/data/DataTable/types.ts index b70505b94..f30c3e884 100644 --- a/src/components/data/DataTable/types.ts +++ b/src/components/data/DataTable/types.ts @@ -29,13 +29,31 @@ export interface CubeDataTableColumn extends CubeTableColumn { dataType?: 'string' | 'number' | 'boolean' | 'date' | 'unknown'; } +/** + * A header band spanning one or more DataTable columns. + * + * Groups are presentational: sorting, resizing, selection and the value + * pipeline continue to operate on the leaf columns in `children`. + */ +export interface CubeDataTableColumnGroup { + /** Stable identity within the column tree. */ + key: string; + title?: ReactNode; + children: CubeDataTableColumnDefinition[]; +} + +/** A leaf column or a nested header group. */ +export type CubeDataTableColumnDefinition = + | CubeDataTableColumn + | CubeDataTableColumnGroup; + export interface CubeDataTableProps extends BaseProps, ContainerStyleProps, CubeTableTreeProps { /* ── data ─────────────────────────────────────────────────────────── */ data: readonly T[]; - columns: CubeDataTableColumn[]; + columns: CubeDataTableColumnDefinition[]; rowKey?: string; getRowKey?: (row: T, index: number) => Key; @@ -74,6 +92,7 @@ export interface CubeDataTableProps rowSize?: CubeTableRowSize; /** An exact height in px, when none of the named steps is the answer. */ rowHeight?: number; + /** Exact height of each header row in px. */ headerHeight?: number; /** @default true — banding is what makes a wide row readable across. */ isStriped?: boolean; diff --git a/src/components/data/TableBase/TableView.tsx b/src/components/data/TableBase/TableView.tsx index 81313c828..bb646ac96 100644 --- a/src/components/data/TableBase/TableView.tsx +++ b/src/components/data/TableBase/TableView.tsx @@ -82,6 +82,7 @@ import type { TableTreeNode } from './table-tree'; import type { CubeResolvedColumn, CubeTableCellContext, + CubeTableColumnGroupHeader, CubeTableColumnLayout, CubeTableHeaderContext, CubeTableLoadingIndicator, @@ -111,6 +112,11 @@ export interface TableViewProps { rowKeys?: readonly Key[]; getRowKey: (row: T, index: number) => Key; layout: CubeTableColumnLayout; + /** Group path for each leaf column, from the outermost header inward. */ + columnHeaderPaths?: ReadonlyMap< + string, + readonly CubeTableColumnGroupHeader[] + >; /** * Receives the scroll container once it exists. A callback rather than a ref * because the virtualized path does not create the element itself — Virtuoso @@ -592,6 +598,7 @@ export function TableView(props: TableViewProps) { rowKeys, getRowKey, layout, + columnHeaderPaths, onScrollerRef, rootRef, size = 'medium', @@ -674,6 +681,11 @@ export function TableView(props: TableViewProps) { const { columns } = layout; const columnCount = columns.length; + const headerGroupDepth = columns.reduce( + (depth, column) => + Math.max(depth, columnHeaderPaths?.get(column.key)?.length ?? 0), + 0, + ); /** * `rowSize` resolved to the size scale, or `null` when it was not given. @@ -729,7 +741,7 @@ export function TableView(props: TableViewProps) { ); // Header rows count towards `aria-rowcount`, which must be document-absolute. - const headerRowCount = isHeaderHidden ? 0 : 1; + const headerRowCount = isHeaderHidden ? 0 : headerGroupDepth + 1; /* ── the ARIA row index space ─────────────────────────────────────────── * Four bands, in the order a screen reader walks them: @@ -808,6 +820,14 @@ export function TableView(props: TableViewProps) { }, } : null), + ...(headerPreset || headerCellStyles + ? { + HeaderGroupCell: { + ...(headerPreset ? { preset: headerPreset } : null), + ...headerCellStyles, + }, + } + : null), ...(bodyStyles ? { Body: bodyStyles } : null), ...(rowStyles ? { Row: rowStyles } : null), ...(cellStyles || tints.cellStyles @@ -1030,7 +1050,10 @@ export function TableView(props: TableViewProps) { )); } - function renderSelectionHeaderCell(column: CubeResolvedColumn) { + function renderSelectionHeaderCell( + column: CubeResolvedColumn, + rowSpan = 1, + ) { return ( { - // `DraggableCollection`'s Alt+Arrow reorder sits on this row and fires - // in the capture phase, so it would beat the resize handle's own - // Alt+Arrow bubble handler and move the column instead of sizing it. + function pathsSharePrefix( + left: readonly CubeTableColumnGroupHeader[], + right: readonly CubeTableColumnGroupHeader[], + depth: number, + ) { + for (let index = 0; index <= depth; index++) { + if (left[index]?.key !== right[index]?.key) return false; + } + + return true; + } + + function renderHeaderGroupCell( + group: CubeTableColumnGroupHeader, + segment: readonly CubeResolvedColumn[], + depth: number, + ) { + const first = segment[0]; + const last = segment[segment.length - 1]; + const pin = segment.every((column) => column.pin === first.pin) + ? first.pin + : undefined; + const pinOffset = + pin === 'start' ? first.pinOffset : pin === 'end' ? last.pinOffset : null; + + return ( + + ); + } + + function renderHeaderRowCells(depth: number) { + const cells: ReactNode[] = []; + + for (let columnIndex = 0; columnIndex < columns.length; ) { + const column = columns[columnIndex]; + const path = columnHeaderPaths?.get(column.key) ?? []; + + // This column's leaf header began on an earlier row and spans this one. + if (path.length < depth) { + columnIndex++; + continue; + } + + if (path.length === depth) { + cells.push(renderHeaderCell(column, headerRowCount - depth)); + columnIndex++; + continue; + } + + let segmentEnd = columnIndex + 1; + + while (segmentEnd < columns.length) { + const nextPath = columnHeaderPaths?.get(columns[segmentEnd].key) ?? []; + if ( - (event.target as HTMLElement)?.closest?.('[data-element="Resizer"]') + nextPath.length <= depth || + columns[segmentEnd].pin !== column.pin || + !pathsSharePrefix(path, nextPath, depth) ) { - return; + break; } - (headCollectionProps as any)?.onKeyDownCapture?.(event); - }} - > - {columns.map(renderHeaderCell)} - - ); + segmentEnd++; + } + + cells.push( + renderHeaderGroupCell( + path[depth], + columns.slice(columnIndex, segmentEnd), + depth, + ), + ); + columnIndex = segmentEnd; + } + + return cells; + } + + const headerRows = isHeaderHidden + ? null + : Array.from({ length: headerRowCount }, (_, depth) => { + const isLeafRow = depth === headerRowCount - 1; + + return ( + { + // `DraggableCollection`'s Alt+Arrow reorder sits on this + // row and fires in capture, so it would beat the resize + // handle's own Alt+Arrow bubble handler. + if ( + (event.target as HTMLElement)?.closest?.( + '[data-element="Resizer"]', + ) + ) { + return; + } + + (headCollectionProps as any)?.onKeyDownCapture?.(event); + } + : undefined + } + > + {renderHeaderRowCells(depth)} + + ); + }); const tableProps = { 'data-element': 'Table', @@ -2355,7 +2495,7 @@ export function TableView(props: TableViewProps) { const tableContent = ( <> {colGroup} - {isHeaderHidden ? null : {headerRow}} + {isHeaderHidden ? null : {headerRows}} {pinnedTopRows?.map((row, index) => renderPinnedRow(row, index, 'top'))} {bodyContent} diff --git a/src/components/data/TableBase/styled.ts b/src/components/data/TableBase/styled.ts index 978b3794e..f747ced7f 100644 --- a/src/components/data/TableBase/styled.ts +++ b/src/components/data/TableBase/styled.ts @@ -337,6 +337,20 @@ export const TableElement = tasty({ '#row-text': '#dark-03', $dim: 1, }, + HeaderGroupCell: { + $: '> Scroller > Table > Head > HeadRow >', + ...CELL_STYLES, + padding: 0, + paddingInline: 0, + height: '($header-height + 1bw)', + preset: 't3m', + border: '1bw #border bottom, $column-divider #border right', + fill: '#surface-3', + userSelect: 'none', + position: { '': 'relative', '@own(pin=start | pin=end)': 'sticky' }, + overflow: 'hidden', + zIndex: { '': 'auto', '@own(pin=start | pin=end)': 3 }, + }, HeaderCell: { $: '> Scroller > Table > Head > HeadRow >', ...CELL_STYLES, diff --git a/src/components/data/TableBase/types.ts b/src/components/data/TableBase/types.ts index 03a8527a9..a59e9f058 100644 --- a/src/components/data/TableBase/types.ts +++ b/src/components/data/TableBase/types.ts @@ -139,6 +139,12 @@ export interface CubeTableHeaderContext { width: number | null; } +/** Presentational path entry used to build multi-row column headers. */ +export interface CubeTableColumnGroupHeader { + key: string; + title?: ReactNode; +} + /* ── column ──────────────────────────────────────────────────────────────── */ /** diff --git a/src/components/data/index.ts b/src/components/data/index.ts index 308bb45c7..c231e959c 100644 --- a/src/components/data/index.ts +++ b/src/components/data/index.ts @@ -1,5 +1,10 @@ export { DataTable } from './DataTable'; -export type { CubeDataTableProps, CubeDataTableColumn } from './DataTable'; +export type { + CubeDataTableProps, + CubeDataTableColumn, + CubeDataTableColumnGroup, + CubeDataTableColumnDefinition, +} from './DataTable'; export { ItemTable } from './ItemTable'; export type { CubeItemTableProps, CubeItemTableColumn } from './ItemTable'; diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx index 898fafe55..ca93a67fc 100644 --- a/src/stories/Usage.docs.mdx +++ b/src/stories/Usage.docs.mdx @@ -424,6 +424,11 @@ sub-elements. ItemTable multiple selection cascades by default; DataTable cell ranges contain visible rows only. See each component's docs for pagination, numbering, selection, and drag/drop limits. +DataTable also accepts nested column definitions for already-shaped pivot +results. Group nodes create accessible multi-row headers with native column +spans, while sorting, resizing, ranges and copying continue to operate on their +leaf columns. + ## Form System ### Form Component From 914c99bf1e6fa28c941b031b95a108e176178ef4 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 21 Aug 2026 10:52:26 +0200 Subject: [PATCH 7/8] feat(DataTable): add auto height mode --- .changeset/tables-grow-branches.md | 2 +- .../data/DataTable/DataTable.browser.test.tsx | 56 +++++++++++++++++++ .../data/DataTable/DataTable.docs.mdx | 21 ++++++- .../data/DataTable/DataTable.stories.tsx | 8 ++- .../data/DataTable/DataTable.test.tsx | 35 ++++++++++++ src/components/data/DataTable/DataTable.tsx | 23 ++++++-- src/components/data/DataTable/types.ts | 8 +++ src/eslint-plugin/defaults.generated.ts | 1 + src/stories/Usage.docs.mdx | 4 +- 9 files changed, 147 insertions(+), 11 deletions(-) diff --git a/.changeset/tables-grow-branches.md b/.changeset/tables-grow-branches.md index d6ec69965..569709f3b 100644 --- a/.changeset/tables-grow-branches.md +++ b/.changeset/tables-grow-branches.md @@ -2,4 +2,4 @@ '@cube-dev/ui-kit': minor --- -Add accessible nested tree rows, expansion state, hierarchy-aware operations, cascading ItemTable selection, and grouped DataTable headers for pivoted results. +Add accessible nested tree rows, expansion state, hierarchy-aware operations, cascading ItemTable selection, grouped DataTable headers for pivoted results, and intrinsic DataTable sizing with `isAutoHeight`. diff --git a/src/components/data/DataTable/DataTable.browser.test.tsx b/src/components/data/DataTable/DataTable.browser.test.tsx index 5bc616d65..65f6722af 100644 --- a/src/components/data/DataTable/DataTable.browser.test.tsx +++ b/src/components/data/DataTable/DataTable.browser.test.tsx @@ -38,6 +38,62 @@ const scroller = () => document.querySelector('[data-element="Scroller"]')!; describe('DataTable layout', () => { + it('fills a flex pane by default', async () => { + renderWithRoot( +
+ +
, + ); + + const frame = screen.getByTestId('SizingTable'); + + await vi.waitFor(() => + expect(Math.round(frame.getBoundingClientRect().height)).toBe(420), + ); + }); + + it('shrink-wraps rows in auto-height mode despite a supplied height', async () => { + renderWithRoot( +
+ +
, + ); + + const frame = screen.getByTestId('SizingTable'); + + await vi.waitFor(() => + expect(frame.getBoundingClientRect().height).toBeGreaterThan(0), + ); + + const frameRect = frame.getBoundingClientRect(); + const tableRect = grid().getBoundingClientRect(); + + expect(frameRect.height).toBeLessThan(300); + // The card/frame edge may contribute one border pixel; there must not be a + // leftover flexible track below the final row. + expect(Math.abs(frameRect.bottom - tableRect.bottom)).toBeLessThanOrEqual( + 2, + ); + }); + it('does not overflow horizontally when the columns fit', async () => { renderWithRoot( ; +; ``` The data/query layer still owns the pivot calculation. DataTable only renders @@ -339,6 +342,20 @@ competing for a position. Above `virtualizeThreshold` a bounded grid virtualizes on its own, so `paginationMode="off"` is a reasonable default for a result the user scrolls. +For a compact result that should participate in normal page flow, set +`isAutoHeight`. The frame then ends at the last rendered row rather than filling +its flex pane. This renders the complete result and disables virtualization, so +keep larger results bounded instead. + +```jsx + +``` + ### Sorting Moves Rows diff --git a/src/components/data/DataTable/DataTable.stories.tsx b/src/components/data/DataTable/DataTable.stories.tsx index 3d8ac5142..1027c4926 100644 --- a/src/components/data/DataTable/DataTable.stories.tsx +++ b/src/components/data/DataTable/DataTable.stories.tsx @@ -270,6 +270,12 @@ const meta: Meta = { options: ['small', 'medium', 'large'], description: 'Row height: 28px / 32px / 40px.', }, + isAutoHeight: { + control: 'boolean', + description: + 'Shrink-wrap the frame to its rows instead of filling a flex pane.', + table: { defaultValue: { summary: 'false' } }, + }, isColumnReorderable: { control: 'boolean', description: 'Drag column headers sideways to reorder them.', @@ -317,7 +323,7 @@ export const Pivot: PivotStory = { pinnedBottomRows: CROSS_TAB_TOTALS, paginationMode: 'off', ariaLabel: 'Orders and revenue by region and channel', - height: '300px', + isAutoHeight: true, }, }; diff --git a/src/components/data/DataTable/DataTable.test.tsx b/src/components/data/DataTable/DataTable.test.tsx index 8e14a3bba..28da8cfd6 100644 --- a/src/components/data/DataTable/DataTable.test.tsx +++ b/src/components/data/DataTable/DataTable.test.tsx @@ -774,6 +774,41 @@ describe('DataTable', () => { grid().querySelectorAll('[data-element="Resizer"]').length, ).toBeGreaterThan(0); }); + + it('disables virtualization in auto-height mode', () => { + const rows = Array.from({ length: 60 }, (_, index) => ({ + id: `row-${index}`, + region: `region-${index}`, + orders: index, + })); + const { rerender } = renderWithRoot( + , + ); + + expect(bodyRows().every((row) => row.hasAttribute('data-index'))).toBe( + true, + ); + + rerender( + , + ); + + expect(bodyRows()).toHaveLength(60); + expect(bodyRows().some((row) => row.hasAttribute('data-index'))).toBe( + false, + ); + }); }); describe('DataTable rowSize', () => { diff --git a/src/components/data/DataTable/DataTable.tsx b/src/components/data/DataTable/DataTable.tsx index cbf145029..3d89b69d4 100644 --- a/src/components/data/DataTable/DataTable.tsx +++ b/src/components/data/DataTable/DataTable.tsx @@ -152,6 +152,7 @@ function DataTable( isStriped = true, isHeaderHidden, isHeaderSticky, + isAutoHeight = false, showRowNumbers = false, sortMode, sorts: sortsProp, @@ -577,11 +578,19 @@ function DataTable( onRangeChange: onCellRangeChange, }); - // Same as `ItemTable`: style props (`height`, `maxHeight`, `margin`, …) land - // on the root frame. `height` is the one that matters — there is no - // page-scroll mode, so bounding the grid is what turns the body into a - // scroller, pins the header, and lets virtualization engage at all. - const rootStyles = { ...extractStyles(props, CONTAINER_STYLES), ...styles }; + // A result grid normally occupies the flex pane it is given. `min-height: 0` + // is the part that lets the scroller become smaller than its contents rather + // than forcing the pane itself taller. Auto-height deliberately reverses + // that contract: its rows determine the frame height, even when a shared + // wrapper supplied a fixed `height` style. + const rootStyles = { + height: 'min 0', + flexGrow: 1, + flexShrink: 1, + ...extractStyles(props, CONTAINER_STYLES), + ...styles, + ...(isAutoHeight ? { height: 'min 0', flexGrow: 0, flexShrink: 0 } : null), + }; const smallestPageSize = pageSizeOptions?.length ? Math.min(...pageSizeOptions) @@ -746,7 +755,9 @@ function DataTable( onColumnMenuAction={onColumnMenuAction} columnMenuTriggerProps={columnMenuTriggerProps} columnMenuProps={columnMenuProps} - isVirtualized={isVirtualized} + // Virtualization needs a bounded scrollport. In auto-height mode the + // whole result is the viewport, so render every row by definition. + isVirtualized={isAutoHeight ? false : isVirtualized} virtualizeThreshold={virtualizeThreshold} overscan={overscan} isResizable={isResizable} diff --git a/src/components/data/DataTable/types.ts b/src/components/data/DataTable/types.ts index f30c3e884..ef328403a 100644 --- a/src/components/data/DataTable/types.ts +++ b/src/components/data/DataTable/types.ts @@ -98,6 +98,14 @@ export interface CubeDataTableProps isStriped?: boolean; isHeaderHidden?: boolean; isHeaderSticky?: boolean; + /** + * Shrink-wraps the frame to its rendered rows instead of filling the + * available flex pane. This mode disables virtualization because there is no + * bounded vertical viewport to virtualize against. + * + * @default false + */ + isAutoHeight?: boolean; /** * Slide rows to their new positions when the sort changes, instead of * teleporting them. @default true diff --git a/src/eslint-plugin/defaults.generated.ts b/src/eslint-plugin/defaults.generated.ts index 97258ed1f..16ae88e5a 100644 --- a/src/eslint-plugin/defaults.generated.ts +++ b/src/eslint-plugin/defaults.generated.ts @@ -180,6 +180,7 @@ export const DEFAULTS: DefaultsRegistry = { cellSelectionMode: { kind: 'default', value: 'range' }, columnContextMenu: { kind: 'default', value: true }, defaultPageSize: { kind: 'default', value: 100 }, + isAutoHeight: { kind: 'default', value: false }, isColumnReorderable: { kind: 'default', value: false }, isHeaderSticky: { kind: 'default', value: true }, isResizable: { kind: 'default', value: true }, diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx index ca93a67fc..4698aa308 100644 --- a/src/stories/Usage.docs.mdx +++ b/src/stories/Usage.docs.mdx @@ -427,7 +427,9 @@ numbering, selection, and drag/drop limits. DataTable also accepts nested column definitions for already-shaped pivot results. Group nodes create accessible multi-row headers with native column spans, while sorting, resizing, ranges and copying continue to operate on their -leaf columns. +leaf columns. It fills an available flex pane by default; use `isAutoHeight` for +a compact result whose frame should end at its last rendered row. Auto-height +renders every row, so bounded tables remain the right choice for large results. ## Form System From bdcaf1aca8f50bf01cb93f618de8384c9fe5e3db Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 21 Aug 2026 10:59:47 +0200 Subject: [PATCH 8/8] feat(ItemTable): support auto height --- .changeset/tables-grow-branches.md | 2 +- src/components/data/DataTable/DataTable.tsx | 19 ++---------- src/components/data/DataTable/types.ts | 10 ++----- .../data/ItemTable/ItemTable.browser.test.tsx | 29 +++++++++++++++++++ .../data/ItemTable/ItemTable.docs.mdx | 16 +++++++++- .../data/ItemTable/ItemTable.stories.tsx | 24 +++++++++++++++ src/components/data/ItemTable/ItemTable.tsx | 2 ++ .../ItemTable.virtualization.test.tsx | 15 ++++++++++ src/components/data/ItemTable/types.ts | 2 ++ src/components/data/TableBase/TableView.tsx | 16 +++++++++- src/components/data/TableBase/index.ts | 1 + src/components/data/TableBase/types.ts | 12 ++++++++ src/components/data/index.ts | 1 + src/eslint-plugin/defaults.generated.ts | 1 + src/stories/Usage.docs.mdx | 7 +++-- 15 files changed, 127 insertions(+), 30 deletions(-) diff --git a/.changeset/tables-grow-branches.md b/.changeset/tables-grow-branches.md index 569709f3b..269006eda 100644 --- a/.changeset/tables-grow-branches.md +++ b/.changeset/tables-grow-branches.md @@ -2,4 +2,4 @@ '@cube-dev/ui-kit': minor --- -Add accessible nested tree rows, expansion state, hierarchy-aware operations, cascading ItemTable selection, grouped DataTable headers for pivoted results, and intrinsic DataTable sizing with `isAutoHeight`. +Add accessible nested tree rows, expansion state, hierarchy-aware operations, cascading ItemTable selection, grouped DataTable headers for pivoted results, and intrinsic table sizing with `isAutoHeight`. diff --git a/src/components/data/DataTable/DataTable.tsx b/src/components/data/DataTable/DataTable.tsx index 3d89b69d4..77fe1513a 100644 --- a/src/components/data/DataTable/DataTable.tsx +++ b/src/components/data/DataTable/DataTable.tsx @@ -578,19 +578,7 @@ function DataTable( onRangeChange: onCellRangeChange, }); - // A result grid normally occupies the flex pane it is given. `min-height: 0` - // is the part that lets the scroller become smaller than its contents rather - // than forcing the pane itself taller. Auto-height deliberately reverses - // that contract: its rows determine the frame height, even when a shared - // wrapper supplied a fixed `height` style. - const rootStyles = { - height: 'min 0', - flexGrow: 1, - flexShrink: 1, - ...extractStyles(props, CONTAINER_STYLES), - ...styles, - ...(isAutoHeight ? { height: 'min 0', flexGrow: 0, flexShrink: 0 } : null), - }; + const rootStyles = { ...extractStyles(props, CONTAINER_STYLES), ...styles }; const smallestPageSize = pageSizeOptions?.length ? Math.min(...pageSizeOptions) @@ -740,6 +728,7 @@ function DataTable( isRowMoveAnimated={isRowMoveAnimated} isHeaderHidden={isHeaderHidden} isHeaderSticky={isHeaderSticky} + isAutoHeight={isAutoHeight} // The header shows the column name at body size — a result grid has no // room for the uppercase caption a list header uses. // `t4` throughout: a result grid is read as a block, and one step down @@ -755,9 +744,7 @@ function DataTable( onColumnMenuAction={onColumnMenuAction} columnMenuTriggerProps={columnMenuTriggerProps} columnMenuProps={columnMenuProps} - // Virtualization needs a bounded scrollport. In auto-height mode the - // whole result is the viewport, so render every row by definition. - isVirtualized={isAutoHeight ? false : isVirtualized} + isVirtualized={isVirtualized} virtualizeThreshold={virtualizeThreshold} overscan={overscan} isResizable={isResizable} diff --git a/src/components/data/DataTable/types.ts b/src/components/data/DataTable/types.ts index ef328403a..2a9e9880a 100644 --- a/src/components/data/DataTable/types.ts +++ b/src/components/data/DataTable/types.ts @@ -6,6 +6,7 @@ import type { CubeTablePageInfo } from '../../navigation/Pagination'; import type { CubeTableCellRange, CubeTableColumn, + CubeTableLayoutProps, CubeTableLoadingIndicator, CubeTableRowContext, CubeTableRowSection, @@ -50,6 +51,7 @@ export type CubeDataTableColumnDefinition = export interface CubeDataTableProps extends BaseProps, ContainerStyleProps, + CubeTableLayoutProps, CubeTableTreeProps { /* ── data ─────────────────────────────────────────────────────────── */ data: readonly T[]; @@ -98,14 +100,6 @@ export interface CubeDataTableProps isStriped?: boolean; isHeaderHidden?: boolean; isHeaderSticky?: boolean; - /** - * Shrink-wraps the frame to its rendered rows instead of filling the - * available flex pane. This mode disables virtualization because there is no - * bounded vertical viewport to virtualize against. - * - * @default false - */ - isAutoHeight?: boolean; /** * Slide rows to their new positions when the sort changes, instead of * teleporting them. @default true diff --git a/src/components/data/ItemTable/ItemTable.browser.test.tsx b/src/components/data/ItemTable/ItemTable.browser.test.tsx index b32a5b44f..b334c2eed 100644 --- a/src/components/data/ItemTable/ItemTable.browser.test.tsx +++ b/src/components/data/ItemTable/ItemTable.browser.test.tsx @@ -62,6 +62,35 @@ const nextFrame = () => * bug that shipped into this component and was found by hand. */ describe('ItemTable layout', () => { + it('shrink-wraps rows in auto-height mode despite a supplied height', async () => { + renderWithRoot( +
+ +
, + ); + + const frame = screen.getByTestId('SizingTable'); + + await vi.waitFor(() => + expect(frame.getBoundingClientRect().height).toBeGreaterThan(0), + ); + + const frameRect = frame.getBoundingClientRect(); + const tableRect = grid().getBoundingClientRect(); + + expect(frameRect.height).toBeLessThan(300); + expect(Math.abs(frameRect.bottom - tableRect.bottom)).toBeLessThanOrEqual( + 2, + ); + }); + it('resolves column widths from the container', async () => { renderWithRoot(); diff --git a/src/components/data/ItemTable/ItemTable.docs.mdx b/src/components/data/ItemTable/ItemTable.docs.mdx index 6d9166a52..8f2dc1c15 100644 --- a/src/components/data/ItemTable/ItemTable.docs.mdx +++ b/src/components/data/ItemTable/ItemTable.docs.mdx @@ -80,6 +80,9 @@ pivots, totals. Use `ListBox` when there is only one column. row and drops it from `aria-rowcount`. - **`isHeaderSticky`** `boolean` (default: `true`) — Pins the column header while the body scrolls. +- **`isAutoHeight`** `boolean` (default: `false`) — Shrink-wraps the frame to + the rendered rows instead of filling its flex pane. It takes precedence over + root height/flex sizing and disables virtualization. - **`sortMode`** `'client' | 'server' | 'off'` (default: `'client'` when any column is sortable, else `'off'`) — `client` reorders `data` itself; `server` only reflects `sort` and fires `onSortChange`; `off` removes the affordance. @@ -111,7 +114,8 @@ pivots, totals. Use `ListBox` when there is only one column. toolbar. - **`isVirtualized`** `boolean | 'auto'` (default: `'auto'`) — `'auto'` turns virtualization on once the row count passes `virtualizeThreshold`. It needs a - bounded height to have a viewport to work against. + bounded height to have a viewport to work against and is ignored when + `isAutoHeight` is enabled. - **`virtualizeThreshold`** `number` (default: `50`) — Row count above which `'auto'` virtualizes. - **`overscan`** `number` (default: `12`) — Rows mounted beyond each viewport @@ -755,6 +759,16 @@ DOM and the styles are identical either way — only the number of mounted rows changes, so sorting, search, pinned columns and the sticky header all behave the same. +For a compact list in normal page flow, set `isAutoHeight`. Its frame then ends +at the final rendered row instead of filling the surrounding flex pane. The +complete result is rendered, so keep large lists bounded and virtualized. + +```jsx + +``` + + + ```jsx ``` diff --git a/src/components/data/ItemTable/ItemTable.stories.tsx b/src/components/data/ItemTable/ItemTable.stories.tsx index 18e7768c6..75a443fc3 100644 --- a/src/components/data/ItemTable/ItemTable.stories.tsx +++ b/src/components/data/ItemTable/ItemTable.stories.tsx @@ -221,6 +221,15 @@ const meta = { type: { summary: 'boolean' }, }, }, + isAutoHeight: { + control: 'boolean', + description: + 'Shrink-wrap the frame to its rows instead of filling a flex pane.', + table: { + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, /* State */ isLoading: { @@ -1267,6 +1276,21 @@ export const Virtualized: Story = { }, }; +/** A compact result can end at its final row inside a taller flex pane. */ +export const AutoHeight: Story = { + render: (args) => ( +
+ +
+ ), + args: { + data: DEPLOYMENTS.slice(0, 4), + shape: 'card', + paginationMode: 'off', + isAutoHeight: true, + }, +}; + const LOREM = 'Cube Cloud runs the semantic layer, the API and the caching tier as one managed service so teams can ship metrics without operating infrastructure themselves.'; diff --git a/src/components/data/ItemTable/ItemTable.tsx b/src/components/data/ItemTable/ItemTable.tsx index 89aa40595..172320e48 100644 --- a/src/components/data/ItemTable/ItemTable.tsx +++ b/src/components/data/ItemTable/ItemTable.tsx @@ -167,6 +167,7 @@ function ItemTable( isStriped = false, isHeaderHidden = false, isHeaderSticky = true, + isAutoHeight = false, isVirtualized = 'auto', virtualizeThreshold = 50, overscan = 20, @@ -792,6 +793,7 @@ function ItemTable( headerHeight={headerHeight} isHeaderHidden={isHeaderHidden} isHeaderSticky={isHeaderSticky} + isAutoHeight={isAutoHeight} isVirtualized={isVirtualized} virtualizeThreshold={virtualizeThreshold} overscan={overscan} diff --git a/src/components/data/ItemTable/ItemTable.virtualization.test.tsx b/src/components/data/ItemTable/ItemTable.virtualization.test.tsx index a356badb0..53f1b28f6 100644 --- a/src/components/data/ItemTable/ItemTable.virtualization.test.tsx +++ b/src/components/data/ItemTable/ItemTable.virtualization.test.tsx @@ -139,6 +139,21 @@ describe('ItemTable virtualization', () => { expect(isVirtualizedPath()).toBe(true); }); + it('turns virtualization off in auto-height mode', () => { + renderWithRoot( + , + ); + + expect(bodyRows()).toHaveLength(100); + expect(isVirtualizedPath()).toBe(false); + }); + it('honours a custom threshold', () => { renderWithRoot( = CubeTableColumn; export interface CubeItemTableProps extends BaseProps, ContainerStyleProps, + CubeTableLayoutProps, CubeTableTreeProps { /* ── data ─────────────────────────────────────────────────────────── */ data: readonly T[]; diff --git a/src/components/data/TableBase/TableView.tsx b/src/components/data/TableBase/TableView.tsx index bb646ac96..76a2a5751 100644 --- a/src/components/data/TableBase/TableView.tsx +++ b/src/components/data/TableBase/TableView.tsx @@ -85,6 +85,7 @@ import type { CubeTableColumnGroupHeader, CubeTableColumnLayout, CubeTableHeaderContext, + CubeTableLayoutProps, CubeTableLoadingIndicator, CubeTableRowContext, CubeTableRowSection, @@ -105,7 +106,7 @@ export interface CubeTableRowRenderProps { tooltip?: string; } -export interface TableViewProps { +export interface TableViewProps extends CubeTableLayoutProps { qa?: string; rows: readonly T[]; /** Stable keys for `rows`; required when a tree was flattened for display. */ @@ -611,6 +612,7 @@ export function TableView(props: TableViewProps) { hasColumnDividers, isRowMoveAnimated = true, isHeaderSticky = true, + isAutoHeight = false, isVirtualized = 'auto', virtualizeThreshold = 50, overscan = 20, @@ -806,7 +808,17 @@ export function TableView(props: TableViewProps) { const mergedStyles = useMemo( () => ({ + // A table normally fills its flex pane. `min-height: 0` lets the scroller + // become smaller than its contents instead of forcing that pane taller. + height: 'min 0', + flexGrow: 1, + flexShrink: 1, ...styles, + // Auto-height is a layout mode rather than a styling suggestion: its + // rows determine the frame even when a wrapper supplied fixed sizing. + ...(isAutoHeight + ? { height: 'min 0', flexGrow: 0, flexShrink: 0 } + : null), ...(contentPreset ? { Table: { preset: contentPreset } } : null), ...(headerStyles ? { HeadRow: headerStyles } : null), ...(headerPreset || headerCellStyles || tints.headerCellStyles @@ -845,6 +857,7 @@ export function TableView(props: TableViewProps) { }), [ styles, + isAutoHeight, headerStyles, headerCellStyles, headerPreset, @@ -1921,6 +1934,7 @@ export function TableView(props: TableViewProps) { const showBlank = isLoading && !hasRows && loadingIndicator === 'none'; const shouldVirtualize = + !isAutoHeight && hasRows && error == null && (isVirtualized === true || diff --git a/src/components/data/TableBase/index.ts b/src/components/data/TableBase/index.ts index d87472901..cfddd1636 100644 --- a/src/components/data/TableBase/index.ts +++ b/src/components/data/TableBase/index.ts @@ -26,6 +26,7 @@ export type { CubeTableColumnHeader, CubeTableColumnLayout, CubeTableHeaderContext, + CubeTableLayoutProps, CubeTableRowContext, CubeTableRowSection, CubeTableRowSize, diff --git a/src/components/data/TableBase/types.ts b/src/components/data/TableBase/types.ts index a59e9f058..d66f24121 100644 --- a/src/components/data/TableBase/types.ts +++ b/src/components/data/TableBase/types.ts @@ -67,6 +67,18 @@ export interface CubeTableRowExpandInfo { expanded: boolean; } +/** Shared root sizing contract used by both table adapters. */ +export interface CubeTableLayoutProps { + /** + * Shrink-wraps the frame to its rendered rows instead of filling the + * available flex pane. This mode disables virtualization because there is no + * bounded vertical viewport to virtualize against. + * + * @default false + */ + isAutoHeight?: boolean; +} + /** Shared opt-in hierarchy contract used by both table adapters. */ export interface CubeTableTreeProps { /** diff --git a/src/components/data/index.ts b/src/components/data/index.ts index c231e959c..0279bd512 100644 --- a/src/components/data/index.ts +++ b/src/components/data/index.ts @@ -21,6 +21,7 @@ export type { CubeTableColumn, CubeTableColumnHeader, CubeTableHeaderContext, + CubeTableLayoutProps, CubeTableRowContext, CubeTableRowSection, CubeTableRowSize, diff --git a/src/eslint-plugin/defaults.generated.ts b/src/eslint-plugin/defaults.generated.ts index 16ae88e5a..8532dd5fe 100644 --- a/src/eslint-plugin/defaults.generated.ts +++ b/src/eslint-plugin/defaults.generated.ts @@ -442,6 +442,7 @@ export const DEFAULTS: DefaultsRegistry = { defaultPage: { kind: 'default', value: 1 }, defaultPageSize: { kind: 'default', value: 50 }, headerPreset: { kind: 'default', value: 'c3' }, + isAutoHeight: { kind: 'default', value: false }, isHeaderHidden: { kind: 'default', value: false }, isHeaderSticky: { kind: 'default', value: true }, isLoading: { kind: 'default', value: false }, diff --git a/src/stories/Usage.docs.mdx b/src/stories/Usage.docs.mdx index 4698aa308..ee8904a01 100644 --- a/src/stories/Usage.docs.mdx +++ b/src/stories/Usage.docs.mdx @@ -427,9 +427,10 @@ numbering, selection, and drag/drop limits. DataTable also accepts nested column definitions for already-shaped pivot results. Group nodes create accessible multi-row headers with native column spans, while sorting, resizing, ranges and copying continue to operate on their -leaf columns. It fills an available flex pane by default; use `isAutoHeight` for -a compact result whose frame should end at its last rendered row. Auto-height -renders every row, so bounded tables remain the right choice for large results. +leaf columns. Both tables fill an available flex pane by default; use +`isAutoHeight` for a compact result whose frame should end at its last rendered +row. Auto-height renders every row, so bounded tables remain the right choice +for large results. ## Form System
(props: TableViewProps) { data-last-column={lastColumnFlag(column)} role="columnheader" scope="col" + rowSpan={rowSpan > 1 ? rowSpan : undefined} tabIndex={-1} aria-colindex={column.ariaColIndex} style={pinStyle(column)} @@ -1173,9 +1197,9 @@ export function TableView(props: TableViewProps) { ); } - function renderHeaderCell(column: CubeResolvedColumn) { + function renderHeaderCell(column: CubeResolvedColumn, rowSpan = 1) { if (column.key === SELECTION_COLUMN_KEY) { - return renderSelectionHeaderCell(column); + return renderSelectionHeaderCell(column, rowSpan); } if (column.key === ROW_NUMBER_COLUMN_KEY) { @@ -1190,6 +1214,7 @@ export function TableView(props: TableViewProps) { data-last-column={lastColumnFlag(column)} role="columnheader" scope="col" + rowSpan={rowSpan > 1 ? rowSpan : undefined} tabIndex={-1} aria-colindex={column.ariaColIndex} style={pinStyle(column)} @@ -1211,6 +1236,7 @@ export function TableView(props: TableViewProps) { data-last-column={lastColumnFlag(column)} role="columnheader" scope="col" + rowSpan={rowSpan > 1 ? rowSpan : undefined} tabIndex={-1} aria-colindex={column.ariaColIndex} style={pinStyle(column)} @@ -1391,6 +1417,7 @@ export function TableView(props: TableViewProps) { 'data-tint': tintSlot(tints, column.key, 'header'), role: 'columnheader', scope: 'col', + rowSpan: rowSpan > 1 ? rowSpan : undefined, // A sortable header is a control, so it takes a tab stop — and so does // one carrying a menu (Shift+F10 needs somewhere to land) or one that // can be moved with Alt+Arrow. Row/cell keyboard navigation lands in a @@ -2292,29 +2319,142 @@ export function TableView(props: TableViewProps) { ); - const headerRow = isHeaderHidden ? null : ( -
column.isPinEdge) ? '' : undefined + } + data-last-column={last.index === columnCount - 1 ? '' : undefined} + data-align="center" + role="columnheader" + scope="colgroup" + tabIndex={-1} + aria-colindex={first.ariaColIndex} + colSpan={segment.length} + style={ + pinOffset == null + ? undefined + : { ['--pin-offset' as any]: `${pinOffset}px` } + } + > + {group.title} +