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