From 3ae094583148640e511d1f4ff80ef39b260572f2 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 1 May 2026 16:33:24 -0400 Subject: [PATCH] feat(DataViewTableBasic): Make select column sticky When attempting to make the first column in a table row sticky, it doesn't make the selection column sticky and the selection column will scroll with the rest of the table. This PR makes the selection column sticky along with the "first" column. --- README.md | 4 +- cypress/component/DataViewTableBasic.cy.tsx | 43 ++++ .../Table/DataViewTableInteractiveExample.tsx | 10 +- .../Table/DataViewTableStickyExample.tsx | 9 +- .../data-view/examples/Table/Table.md | 9 +- packages/module/src/DataViewTable/index.ts | 1 + .../stickySelectionColumn.test.ts | 187 ++++++++++++++++++ .../DataViewTable/stickySelectionColumn.ts | 100 ++++++++++ .../DataViewTableBasic.test.tsx | 43 ++++ .../DataViewTableBasic/DataViewTableBasic.tsx | 40 +++- .../DataViewTableHead.test.tsx | 27 ++- .../DataViewTableHead/DataViewTableHead.tsx | 30 ++- 12 files changed, 482 insertions(+), 21 deletions(-) create mode 100644 packages/module/src/DataViewTable/stickySelectionColumn.test.ts create mode 100644 packages/module/src/DataViewTable/stickySelectionColumn.ts diff --git a/README.md b/README.md index 5f218c2c..8a3a392a 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,8 @@ When adding/making changes to a component, always make sure your code is tested: ## Testing and Linting - run `npm run test` to run the unit tests -- run `cypress:run:ci:cp` to run component tests -- run `cypress:run:ci:e2e` to run E2E tests +- run `npm run cypress:run:ci:cp` to run component tests +- run `npm run cypress:run:ci:e2e` to run E2E tests - run `npm run lint` to run the linter ## A11y testing diff --git a/cypress/component/DataViewTableBasic.cy.tsx b/cypress/component/DataViewTableBasic.cy.tsx index 694e62f3..7cd8bed5 100644 --- a/cypress/component/DataViewTableBasic.cy.tsx +++ b/cypress/component/DataViewTableBasic.cy.tsx @@ -23,6 +23,30 @@ const rows = repositories.map(item => Object.values(item)); const columns = [ 'Repositories', 'Branches', 'Pull requests', 'Workspaces', 'Last commit' ]; +const stickyColumns = [ + { cell: 'Repositories', props: { isStickyColumn: true, hasRightBorder: true } }, + 'Branches', + 'Pull requests', + 'Workspaces', + 'Last commit', +]; + +const stickyRows = [ + { name: 'Repository one', branches: 'Branch one', prs: 'Pull request one', workspaces: 'Workspace one', lastCommit: 'Timestamp one' }, +].map(item => [ + { cell: item.name, props: { isStickyColumn: true, hasRightBorder: true } }, + item.branches, + item.prs, + item.workspaces, + item.lastCommit, +]); + +const selection = { + onSelect: () => undefined, + isSelected: () => false, + isSelectDisabled: () => false, +}; + describe('DataViewTableBasic', () => { it('renders a basic data view table', () => { @@ -102,4 +126,23 @@ describe('DataViewTableBasic', () => { cy.get('[data-ouia-component-id="data-tr-loading"]').contains('Data is loading'); }); + it('applies sticky column styling to the selection and first data column when isSticky and the first column is sticky', () => { + const ouiaId = 'data-sticky-select'; + + cy.mount( + + + + ); + + cy.get('thead tr th.pf-v6-c-table__sticky-cell').should('have.length', 2); + cy.get('tbody tr').first().find('td.pf-v6-c-table__sticky-cell').should('have.length', 2); + }); + }); \ No newline at end of file diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableInteractiveExample.tsx b/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableInteractiveExample.tsx index 4972770d..8b70f50a 100644 --- a/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableInteractiveExample.tsx +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableInteractiveExample.tsx @@ -1,5 +1,6 @@ import { FunctionComponent, useState } from 'react'; import { DataViewTable, DataViewTr, DataViewTh } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; +import { STICKY_SELECTION_COLUMN_WIDTH } from '@patternfly/react-data-view/dist/dynamic/DataViewTable/stickySelectionColumn'; import { ExpandableContent } from '@patternfly/react-data-view/dist/dynamic/DataViewTableBasic'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { Button, Toolbar, ToolbarContent, ToolbarItem, Switch } from '@patternfly/react-core'; @@ -83,10 +84,11 @@ export const InteractiveExample: FunctionComponent = () => { id, cell: workspaces, props: { - favorites: { isFavorited: true } + favorites: { isFavorited: true }, + ...(isSticky ? { isStickyColumn: true, stickyMinWidth: STICKY_SELECTION_COLUMN_WIDTH } : {}), } }, - { cell: , props: { isStickyColumn: isSticky, hasRightBorder: true, hasLeftBorder: true, modifier: "nowrap" } }, + { cell: , props: { isStickyColumn: isSticky, hasRightBorder: isSticky, modifier: "nowrap" } }, { cell: branches, props: { modifier: "nowrap" } }, { cell: prs, props: { modifier: "nowrap" } }, { cell: workspaces, props: { modifier: "nowrap" } }, @@ -97,8 +99,8 @@ export const InteractiveExample: FunctionComponent = () => { ]); const columns: DataViewTh[] = [ - null, - { cell: 'Repositories', props: { isStickyColumn: isSticky, modifier: 'fitContent', hasRightBorder: true, hasLeftBorder: true } }, + isSticky ? { cell: '', props: { isStickyColumn: true, stickyMinWidth: STICKY_SELECTION_COLUMN_WIDTH } } : null, + { cell: 'Repositories', props: { isStickyColumn: isSticky, modifier: 'fitContent', hasRightBorder: isSticky } }, { cell: <>Branches, props: { width: 20 } }, { cell: 'Pull requests', props: { width: 20 } }, { cell: 'Workspaces', props: { info: { tooltip: 'More information' }, width: 20 } }, diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableStickyExample.tsx b/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableStickyExample.tsx index ce6fa304..ec6048c9 100644 --- a/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableStickyExample.tsx +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableStickyExample.tsx @@ -1,5 +1,6 @@ import { FunctionComponent } from 'react'; import { DataViewTable, DataViewTr, DataViewTh } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; +import { STICKY_SELECTION_COLUMN_WIDTH } from '@patternfly/react-data-view/dist/dynamic/DataViewTable/stickySelectionColumn'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { Button } from '@patternfly/react-core'; import { ActionsColumn } from '@patternfly/react-table'; @@ -50,8 +51,8 @@ const rowActions = [ ]; const rows: DataViewTr[] = repositories.map(({ id, name, branches, prs, workspaces, lastCommit, contributors, stars, forks }) => [ - { id, cell: workspaces, props: { favorites: { isFavorited: true } } }, - { cell: , props: { isStickyColumn: true, hasRightBorder: true, hasLeftBorder: true, modifier: "nowrap" } }, + { id, cell: workspaces, props: { favorites: { isFavorited: true }, isStickyColumn: true, stickyMinWidth: STICKY_SELECTION_COLUMN_WIDTH } }, + { cell: , props: { isStickyColumn: true, hasRightBorder: true, modifier: "nowrap" } }, { cell: branches, props: { modifier: "nowrap" } }, { cell: prs, props: { modifier: "nowrap" } }, { cell: workspaces, props: { modifier: "nowrap" } }, @@ -63,8 +64,8 @@ const rows: DataViewTr[] = repositories.map(({ id, name, branches, prs, workspac ]); const columns: DataViewTh[] = [ - null, - { cell: 'Repositories', props: { isStickyColumn: true, modifier: 'fitContent', hasRightBorder: true, hasLeftBorder: true } }, + { cell: '', props: { isStickyColumn: true, stickyMinWidth: STICKY_SELECTION_COLUMN_WIDTH } }, + { cell: 'Repositories', props: { isStickyColumn: true, modifier: 'fitContent', hasRightBorder: true } }, { cell: <>Branches, props: { width: 20 } }, { cell: 'Pull requests', props: { width: 20 } }, { cell: 'Workspaces', props: { info: { tooltip: 'More information' }, width: 20 } }, diff --git a/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/Table.md b/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/Table.md index 2aa382ad..d906355d 100644 --- a/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/Table.md +++ b/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/Table.md @@ -19,7 +19,8 @@ propComponents: 'DataViewTrTree', 'DataViewTrObject', 'DataViewTh', - 'DataViewThResizableProps' + 'DataViewThResizableProps', + 'DataViewTableHead' ] sourceLink: https://github.com/patternfly/react-data-view/blob/main/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/Table.md --- @@ -103,7 +104,11 @@ When sticky headers and columns are enabled: - The table header remains visible when scrolling vertically - Columns marked with `isStickyColumn: true` remain visible when scrolling horizontally - The table is wrapped in `OuterScrollContainer` and `InnerScrollContainer` components to enable sticky behavior -- Sticky columns can have additional styling like borders using `hasRightBorder` or `hasLeftBorder` props +- Sticky columns can use `hasRightBorder` on the **last** column in a locked group to draw a single divider before scrollable columns. Do not set `hasRightBorder` or `hasLeftBorder` on earlier columns in the group (for example, the selection checkbox column or a leading favorites column). + +When **row selection** is enabled (via the `DataView` `selection` prop) and a column in the `columns` array is marked `isStickyColumn: true`, the row-selection checkbox column is included in the same sticky group. The checkbox column stays sticky without a right border; the first sticky data column’s `stickyLeftOffset` is aligned to sit to the right of the selection column. Leading `null` placeholders in `columns` are skipped when locating the first sticky data column. + +When multiple leading data columns are sticky (for example, favorites and name), mark each with `isStickyColumn: true`, set `stickyMinWidth` on the first, and set `hasRightBorder: true` only on the last column in that group. Offsets for the second sticky column are applied automatically from the previous column’s `stickyMinWidth`. ### Sticky header and columns example diff --git a/packages/module/src/DataViewTable/index.ts b/packages/module/src/DataViewTable/index.ts index 35373805..c1eeddae 100644 --- a/packages/module/src/DataViewTable/index.ts +++ b/packages/module/src/DataViewTable/index.ts @@ -1,2 +1,3 @@ export { default } from './DataViewTable'; export * from './DataViewTable'; +export * from './stickySelectionColumn'; diff --git a/packages/module/src/DataViewTable/stickySelectionColumn.test.ts b/packages/module/src/DataViewTable/stickySelectionColumn.test.ts new file mode 100644 index 00000000..de55f8fc --- /dev/null +++ b/packages/module/src/DataViewTable/stickySelectionColumn.test.ts @@ -0,0 +1,187 @@ +import { + getFirstStickyColumnIndex, + mergeFirstStickyDataColumnProps, + mergeLeadingStickyDataColumnProps, + shouldIncludeStickySelectionColumn, + STICKY_SELECTION_COLUMN_WIDTH, + stickySelectionCellProps, +} from './stickySelectionColumn'; + +describe('stickySelectionColumn', () => { + describe('stickySelectionCellProps', () => { + it('matches row-selection sticky grouping props', () => { + expect(stickySelectionCellProps).toEqual({ + isStickyColumn: true, + hasRightBorder: false, + stickyMinWidth: STICKY_SELECTION_COLUMN_WIDTH, + }); + }); + }); + + describe('getFirstStickyColumnIndex', () => { + it('returns the first sticky column index', () => { + expect( + getFirstStickyColumnIndex([ + null, + { cell: 'Name', props: { isStickyColumn: true } }, + { cell: 'Tags' }, + ]) + ).toBe(1); + }); + + it('returns -1 when no sticky column exists', () => { + expect(getFirstStickyColumnIndex([ { cell: 'Name' } ])).toBe(-1); + }); + }); + + describe('shouldIncludeStickySelectionColumn', () => { + it('is true when table is sticky, selectable, and first sticky column exists', () => { + expect( + shouldIncludeStickySelectionColumn( + [ { cell: 'Name', props: { isStickyColumn: true } } ], + true, + true + ) + ).toBe(true); + }); + + it('is true when the first sticky column follows a null placeholder', () => { + expect( + shouldIncludeStickySelectionColumn( + [ null, { cell: 'Name', props: { isStickyColumn: true } } ], + true, + true + ) + ).toBe(true); + }); + + it('is false when table is not sticky', () => { + expect( + shouldIncludeStickySelectionColumn( + [ { cell: 'Name', props: { isStickyColumn: true } } ], + true, + false + ) + ).toBe(false); + }); + + it('is false when not selectable', () => { + expect( + shouldIncludeStickySelectionColumn( + [ { cell: 'Name', props: { isStickyColumn: true } } ], + false, + true + ) + ).toBe(false); + }); + + it('is false when no column is sticky', () => { + expect( + shouldIncludeStickySelectionColumn( + [ { cell: 'Name', props: { isStickyColumn: false } } ], + true, + true + ) + ).toBe(false); + }); + + it('is false when columns is empty', () => { + expect(shouldIncludeStickySelectionColumn([], true, true)).toBe(false); + }); + }); + + describe('mergeFirstStickyDataColumnProps', () => { + it('adds stickyLeftOffset when including selection sticky', () => { + expect( + mergeFirstStickyDataColumnProps( + { isStickyColumn: true, hasRightBorder: true }, + true + ) + ).toEqual({ + isStickyColumn: true, + hasRightBorder: true, + stickyLeftOffset: STICKY_SELECTION_COLUMN_WIDTH, + }); + }); + + it('preserves existing stickyLeftOffset', () => { + expect( + mergeFirstStickyDataColumnProps( + { isStickyColumn: true, stickyLeftOffset: '80px' }, + true + ) + ).toEqual({ + isStickyColumn: true, + stickyLeftOffset: '80px', + }); + }); + + it('does not merge when first column is not sticky', () => { + expect( + mergeFirstStickyDataColumnProps({ isStickyColumn: false }, true) + ).toEqual({ isStickyColumn: false }); + }); + + it('returns column props unchanged when not including sticky selection', () => { + const props = { isStickyColumn: true, hasRightBorder: true }; + expect(mergeFirstStickyDataColumnProps(props, false)).toBe(props); + }); + + it('returns undefined when column props are undefined', () => { + expect(mergeFirstStickyDataColumnProps(undefined, true)).toBeUndefined(); + }); + }); + + describe('mergeLeadingStickyDataColumnProps', () => { + const leadingStickyColumns = [ + { cell: '', props: { isStickyColumn: true, stickyMinWidth: '3rem' } }, + { cell: 'Name', props: { isStickyColumn: true, hasRightBorder: true } }, + { cell: 'Tags' }, + ]; + + it('offsets the second sticky column from the first stickyMinWidth', () => { + expect( + mergeLeadingStickyDataColumnProps( + { isStickyColumn: true, hasRightBorder: true }, + 1, + leadingStickyColumns, + false + ) + ).toEqual({ + isStickyColumn: true, + hasRightBorder: true, + stickyLeftOffset: '3rem', + }); + }); + + it('applies selection offset on the first sticky data column', () => { + expect( + mergeLeadingStickyDataColumnProps( + { isStickyColumn: true, hasRightBorder: true }, + 0, + [ { cell: 'Name', props: { isStickyColumn: true } } ], + true + ) + ).toEqual({ + isStickyColumn: true, + hasRightBorder: true, + stickyLeftOffset: STICKY_SELECTION_COLUMN_WIDTH, + }); + }); + + it('applies selection offset when sticky column follows a null placeholder', () => { + expect( + mergeLeadingStickyDataColumnProps( + { isStickyColumn: true, hasRightBorder: true }, + 1, + [ null, { cell: 'Name', props: { isStickyColumn: true } } ], + true + ) + ).toEqual({ + isStickyColumn: true, + hasRightBorder: true, + stickyLeftOffset: STICKY_SELECTION_COLUMN_WIDTH, + }); + }); + }); +}); diff --git a/packages/module/src/DataViewTable/stickySelectionColumn.ts b/packages/module/src/DataViewTable/stickySelectionColumn.ts new file mode 100644 index 00000000..238b8789 --- /dev/null +++ b/packages/module/src/DataViewTable/stickySelectionColumn.ts @@ -0,0 +1,100 @@ +import { TdProps, ThProps } from '@patternfly/react-table'; + +/** + * Min width / left offset for the row-selection column when it is grouped with a sticky first data column. + * Matches PatternFly’s typical checkbox column width so the Name column’s sticky inset aligns. + */ +export const STICKY_SELECTION_COLUMN_WIDTH = '4rem'; + +/** Props applied to the injected checkbox Th/Td when they participate in a sticky first-column group */ +export const stickySelectionCellProps: Pick< + ThProps, + 'isStickyColumn' | 'hasRightBorder' | 'stickyMinWidth' +> = { + isStickyColumn: true, + hasRightBorder: false, + stickyMinWidth: STICKY_SELECTION_COLUMN_WIDTH, +}; + +function isStickyColumnDefinition(column: unknown): boolean { + return ( + column != null && + typeof column === 'object' && + 'props' in column && + (column as { props?: { isStickyColumn?: boolean } }).props?.isStickyColumn === true + ); +} + +function getStickyMinWidth(column: unknown): string | undefined { + if (column == null || typeof column !== 'object' || !('props' in column)) { + return undefined; + } + return (column as { props?: { stickyMinWidth?: string } }).props?.stickyMinWidth; +} + +/** Index of the first column definition marked `isStickyColumn`, or -1 when none. */ +export function getFirstStickyColumnIndex(columns: unknown[]): number { + return columns.findIndex((column) => isStickyColumnDefinition(column)); +} + +export function shouldIncludeStickySelectionColumn( + columns: unknown[], + isSelectable: boolean, + isStickyTable: boolean +): boolean { + if (!isStickyTable || !isSelectable || columns.length === 0) { + return false; + } + return getFirstStickyColumnIndex(columns) >= 0; +} + +/** Adds horizontal inset so the first sticky data column sits after the sticky selection column */ +export function mergeFirstStickyDataColumnProps

