Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions cypress/component/DataViewTableBasic.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(
<DataView selection={selection}>
<DataViewTableBasic
aria-label="Sticky selectable table"
ouiaId={ouiaId}
columns={stickyColumns}
rows={stickyRows}
isSticky
/>
</DataView>
);

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);
});

});
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: <Button href='#' variant='link' isInline>{name}</Button>, props: { isStickyColumn: isSticky, hasRightBorder: true, hasLeftBorder: true, modifier: "nowrap" } },
{ cell: <Button href='#' variant='link' isInline>{name}</Button>, props: { isStickyColumn: isSticky, hasRightBorder: isSticky, modifier: "nowrap" } },
{ cell: branches, props: { modifier: "nowrap" } },
{ cell: prs, props: { modifier: "nowrap" } },
{ cell: workspaces, props: { modifier: "nowrap" } },
Expand All @@ -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<ExclamationCircleIcon className='pf-v6-u-ml-sm' color="var(--pf-t--global--color--status--danger--default)"/></>, props: { width: 20 } },
{ cell: 'Pull requests', props: { width: 20 } },
{ cell: 'Workspaces', props: { info: { tooltip: 'More information' }, width: 20 } },
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: <Button href='#' variant='link' isInline>{name}</Button>, props: { isStickyColumn: true, hasRightBorder: true, hasLeftBorder: true, modifier: "nowrap" } },
{ id, cell: workspaces, props: { favorites: { isFavorited: true }, isStickyColumn: true, stickyMinWidth: STICKY_SELECTION_COLUMN_WIDTH } },
{ cell: <Button href='#' variant='link' isInline>{name}</Button>, props: { isStickyColumn: true, hasRightBorder: true, modifier: "nowrap" } },
{ cell: branches, props: { modifier: "nowrap" } },
{ cell: prs, props: { modifier: "nowrap" } },
{ cell: workspaces, props: { modifier: "nowrap" } },
Expand All @@ -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<ExclamationCircleIcon className='pf-v6-u-ml-sm' color="var(--pf-t--global--color--status--danger--default)"/></>, props: { width: 20 } },
{ cell: 'Pull requests', props: { width: 20 } },
{ cell: 'Workspaces', props: { info: { tooltip: 'More information' }, width: 20 } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/module/src/DataViewTable/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default } from './DataViewTable';
export * from './DataViewTable';
export * from './stickySelectionColumn';
187 changes: 187 additions & 0 deletions packages/module/src/DataViewTable/stickySelectionColumn.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
});
Loading