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/Table.md b/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/Table.md
index 2aa382ad..23dbe8c3 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
---
@@ -105,6 +106,8 @@ When sticky headers and columns are enabled:
- 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
+When **row selection** is enabled (via the `DataView` `selection` prop) and the first column in the `columns` array is a sticky column (`isStickyColumn: true` in that column’s `props`), the row-selection checkbox column is included in the same sticky group as that first data column. The first data column’s `stickyLeftOffset` is aligned so it sits to the right of the selection column. This requires the first **defined** column in `columns` to be the sticky one; a leading `null` placeholder in `columns` is not treated as a sticky first column for this behavior.
+
### Sticky header and columns example
```js file="./DataViewTableStickyExample.tsx"
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..f0ecffb6
--- /dev/null
+++ b/packages/module/src/DataViewTable/stickySelectionColumn.test.ts
@@ -0,0 +1,112 @@
+import {
+ mergeFirstStickyDataColumnProps,
+ 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('shouldIncludeStickySelectionColumn', () => {
+ it('is true when table is sticky, selectable, and first column is sticky', () => {
+ expect(
+ shouldIncludeStickySelectionColumn(
+ [ { 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 first column is not 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);
+ });
+
+ it('is false when first column entry is null', () => {
+ expect(
+ shouldIncludeStickySelectionColumn([ null, { cell: 'Name', props: { isStickyColumn: true } } ], 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();
+ });
+ });
+});
diff --git a/packages/module/src/DataViewTable/stickySelectionColumn.ts b/packages/module/src/DataViewTable/stickySelectionColumn.ts
new file mode 100644
index 00000000..53dc5ef8
--- /dev/null
+++ b/packages/module/src/DataViewTable/stickySelectionColumn.ts
@@ -0,0 +1,56 @@
+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,
+};
+
+/** `columns[0]` object shape from {@link DataViewTh} (avoids importing DataViewTable and a circular dependency). */
+function isStickyFirstColumnDefinition(column: unknown): boolean {
+ return (
+ column != null &&
+ typeof column === 'object' &&
+ 'props' in column &&
+ (column as { props?: { isStickyColumn?: boolean } }).props?.isStickyColumn === true
+ );
+}
+
+export function shouldIncludeStickySelectionColumn(
+ columns: unknown[],
+ isSelectable: boolean,
+ isStickyTable: boolean
+): boolean {
+ if (!isStickyTable || !isSelectable || columns.length === 0) {
+ return false;
+ }
+ const first = columns[0];
+ if (first == null) {
+ return false;
+ }
+ return isStickyFirstColumnDefinition(first);
+}
+
+/** 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,
+ };
+}
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..bd21cc3b 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 {
+ mergeFirstStickyDataColumnProps,
+ 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,13 @@ export const DataViewTableBasic: FC = ({
const cellExpandableContent = isExpandable ? expandedRows?.find(
(content) => content.rowId === rowId && content.columnId === colIndex
) : undefined;
+ const baseTdProps = cellIsObject ? (cell?.props ?? {}) : {};
+ const tdProps =
+ colIndex === 0 ? mergeFirstStickyDataColumnProps(baseTdProps, includeStickySelection) : baseTdProps;
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 +185,7 @@ export const DataViewTableBasic: FC = ({
- { activeHeadState || }
+ { activeHeadState || }
{ bodyContent }
@@ -167,7 +194,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..b54df3f1 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 {
+ mergeFirstStickyDataColumnProps,
+ 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={mergeFirstStickyDataColumnProps(
+ isDataViewThObject(column) ? (column?.props ?? {}) : {},
+ index === 0 && includeStickySelection
+ )}
hasResizableColumns={hasResizableColumns}
/>
))
],
- [ columns, ouiaId, onSelect, isSelected, isTreeTable, hasResizableColumns ]
+ [ columns, ouiaId, onSelect, isSelected, isTreeTable, hasResizableColumns, includeStickySelection ]
);
return (
|