( + columnProps: P | undefined, + includeStickySelection: boolean +): P | undefined { + if (!columnProps || !includeStickySelection || !columnProps.isStickyColumn) { + return columnProps; + } + return { + ...columnProps, + stickyLeftOffset: columnProps.stickyLeftOffset ?? STICKY_SELECTION_COLUMN_WIDTH, + }; +} + +/** + * Applies sticky offsets for a leading group of sticky columns (selection + data, or multiple data columns). + * Only the rightmost column in the group should use `hasRightBorder: true`; earlier columns should not. + */ +export function mergeLeadingStickyDataColumnProps

( + columnProps: P | undefined, + colIndex: number, + rowOrColumns: unknown[], + includeStickySelection: boolean +): P | undefined { + if (!columnProps?.isStickyColumn) { + return columnProps; + } + + const firstStickyIndex = getFirstStickyColumnIndex(rowOrColumns); + if (firstStickyIndex < 0) { + return columnProps; + } + + if (includeStickySelection && colIndex === firstStickyIndex) { + return mergeFirstStickyDataColumnProps(columnProps, true); + } + + if ( + colIndex > firstStickyIndex && + isStickyColumnDefinition(rowOrColumns[colIndex - 1]) && + columnProps.stickyLeftOffset == null + ) { + return { + ...columnProps, + stickyLeftOffset: getStickyMinWidth(rowOrColumns[colIndex - 1]) ?? STICKY_SELECTION_COLUMN_WIDTH, + }; + } + + return columnProps; +} diff --git a/packages/module/src/DataViewTableBasic/DataViewTableBasic.test.tsx b/packages/module/src/DataViewTableBasic/DataViewTableBasic.test.tsx index 385319a6..571814ba 100644 --- a/packages/module/src/DataViewTableBasic/DataViewTableBasic.test.tsx +++ b/packages/module/src/DataViewTableBasic/DataViewTableBasic.test.tsx @@ -1,7 +1,9 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DataView } from '../DataView'; +import { DataViewSelection } from '../InternalContext'; import { DataViewTableBasic, ExpandableContent } from './DataViewTableBasic'; +import { DataViewTh } from '../DataViewTable'; interface Repository { id: number; @@ -31,6 +33,20 @@ const rows = repositories.map(({ id, name, branches, prs, workspaces, lastCommit const columns = [ 'Repositories', 'Branches', 'Pull requests', 'Workspaces', 'Last commit' ]; +const stickyColumns: DataViewTh[] = [ + { cell: 'Repositories', props: { isStickyColumn: true, hasRightBorder: true } }, + 'Branches', + 'Pull requests', + 'Workspaces', + 'Last commit', +]; + +const mockSelection: DataViewSelection = { + onSelect: jest.fn(), + isSelected: jest.fn(() => false), + isSelectDisabled: jest.fn(() => false), +}; + const expandableContents: ExpandableContent[] = [ { rowId: 1, columnId: 1, content:

Branch details for Repository one
}, ]; @@ -72,6 +88,33 @@ describe('DataViewTable component', () => { expect(container).toMatchSnapshot(); }); + test('applies sticky classes to selection and first data cells when isSticky and first column is sticky', () => { + const stickyRows = repositories.map(({ id, name, branches, prs, workspaces, lastCommit }) => [ + { id, cell: name, props: { isStickyColumn: true, hasRightBorder: true } }, + branches, + prs, + workspaces, + lastCommit, + ]); + + const { container } = render( + + + + ); + + const firstBodyRow = container.querySelector('tbody tr'); + const bodyCells = firstBodyRow?.querySelectorAll('td'); + expect(bodyCells?.[0]?.classList.contains('pf-v6-c-table__sticky-cell')).toBe(true); + expect(bodyCells?.[1]?.classList.contains('pf-v6-c-table__sticky-cell')).toBe(true); + }); + test('when isExpandable cell should be clickable and expandable', async () => { const user = userEvent.setup(); diff --git a/packages/module/src/DataViewTableBasic/DataViewTableBasic.tsx b/packages/module/src/DataViewTableBasic/DataViewTableBasic.tsx index 7d3fe3ac..f64b764d 100644 --- a/packages/module/src/DataViewTableBasic/DataViewTableBasic.tsx +++ b/packages/module/src/DataViewTableBasic/DataViewTableBasic.tsx @@ -12,6 +12,11 @@ import { import { useInternalContext } from '../InternalContext'; import { DataViewTableHead } from '../DataViewTableHead'; import { DataViewTh, DataViewTr, isDataViewTdObject, isDataViewTrObject } from '../DataViewTable'; +import { + mergeLeadingStickyDataColumnProps, + shouldIncludeStickySelectionColumn, + stickySelectionCellProps, +} from '../DataViewTable/stickySelectionColumn'; import { DataViewState } from '../DataView/DataView'; export interface ExpandableContent { @@ -57,6 +62,11 @@ export const DataViewTableBasic: FC = ({ const { selection, activeState, isSelectable } = useInternalContext(); const { onSelect, isSelected, isSelectDisabled } = selection ?? {}; + const includeStickySelection = useMemo( + () => shouldIncludeStickySelectionColumn(columns, isSelectable, isSticky), + [ columns, isSelectable, isSticky ] + ); + const activeHeadState = useMemo(() => activeState ? headStates?.[activeState] : undefined, [ activeState, headStates ]); const activeBodyState = useMemo(() => activeState ? bodyStates?.[activeState] : undefined, [ activeState, bodyStates ]); @@ -87,6 +97,7 @@ export const DataViewTableBasic: FC = ({ {isSelectable && ( { @@ -102,10 +113,18 @@ export const DataViewTableBasic: FC = ({ const cellExpandableContent = isExpandable ? expandedRows?.find( (content) => content.rowId === rowId && content.columnId === colIndex ) : undefined; + const baseTdProps = cellIsObject ? (cell?.props ?? {}) : {}; + const rowCells = rowIsObject ? row.row : row; + const tdProps = mergeLeadingStickyDataColumnProps( + baseTdProps, + colIndex, + rowCells, + includeStickySelection + ); return ( = ({ } else { return rowContent; } - }), [ rows, isSelectable, isSelected, isSelectDisabled, onSelect, ouiaId, expandedRowsState, expandedColumnIndex, expandedRows, isExpandable, needsSeparateTbody ]); + }), [ + rows, + isSelectable, + isSelected, + isSelectDisabled, + onSelect, + ouiaId, + expandedRowsState, + expandedColumnIndex, + expandedRows, + isExpandable, + needsSeparateTbody, + includeStickySelection, + ]); const bodyContent = activeBodyState || (needsSeparateTbody ? renderedRows : {renderedRows}); @@ -158,7 +190,7 @@ export const DataViewTableBasic: FC = ({ - { activeHeadState || } + { activeHeadState || } { bodyContent }
@@ -167,7 +199,7 @@ export const DataViewTableBasic: FC = ({ } else { return ( - { activeHeadState || } + { activeHeadState || } { bodyContent }
); diff --git a/packages/module/src/DataViewTableHead/DataViewTableHead.test.tsx b/packages/module/src/DataViewTableHead/DataViewTableHead.test.tsx index 47f774d4..d4b34726 100644 --- a/packages/module/src/DataViewTableHead/DataViewTableHead.test.tsx +++ b/packages/module/src/DataViewTableHead/DataViewTableHead.test.tsx @@ -1,11 +1,20 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { Table } from '@patternfly/react-table'; import { DataViewTableHead } from './DataViewTableHead'; import { DataViewSelection } from '../InternalContext'; import { DataView } from '../DataView'; +import { DataViewTh } from '../DataViewTable'; const columns = [ 'Repositories', 'Branches', 'Pull requests', 'Workspaces', 'Last commit' ]; +const stickyFirstColumn: DataViewTh[] = [ + { cell: 'Repositories', props: { isStickyColumn: true, hasRightBorder: true } }, + 'Branches', + 'Pull requests', + 'Workspaces', + 'Last commit', +]; + const ouiaId = 'HeaderExample'; describe('DataViewTableHead component', () => { @@ -45,5 +54,21 @@ describe('DataViewTableHead component', () => { ); expect(container).toMatchSnapshot(); }); + + test('applies sticky classes to selection and first column when isSticky and first column is sticky', () => { + render( + + + +
+
+ ); + + const selectionTh = screen.getByText('Data selection table head cell').closest('th'); + expect(selectionTh?.classList.contains('pf-v6-c-table__sticky-cell')).toBe(true); + + const repositoriesTh = screen.getByRole('columnheader', { name: 'Repositories' }); + expect(repositoriesTh.classList.contains('pf-v6-c-table__sticky-cell')).toBe(true); + }); }); diff --git a/packages/module/src/DataViewTableHead/DataViewTableHead.tsx b/packages/module/src/DataViewTableHead/DataViewTableHead.tsx index 81fc1e19..0b0300cf 100644 --- a/packages/module/src/DataViewTableHead/DataViewTableHead.tsx +++ b/packages/module/src/DataViewTableHead/DataViewTableHead.tsx @@ -2,6 +2,11 @@ import { FC, useMemo } from 'react'; import { Th, Thead, TheadProps, Tr } from '@patternfly/react-table'; import { useInternalContext } from '../InternalContext'; import { DataViewTh, isDataViewThObject } from '../DataViewTable'; +import { + mergeLeadingStickyDataColumnProps, + shouldIncludeStickySelectionColumn, + stickySelectionCellProps, +} from '../DataViewTable/stickySelectionColumn'; import { DataViewTh as DataViewThElement } from '../DataViewTh/DataViewTh'; /** extends TheadProps */ @@ -14,6 +19,8 @@ export interface DataViewTableHeadProps extends TheadProps { ouiaId?: string; /** @hide Indicates whether table is resizable */ hasResizableColumns?: boolean; + /** When true with a sticky first data column and row selection, the selection column participates in the sticky group */ + isSticky?: boolean; } export const DataViewTableHead: FC = ({ @@ -21,15 +28,25 @@ export const DataViewTableHead: FC = ({ columns, ouiaId = 'DataViewTableHead', hasResizableColumns, + isSticky = false, ...props }: DataViewTableHeadProps) => { - const { selection } = useInternalContext(); + const { selection, isSelectable } = useInternalContext(); const { onSelect, isSelected } = selection ?? {}; + const includeStickySelection = useMemo( + () => shouldIncludeStickySelectionColumn(columns, isSelectable, isSticky), + [ columns, isSelectable, isSticky ] + ); + const cells = useMemo( () => [ onSelect && isSelected && !isTreeTable ? ( - + ) : null, ...columns.map((column, index) => ( = ({ content={isDataViewThObject(column) ? column.cell : column} resizableProps={isDataViewThObject(column) ? column.resizableProps : undefined} data-ouia-component-id={`${ouiaId}-th-${index}`} - thProps={isDataViewThObject(column) ? (column?.props ?? {}) : {}} + thProps={mergeLeadingStickyDataColumnProps( + isDataViewThObject(column) ? (column?.props ?? {}) : {}, + index, + columns, + includeStickySelection + )} hasResizableColumns={hasResizableColumns} /> )) ], - [ columns, ouiaId, onSelect, isSelected, isTreeTable, hasResizableColumns ] + [ columns, ouiaId, onSelect, isSelected, isTreeTable, hasResizableColumns, includeStickySelection ] ); return (