Skip to content

Commit 9b7ca54

Browse files
Michael Johnsonjohnsonm325
authored andcommitted
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.
1 parent cf95c31 commit 9b7ca54

12 files changed

Lines changed: 576 additions & 22 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ When adding/making changes to a component, always make sure your code is tested:
152152

153153
## Testing and Linting
154154
- run `npm run test` to run the unit tests
155-
- run `cypress:run:ci:cp` to run component tests
156-
- run `cypress:run:ci:e2e` to run E2E tests
155+
- run `npm run cypress:run:ci:cp` to run component tests
156+
- run `npm run cypress:run:ci:e2e` to run E2E tests
157157
- run `npm run lint` to run the linter
158158

159159
## A11y testing

cypress/component/DataViewTableBasic.cy.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,30 @@ const rows = repositories.map(item => Object.values(item));
2323

2424
const columns = [ 'Repositories', 'Branches', 'Pull requests', 'Workspaces', 'Last commit' ];
2525

26+
const stickyColumns = [
27+
{ cell: 'Repositories', props: { isStickyColumn: true, hasRightBorder: true } },
28+
'Branches',
29+
'Pull requests',
30+
'Workspaces',
31+
'Last commit',
32+
];
33+
34+
const stickyRows = [
35+
{ name: 'Repository one', branches: 'Branch one', prs: 'Pull request one', workspaces: 'Workspace one', lastCommit: 'Timestamp one' },
36+
].map(item => [
37+
{ cell: item.name, props: { isStickyColumn: true, hasRightBorder: true } },
38+
item.branches,
39+
item.prs,
40+
item.workspaces,
41+
item.lastCommit,
42+
]);
43+
44+
const selection = {
45+
onSelect: () => undefined,
46+
isSelected: () => false,
47+
isSelectDisabled: () => false,
48+
};
49+
2650
describe('DataViewTableBasic', () => {
2751

2852
it('renders a basic data view table', () => {
@@ -102,4 +126,23 @@ describe('DataViewTableBasic', () => {
102126
cy.get('[data-ouia-component-id="data-tr-loading"]').contains('Data is loading');
103127
});
104128

129+
it('applies sticky column styling to the selection and first data column when isSticky and the first column is sticky', () => {
130+
const ouiaId = 'data-sticky-select';
131+
132+
cy.mount(
133+
<DataView selection={selection}>
134+
<DataViewTableBasic
135+
aria-label="Sticky selectable table"
136+
ouiaId={ouiaId}
137+
columns={stickyColumns}
138+
rows={stickyRows}
139+
isSticky
140+
/>
141+
</DataView>
142+
);
143+
144+
cy.get('thead tr th.pf-v6-c-table__sticky-cell').should('have.length', 2);
145+
cy.get('tbody tr').first().find('td.pf-v6-c-table__sticky-cell').should('have.length', 2);
146+
});
147+
105148
});

packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableInteractiveExample.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,13 @@ export const InteractiveExample: FunctionComponent = () => {
8181
const rows: DataViewTr[] = repositories.map(({ id, name, branches, prs, workspaces, lastCommit, contributors, stars, forks }) => [
8282
{
8383
id,
84-
cell: workspaces,
84+
cell: isSticky ? null : workspaces,
8585
props: {
86-
favorites: { isFavorited: true }
86+
favorites: { isFavorited: true },
87+
...(isSticky ? { isStickyColumn: true } : {}),
8788
}
8889
},
89-
{ cell: <Button href='#' variant='link' isInline>{name}</Button>, props: { isStickyColumn: isSticky, hasRightBorder: true, hasLeftBorder: true, modifier: "nowrap" } },
90+
{ cell: <Button href='#' variant='link' isInline>{name}</Button>, props: { isStickyColumn: isSticky, hasRightBorder: isSticky, modifier: "nowrap" } },
9091
{ cell: branches, props: { modifier: "nowrap" } },
9192
{ cell: prs, props: { modifier: "nowrap" } },
9293
{ cell: workspaces, props: { modifier: "nowrap" } },
@@ -97,8 +98,8 @@ export const InteractiveExample: FunctionComponent = () => {
9798
]);
9899

99100
const columns: DataViewTh[] = [
100-
null,
101-
{ cell: 'Repositories', props: { isStickyColumn: isSticky, modifier: 'fitContent', hasRightBorder: true, hasLeftBorder: true } },
101+
isSticky ? { cell: '', props: { isStickyColumn: true, stickyMinWidth: '3rem' } } : null,
102+
{ cell: 'Repositories', props: { isStickyColumn: isSticky, modifier: 'nowrap', hasRightBorder: isSticky } },
102103
{ cell: <>Branches<ExclamationCircleIcon className='pf-v6-u-ml-sm' color="var(--pf-t--global--color--status--danger--default)"/></>, props: { width: 20 } },
103104
{ cell: 'Pull requests', props: { width: 20 } },
104105
{ cell: 'Workspaces', props: { info: { tooltip: 'More information' }, width: 20 } },

packages/module/patternfly-docs/content/extensions/data-view/examples/Table/DataViewTableStickyExample.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ const rowActions = [
5050
];
5151

5252
const rows: DataViewTr[] = repositories.map(({ id, name, branches, prs, workspaces, lastCommit, contributors, stars, forks }) => [
53-
{ id, cell: workspaces, props: { favorites: { isFavorited: true } } },
54-
{ cell: <Button href='#' variant='link' isInline>{name}</Button>, props: { isStickyColumn: true, hasRightBorder: true, hasLeftBorder: true, modifier: "nowrap" } },
53+
{ id, cell: null, props: { favorites: { isFavorited: true }, isStickyColumn: true } },
54+
{ cell: <Button href='#' variant='link' isInline>{name}</Button>, props: { isStickyColumn: true, hasRightBorder: true, modifier: "nowrap" } },
5555
{ cell: branches, props: { modifier: "nowrap" } },
5656
{ cell: prs, props: { modifier: "nowrap" } },
5757
{ cell: workspaces, props: { modifier: "nowrap" } },
@@ -63,8 +63,8 @@ const rows: DataViewTr[] = repositories.map(({ id, name, branches, prs, workspac
6363
]);
6464

6565
const columns: DataViewTh[] = [
66-
null,
67-
{ cell: 'Repositories', props: { isStickyColumn: true, modifier: 'fitContent', hasRightBorder: true, hasLeftBorder: true } },
66+
{ cell: '', props: { isStickyColumn: true, stickyMinWidth: '4rem' } },
67+
{ cell: 'Repositories', props: { isStickyColumn: true, stickyMinWidth: '150px', hasRightBorder: true } },
6868
{ cell: <>Branches<ExclamationCircleIcon className='pf-v6-u-ml-sm' color="var(--pf-t--global--color--status--danger--default)"/></>, props: { width: 20 } },
6969
{ cell: 'Pull requests', props: { width: 20 } },
7070
{ cell: 'Workspaces', props: { info: { tooltip: 'More information' }, width: 20 } },

packages/module/patternfly-docs/content/extensions/data-view/examples/Table/Table.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ propComponents:
1919
'DataViewTrTree',
2020
'DataViewTrObject',
2121
'DataViewTh',
22-
'DataViewThResizableProps'
22+
'DataViewThResizableProps',
23+
'DataViewTableHead'
2324
]
2425
sourceLink: https://github.com/patternfly/react-data-view/blob/main/packages/module/patternfly-docs/content/extensions/data-view/examples/Table/Table.md
2526
---
@@ -103,7 +104,11 @@ When sticky headers and columns are enabled:
103104
- The table header remains visible when scrolling vertically
104105
- Columns marked with `isStickyColumn: true` remain visible when scrolling horizontally
105106
- The table is wrapped in `OuterScrollContainer` and `InnerScrollContainer` components to enable sticky behavior
106-
- Sticky columns can have additional styling like borders using `hasRightBorder` or `hasLeftBorder` props
107+
- 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).
108+
109+
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.
110+
111+
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.
107112

108113
### Sticky header and columns example
109114

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { default } from './DataViewTable';
22
export * from './DataViewTable';
3+
export * from './stickySelectionColumn';
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import {
2+
computeStickyLeftOffset,
3+
getFirstStickyColumnIndex,
4+
mergeFirstStickyDataColumnProps,
5+
mergeLeadingStickyDataColumnProps,
6+
shouldIncludeStickySelectionColumn,
7+
STICKY_SELECTION_COLUMN_WIDTH,
8+
stickySelectionCellProps,
9+
} from './stickySelectionColumn';
10+
11+
describe('stickySelectionColumn', () => {
12+
describe('stickySelectionCellProps', () => {
13+
it('matches row-selection sticky grouping props', () => {
14+
expect(stickySelectionCellProps).toEqual({
15+
isStickyColumn: true,
16+
hasRightBorder: false,
17+
stickyMinWidth: STICKY_SELECTION_COLUMN_WIDTH,
18+
});
19+
});
20+
});
21+
22+
describe('getFirstStickyColumnIndex', () => {
23+
it('returns the first sticky column index', () => {
24+
expect(
25+
getFirstStickyColumnIndex([
26+
null,
27+
{ cell: 'Name', props: { isStickyColumn: true } },
28+
{ cell: 'Tags' },
29+
])
30+
).toBe(1);
31+
});
32+
33+
it('returns -1 when no sticky column exists', () => {
34+
expect(getFirstStickyColumnIndex([ { cell: 'Name' } ])).toBe(-1);
35+
});
36+
});
37+
38+
describe('shouldIncludeStickySelectionColumn', () => {
39+
it('is true when table is sticky, selectable, and first sticky column exists', () => {
40+
expect(
41+
shouldIncludeStickySelectionColumn(
42+
[ { cell: 'Name', props: { isStickyColumn: true } } ],
43+
true,
44+
true
45+
)
46+
).toBe(true);
47+
});
48+
49+
it('is true when the first sticky column follows a null placeholder', () => {
50+
expect(
51+
shouldIncludeStickySelectionColumn(
52+
[ null, { cell: 'Name', props: { isStickyColumn: true } } ],
53+
true,
54+
true
55+
)
56+
).toBe(true);
57+
});
58+
59+
it('is false when table is not sticky', () => {
60+
expect(
61+
shouldIncludeStickySelectionColumn(
62+
[ { cell: 'Name', props: { isStickyColumn: true } } ],
63+
true,
64+
false
65+
)
66+
).toBe(false);
67+
});
68+
69+
it('is false when not selectable', () => {
70+
expect(
71+
shouldIncludeStickySelectionColumn(
72+
[ { cell: 'Name', props: { isStickyColumn: true } } ],
73+
false,
74+
true
75+
)
76+
).toBe(false);
77+
});
78+
79+
it('is false when no column is sticky', () => {
80+
expect(
81+
shouldIncludeStickySelectionColumn(
82+
[ { cell: 'Name', props: { isStickyColumn: false } } ],
83+
true,
84+
true
85+
)
86+
).toBe(false);
87+
});
88+
89+
it('is false when columns is empty', () => {
90+
expect(shouldIncludeStickySelectionColumn([], true, true)).toBe(false);
91+
});
92+
});
93+
94+
describe('computeStickyLeftOffset', () => {
95+
const leadingStickyColumns = [
96+
{ cell: '', props: { isStickyColumn: true, stickyMinWidth: '3rem' } },
97+
{ cell: 'Name', props: { isStickyColumn: true, hasRightBorder: true } },
98+
{ cell: 'Tags' },
99+
];
100+
101+
it('returns the first sticky column width when selection is not included', () => {
102+
expect(computeStickyLeftOffset(leadingStickyColumns, 1, false)).toBe('3rem');
103+
});
104+
105+
it('combines selection and first sticky column widths', () => {
106+
expect(computeStickyLeftOffset(leadingStickyColumns, 1, true)).toBe(
107+
`calc(${STICKY_SELECTION_COLUMN_WIDTH} + 3rem)`
108+
);
109+
});
110+
111+
it('returns selection width for the first sticky data column when only one sticky column exists', () => {
112+
expect(
113+
computeStickyLeftOffset([ { cell: 'Name', props: { isStickyColumn: true } } ], 0, true)
114+
).toBeUndefined();
115+
});
116+
});
117+
118+
describe('mergeFirstStickyDataColumnProps', () => {
119+
it('adds stickyLeftOffset when including selection sticky', () => {
120+
expect(
121+
mergeFirstStickyDataColumnProps(
122+
{ isStickyColumn: true, hasRightBorder: true },
123+
true
124+
)
125+
).toEqual({
126+
isStickyColumn: true,
127+
hasRightBorder: true,
128+
stickyLeftOffset: STICKY_SELECTION_COLUMN_WIDTH,
129+
});
130+
});
131+
132+
it('preserves existing stickyLeftOffset', () => {
133+
expect(
134+
mergeFirstStickyDataColumnProps(
135+
{ isStickyColumn: true, stickyLeftOffset: '80px' },
136+
true
137+
)
138+
).toEqual({
139+
isStickyColumn: true,
140+
stickyLeftOffset: '80px',
141+
});
142+
});
143+
144+
it('does not merge when first column is not sticky', () => {
145+
expect(
146+
mergeFirstStickyDataColumnProps({ isStickyColumn: false }, true)
147+
).toEqual({ isStickyColumn: false });
148+
});
149+
150+
it('returns column props unchanged when not including sticky selection', () => {
151+
const props = { isStickyColumn: true, hasRightBorder: true };
152+
expect(mergeFirstStickyDataColumnProps(props, false)).toBe(props);
153+
});
154+
155+
it('returns undefined when column props are undefined', () => {
156+
expect(mergeFirstStickyDataColumnProps(undefined, true)).toBeUndefined();
157+
});
158+
});
159+
160+
describe('mergeLeadingStickyDataColumnProps', () => {
161+
const leadingStickyColumns = [
162+
{ cell: '', props: { isStickyColumn: true, stickyMinWidth: '3rem' } },
163+
{ cell: 'Name', props: { isStickyColumn: true, hasRightBorder: true } },
164+
{ cell: 'Tags' },
165+
];
166+
167+
it('offsets the second sticky column from the columns definition', () => {
168+
expect(
169+
mergeLeadingStickyDataColumnProps(
170+
{ isStickyColumn: true, hasRightBorder: true },
171+
1,
172+
leadingStickyColumns,
173+
false
174+
)
175+
).toEqual({
176+
isStickyColumn: true,
177+
hasRightBorder: true,
178+
stickyLeftOffset: '3rem',
179+
});
180+
});
181+
182+
it('uses the columns definition stickyMinWidth for body cells', () => {
183+
expect(
184+
mergeLeadingStickyDataColumnProps(
185+
{ isStickyColumn: true, stickyMinWidth: '4rem' },
186+
0,
187+
leadingStickyColumns,
188+
false
189+
)
190+
).toEqual({
191+
isStickyColumn: true,
192+
stickyMinWidth: '3rem',
193+
style: { width: '3rem', minWidth: '3rem', maxWidth: '3rem' },
194+
});
195+
});
196+
197+
it('applies selection offset on the first sticky data column', () => {
198+
expect(
199+
mergeLeadingStickyDataColumnProps(
200+
{ isStickyColumn: true, hasRightBorder: true },
201+
0,
202+
[ { cell: 'Name', props: { isStickyColumn: true } } ],
203+
true
204+
)
205+
).toEqual({
206+
isStickyColumn: true,
207+
hasRightBorder: true,
208+
stickyLeftOffset: STICKY_SELECTION_COLUMN_WIDTH,
209+
});
210+
});
211+
212+
it('combines selection and leading sticky offsets for later sticky columns', () => {
213+
expect(
214+
mergeLeadingStickyDataColumnProps(
215+
{ isStickyColumn: true, hasRightBorder: true },
216+
1,
217+
leadingStickyColumns,
218+
true
219+
)
220+
).toEqual({
221+
isStickyColumn: true,
222+
hasRightBorder: true,
223+
stickyLeftOffset: `calc(${STICKY_SELECTION_COLUMN_WIDTH} + 3rem)`,
224+
});
225+
});
226+
});
227+
});

0 commit comments

Comments
 (0)