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 (