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..c91a9050 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 @@ -81,12 +81,13 @@ export const InteractiveExample: FunctionComponent = () => { const rows: DataViewTr[] = repositories.map(({ id, name, branches, prs, workspaces, lastCommit, contributors, stars, forks }) => [ { id, - cell: workspaces, + cell: isSticky ? null : workspaces, props: { - favorites: { isFavorited: true } + favorites: { isFavorited: true }, + ...(isSticky ? { isStickyColumn: true } : {}), } }, - { 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 +98,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: '3rem' } } : null, + { cell: 'Repositories', props: { isStickyColumn: isSticky, modifier: 'nowrap', 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..258cc855 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 @@ -50,8 +50,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: null, props: { favorites: { isFavorited: true }, isStickyColumn: true } }, + { 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 +63,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: '3rem' } }, + { cell: 'Repositories', props: { isStickyColumn: true, modifier: 'nowrap', 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..03ceb45e 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 a narrow `stickyMinWidth` on the first column in `columns` (for example `3rem` for a favorites-only cell), leave that cell's content empty (`cell: null`) so only the favorite star renders, and set `hasRightBorder: true` only on the last column in that group. ### 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..10d6bc4f --- /dev/null +++ b/packages/module/src/DataViewTable/stickySelectionColumn.test.ts @@ -0,0 +1,227 @@ +import { + computeStickyLeftOffset, + 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('computeStickyLeftOffset', () => { + const leadingStickyColumns = [ + { cell: '', props: { isStickyColumn: true, stickyMinWidth: '3rem' } }, + { cell: 'Name', props: { isStickyColumn: true, hasRightBorder: true } }, + { cell: 'Tags' }, + ]; + + it('returns the first sticky column width when selection is not included', () => { + expect(computeStickyLeftOffset(leadingStickyColumns, 1, false)).toBe('3rem'); + }); + + it('combines selection and first sticky column widths', () => { + expect(computeStickyLeftOffset(leadingStickyColumns, 1, true)).toBe( + `calc(${STICKY_SELECTION_COLUMN_WIDTH} + 3rem)` + ); + }); + + it('returns selection width for the first sticky data column when only one sticky column exists', () => { + expect( + computeStickyLeftOffset([ { cell: 'Name', props: { isStickyColumn: true } } ], 0, true) + ).toBeUndefined(); + }); + }); + + 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 columns definition', () => { + expect( + mergeLeadingStickyDataColumnProps( + { isStickyColumn: true, hasRightBorder: true }, + 1, + leadingStickyColumns, + false + ) + ).toEqual({ + isStickyColumn: true, + hasRightBorder: true, + stickyLeftOffset: '3rem', + }); + }); + + it('uses the columns definition stickyMinWidth for body cells', () => { + expect( + mergeLeadingStickyDataColumnProps( + { isStickyColumn: true, stickyMinWidth: '4rem' }, + 0, + leadingStickyColumns, + false + ) + ).toEqual({ + isStickyColumn: true, + stickyMinWidth: '3rem', + style: { width: '3rem', maxWidth: '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('combines selection and leading sticky offsets for later sticky columns', () => { + expect( + mergeLeadingStickyDataColumnProps( + { isStickyColumn: true, hasRightBorder: true }, + 1, + leadingStickyColumns, + true + ) + ).toEqual({ + isStickyColumn: true, + hasRightBorder: true, + stickyLeftOffset: `calc(${STICKY_SELECTION_COLUMN_WIDTH} + 3rem)`, + }); + }); + }); +}); diff --git a/packages/module/src/DataViewTable/stickySelectionColumn.ts b/packages/module/src/DataViewTable/stickySelectionColumn.ts new file mode 100644 index 00000000..7ac261a7 --- /dev/null +++ b/packages/module/src/DataViewTable/stickySelectionColumn.ts @@ -0,0 +1,155 @@ +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. + * Sized for a typical checkbox column; tune if your selection column renders wider. + */ +export const STICKY_SELECTION_COLUMN_WIDTH = '4rem'; + +/** Default used when computing offsets for a preceding sticky column with no explicit stickyMinWidth. */ +export const DEFAULT_STICKY_COLUMN_WIDTH = '120px'; + +/** 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; +} + +function sumCssWidths(first: string, second: string): string { + return `calc(${first} + ${second})`; +} + +/** Applies column-definition width to sticky cells and locks width so scroll offsets stay aligned. */ +function applyColumnDefStickyWidth

( + columnProps: P, + columnDefStickyMinWidth: string | undefined +): P { + if (!columnDefStickyMinWidth) { + return columnProps; + } + return { + ...columnProps, + stickyMinWidth: columnDefStickyMinWidth, + style: { + ...columnProps.style, + width: columnDefStickyMinWidth, + maxWidth: columnDefStickyMinWidth, + }, + }; +} + +/** + * Combined inset for a sticky column from preceding sticky columns in `columns` + * and, when applicable, the injected selection column. + */ +export function computeStickyLeftOffset( + columns: unknown[], + colIndex: number, + includeStickySelection: boolean +): string | undefined { + const firstStickyIndex = getFirstStickyColumnIndex(columns); + if (firstStickyIndex < 0 || colIndex <= firstStickyIndex) { + return undefined; + } + + let offset: string | undefined = includeStickySelection ? STICKY_SELECTION_COLUMN_WIDTH : undefined; + + for (let i = firstStickyIndex; i < colIndex; i++) { + if (!isStickyColumnDefinition(columns[i])) { + continue; + } + const width = getStickyMinWidth(columns[i]) ?? DEFAULT_STICKY_COLUMN_WIDTH; + offset = offset ? sumCssWidths(offset, width) : width; + } + + return offset; +} + +/** 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). + * Offsets are derived from the `columns` definition so header and body cells stay aligned while scrolling. + * Only the rightmost column in the group should use `hasRightBorder: true`; earlier columns should not. + */ +export function mergeLeadingStickyDataColumnProps

( + columnProps: P | undefined, + colIndex: number, + columns: unknown[], + includeStickySelection: boolean +): P | undefined { + if (!columnProps?.isStickyColumn) { + return columnProps; + } + + const firstStickyIndex = getFirstStickyColumnIndex(columns); + if (firstStickyIndex < 0) { + return columnProps; + } + + const columnDefStickyMinWidth = getStickyMinWidth(columns[colIndex]); + let merged: P = applyColumnDefStickyWidth(columnProps, columnDefStickyMinWidth); + + if (merged.stickyLeftOffset != null) { + return merged; + } + + if (includeStickySelection && colIndex === firstStickyIndex) { + return mergeFirstStickyDataColumnProps(merged, true); + } + + const offset = computeStickyLeftOffset(columns, colIndex, includeStickySelection); + if (offset != null) { + merged = { ...merged, stickyLeftOffset: offset }; + } + + return merged; +} 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..2dc8f912 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,17 @@ export const DataViewTableBasic: FC = ({ const cellExpandableContent = isExpandable ? expandedRows?.find( (content) => content.rowId === rowId && content.columnId === colIndex ) : undefined; + const baseTdProps = cellIsObject ? (cell?.props ?? {}) : {}; + const tdProps = mergeLeadingStickyDataColumnProps( + baseTdProps, + colIndex, + columns, + 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 +189,7 @@ export const DataViewTableBasic: FC = ({ - { activeHeadState || } + { activeHeadState || } { bodyContent }
@@ -167,7 +198,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 (