From c62c318a143a11727dd86fdcc7478a24789c0298 Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Fri, 10 Apr 2026 13:47:48 -0400 Subject: [PATCH 1/3] feat(Table): support dynamic sticky styling --- .../components/Table/InnerScrollContainer.tsx | 11 +- .../src/components/Table/Table.tsx | 10 +- .../src/components/Table/examples/Table.md | 13 +- .../examples/TableStickyHeaderDynamic.tsx | 140 ++++++++++++++++++ 4 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 packages/react-table/src/components/Table/examples/TableStickyHeaderDynamic.tsx diff --git a/packages/react-table/src/components/Table/InnerScrollContainer.tsx b/packages/react-table/src/components/Table/InnerScrollContainer.tsx index f5e0be5be58..15faf6f7e3e 100644 --- a/packages/react-table/src/components/Table/InnerScrollContainer.tsx +++ b/packages/react-table/src/components/Table/InnerScrollContainer.tsx @@ -1,3 +1,4 @@ +import { forwardRef } from 'react'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Table/table-scrollable'; @@ -6,16 +7,22 @@ export interface InnerScrollContainerProps extends React.HTMLProps; } -export const InnerScrollContainer: React.FunctionComponent = ({ +const InnerScrollContainerBase: React.FunctionComponent = ({ children, className, + innerRef, ...props }: InnerScrollContainerProps) => ( -
+
{children}
); +export const InnerScrollContainer = forwardRef((props: InnerScrollContainerProps, ref: React.Ref) => ( + +)); InnerScrollContainer.displayName = 'InnerScrollContainer'; diff --git a/packages/react-table/src/components/Table/Table.tsx b/packages/react-table/src/components/Table/Table.tsx index fc2305011a2..94d26256993 100644 --- a/packages/react-table/src/components/Table/Table.tsx +++ b/packages/react-table/src/components/Table/Table.tsx @@ -52,8 +52,12 @@ export interface TableProps extends React.HTMLProps, OUIAProps isPlain?: boolean; /** @beta Flag indicating if the table should not have plain styling when in the glass theme */ isNoPlainOnGlass?: boolean; - /** If set to true, the table header sticks to the top of its container */ + /** If set to true, the table header sticks to the top of its container. This property applies both the sticky position and styling. */ isStickyHeader?: boolean; + /** @beta Flag indicating the table header should have sticky positioning to the top of the parentInnerScrollContainer. */ + isStickyHeaderBase?: boolean; + /** @beta Flag indicating the table header should have stuck styling, when the header is not at the top of the scroll container. */ + isStickyHeaderStuck?: boolean; /** @hide Forwarded ref */ innerRef?: React.RefObject; /** Flag indicating table is a tree table */ @@ -98,6 +102,8 @@ const TableBase: React.FunctionComponent = ({ variant, borders = true, isStickyHeader = false, + isStickyHeaderBase = false, + isStickyHeaderStuck = false, isPlain = false, isNoPlainOnGlass = false, gridBreakPoint = TableGridBreakpoint.gridMd, @@ -225,6 +231,8 @@ const TableBase: React.FunctionComponent = ({ styles.modifiers[variant], !borders && styles.modifiers.noBorderRows, isStickyHeader && styles.modifiers.stickyHeader, + isStickyHeaderBase && styles.modifiers.stickyHeaderBase, + isStickyHeaderStuck && styles.modifiers.stickyHeaderStuck, isTreeTable && stylesTreeView.modifiers.treeView, isStriped && styles.modifiers.striped, isExpandable && styles.modifiers.expandable, diff --git a/packages/react-table/src/components/Table/examples/Table.md b/packages/react-table/src/components/Table/examples/Table.md index 85c2410f1ec..ca52d1a70e5 100644 --- a/packages/react-table/src/components/Table/examples/Table.md +++ b/packages/react-table/src/components/Table/examples/Table.md @@ -41,7 +41,7 @@ The `Table` component takes an explicit and declarative approach, and its implem The documentation for the deprecated table implementation can be found under the [React deprecated](/components/table/react-deprecated) tab. It is configuration based and takes a less declarative and more implicit approach to laying out the table structure, such as the rows and cells within it. -import { Fragment, isValidElement, useCallback, useEffect, useRef, useState } from 'react'; +import { Fragment, isValidElement, useCallback, useEffect, useRef, useState, useLayoutEffect } from 'react'; import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; import CodeBranchIcon from '@patternfly/react-icons/dist/esm/icons/code-branch-icon'; import CodeIcon from '@patternfly/react-icons/dist/esm/icons/code-icon'; @@ -327,7 +327,6 @@ To enable a tree table: - `checkAriaLabel` - (optional) accessible label for the checkbox - `showDetailsAriaLabel` - (optional) accessible label for the show row details button in the responsive view 4. The first `Td` in each row will pass the following to the `treeRow` prop: - - `onCollapse` - Callback when user expands/collapses a row to reveal/hide the row's children. - `onCheckChange` - (optional) Callback when user changes the checkbox on a row. - `onToggleRowDetails` - (optional) Callback when user shows/hides the row details in responsive view. @@ -419,6 +418,16 @@ To prevent the default text wrapping behavior and allow horizontal scrolling, al ``` +### Dynamic sticky header + +A sticky header may alternatively be implemented with two properties: `isStickyHeaderBase` and `isStickyHeaderStuck` - which allows separate control of the sticky position and sticky styling. `isStickyHeaderBase` should always be applied to make the header position sticky, and `isStickyHeaderStuck` may be applied dynamically to enable the sticky styling, such as when the sticky header is not at the top of the scroll parent as shown in the example. + +`isStickyHeader` acts as if both properties are present and true when applied, and is useful when dynamic sticky styling is not necessary. + +```ts file="TableStickyHeaderDynamic.tsx" + +``` + ### Sticky columns and header To maintain proper sticky behavior across sticky columns and header, `Table` must be wrapped with `OuterScrollContainer` and `InnerScrollContainer`. diff --git a/packages/react-table/src/components/Table/examples/TableStickyHeaderDynamic.tsx b/packages/react-table/src/components/Table/examples/TableStickyHeaderDynamic.tsx new file mode 100644 index 00000000000..8a72f8272c0 --- /dev/null +++ b/packages/react-table/src/components/Table/examples/TableStickyHeaderDynamic.tsx @@ -0,0 +1,140 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { Table, Thead, Tr, Th, Tbody, Td, InnerScrollContainer } from '@patternfly/react-table'; +import BlueprintIcon from '@patternfly/react-icons/dist/esm/icons/blueprint-icon'; + +interface Fact { + name: string; + state: string; + detail1: string; + detail2: string; + detail3: string; + detail4: string; + detail5: string; + detail6: string; + detail7: string; +} + +const useIsStuckFromScrollParent = ({ + shouldTrack, + scrollParentRef +}: { + /** Indicates whether to track the scroll top position of the scroll parent element */ + shouldTrack: boolean; + /** Reference to the scroll parent element */ + scrollParentRef: React.RefObject; +}): boolean => { + const [isStuck, setIsStuck] = useState(false); + + useLayoutEffect(() => { + if (!shouldTrack) { + setIsStuck(false); + return; + } + + const scrollElement = scrollParentRef.current; + if (!scrollElement) { + setIsStuck(false); + return; + } + + const syncFromScroll = () => { + setIsStuck(scrollElement.scrollTop > 0); + }; + syncFromScroll(); + scrollElement.addEventListener('scroll', syncFromScroll, { passive: true }); + return () => scrollElement.removeEventListener('scroll', syncFromScroll); + }, [shouldTrack, scrollParentRef]); + + return isStuck; +}; + +export const TableStickyHeaderDynamic: React.FunctionComponent = () => { + const scrollContainerRef = useRef(null); + const isStuck = useIsStuckFromScrollParent({ shouldTrack: true, scrollParentRef: scrollContainerRef }); + + // In real usage, this data would come from some external source like an API via props. + const facts: Fact[] = Array.from({ length: 9 }, (_, index) => ({ + name: `Fact ${index + 1}`, + state: `State ${index + 1}`, + detail1: `Test cell ${index + 1}-3`, + detail2: `Test cell ${index + 1}-4`, + detail3: `Test cell ${index + 1}-5`, + detail4: `Test cell ${index + 1}-6`, + detail5: `Test cell ${index + 1}-7`, + detail6: `Test cell ${index + 1}-8`, + detail7: `Test cell ${index + 1}-9` + })); + + const columnNames = { + name: 'Fact', + state: 'State', + header3: 'Header 3', + header4: 'Header 4', + header5: 'Header 5', + header6: 'Header 6', + header7: 'Header 7', + header8: 'Header 8', + header9: 'Header 9' + }; + + return ( +
+ + + + + + + + + + + + + + + + + {facts.map((fact) => ( + + + + + + + + + + + + ))} + +
{columnNames.name}{columnNames.state}{columnNames.header3}{columnNames.header4}{columnNames.header5}{columnNames.header6}{columnNames.header7}{columnNames.header8}{columnNames.header9}
+ {fact.name} + + + {` ${fact.state}`} + + {fact.detail1} + + {fact.detail2} + + {fact.detail3} + + {fact.detail4} + + {fact.detail5} + + {fact.detail6} + + {fact.detail7} +
+
+
+ ); +}; From 4316b409e8c653dda2f11debf5c3fee51de82d7a Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Fri, 10 Apr 2026 13:51:59 -0400 Subject: [PATCH 2/3] bump core for new sticky styling --- packages/react-core/package.json | 2 +- packages/react-docs/package.json | 2 +- packages/react-icons/package.json | 2 +- packages/react-styles/package.json | 2 +- packages/react-tokens/package.json | 2 +- yarn.lock | 18 +++++++++--------- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/react-core/package.json b/packages/react-core/package.json index c7cc7e115fb..82748d3691d 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -54,7 +54,7 @@ "tslib": "^2.8.1" }, "devDependencies": { - "@patternfly/patternfly": "6.5.0-prerelease.65", + "@patternfly/patternfly": "6.5.0-prerelease.66", "case-anything": "^3.1.2", "css": "^3.0.0", "fs-extra": "^11.3.3" diff --git a/packages/react-docs/package.json b/packages/react-docs/package.json index eb058e8fd75..83932a1e8e3 100644 --- a/packages/react-docs/package.json +++ b/packages/react-docs/package.json @@ -23,7 +23,7 @@ "test:a11y": "patternfly-a11y --config patternfly-a11y.config" }, "dependencies": { - "@patternfly/patternfly": "6.5.0-prerelease.65", + "@patternfly/patternfly": "6.5.0-prerelease.66", "@patternfly/react-charts": "workspace:^", "@patternfly/react-code-editor": "workspace:^", "@patternfly/react-core": "workspace:^", diff --git a/packages/react-icons/package.json b/packages/react-icons/package.json index f12b73da4d5..ee98344918e 100644 --- a/packages/react-icons/package.json +++ b/packages/react-icons/package.json @@ -35,7 +35,7 @@ "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", - "@patternfly/patternfly": "6.5.0-prerelease.65", + "@patternfly/patternfly": "6.5.0-prerelease.66", "@rhds/icons": "^2.1.0", "fs-extra": "^11.3.3", "tslib": "^2.8.1" diff --git a/packages/react-styles/package.json b/packages/react-styles/package.json index df7a7b8ff47..549bf75efbe 100644 --- a/packages/react-styles/package.json +++ b/packages/react-styles/package.json @@ -19,7 +19,7 @@ "clean": "rimraf dist css" }, "devDependencies": { - "@patternfly/patternfly": "6.5.0-prerelease.65", + "@patternfly/patternfly": "6.5.0-prerelease.66", "change-case": "^5.4.4", "fs-extra": "^11.3.3" }, diff --git a/packages/react-tokens/package.json b/packages/react-tokens/package.json index 54eebb73c93..7880693fc1b 100644 --- a/packages/react-tokens/package.json +++ b/packages/react-tokens/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@adobe/css-tools": "^4.4.4", - "@patternfly/patternfly": "6.5.0-prerelease.65", + "@patternfly/patternfly": "6.5.0-prerelease.66", "fs-extra": "^11.3.3" } } diff --git a/yarn.lock b/yarn.lock index 9b11737b8cf..4f79c672463 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5070,10 +5070,10 @@ __metadata: languageName: node linkType: hard -"@patternfly/patternfly@npm:6.5.0-prerelease.65": - version: 6.5.0-prerelease.65 - resolution: "@patternfly/patternfly@npm:6.5.0-prerelease.65" - checksum: 10c0/5d6042bb3b3a562b3b1421395edbbd5899f84a3349d45be29f9d3d9a9e18968b8e86275f75a86b9a3720db905679b52a4227377b640c0096f498d584e5f4c2fb +"@patternfly/patternfly@npm:6.5.0-prerelease.66": + version: 6.5.0-prerelease.66 + resolution: "@patternfly/patternfly@npm:6.5.0-prerelease.66" + checksum: 10c0/bd5f5809334a5256c5924bfacf1e07f52f52ef7c86955e23d2cf32b142403fa41dc8f40b14dd2026468db628b482fa9175280548ac91a9798882bf053bc1bea4 languageName: node linkType: hard @@ -5171,7 +5171,7 @@ __metadata: version: 0.0.0-use.local resolution: "@patternfly/react-core@workspace:packages/react-core" dependencies: - "@patternfly/patternfly": "npm:6.5.0-prerelease.65" + "@patternfly/patternfly": "npm:6.5.0-prerelease.66" "@patternfly/react-icons": "workspace:^" "@patternfly/react-styles": "workspace:^" "@patternfly/react-tokens": "workspace:^" @@ -5192,7 +5192,7 @@ __metadata: resolution: "@patternfly/react-docs@workspace:packages/react-docs" dependencies: "@patternfly/documentation-framework": "npm:^6.36.8" - "@patternfly/patternfly": "npm:6.5.0-prerelease.65" + "@patternfly/patternfly": "npm:6.5.0-prerelease.66" "@patternfly/patternfly-a11y": "npm:5.1.0" "@patternfly/react-charts": "workspace:^" "@patternfly/react-code-editor": "workspace:^" @@ -5232,7 +5232,7 @@ __metadata: "@fortawesome/free-brands-svg-icons": "npm:^5.15.4" "@fortawesome/free-regular-svg-icons": "npm:^5.15.4" "@fortawesome/free-solid-svg-icons": "npm:^5.15.4" - "@patternfly/patternfly": "npm:6.5.0-prerelease.65" + "@patternfly/patternfly": "npm:6.5.0-prerelease.66" "@rhds/icons": "npm:^2.1.0" fs-extra: "npm:^11.3.3" tslib: "npm:^2.8.1" @@ -5319,7 +5319,7 @@ __metadata: version: 0.0.0-use.local resolution: "@patternfly/react-styles@workspace:packages/react-styles" dependencies: - "@patternfly/patternfly": "npm:6.5.0-prerelease.65" + "@patternfly/patternfly": "npm:6.5.0-prerelease.66" change-case: "npm:^5.4.4" fs-extra: "npm:^11.3.3" languageName: unknown @@ -5361,7 +5361,7 @@ __metadata: resolution: "@patternfly/react-tokens@workspace:packages/react-tokens" dependencies: "@adobe/css-tools": "npm:^4.4.4" - "@patternfly/patternfly": "npm:6.5.0-prerelease.65" + "@patternfly/patternfly": "npm:6.5.0-prerelease.66" fs-extra: "npm:^11.3.3" languageName: unknown linkType: soft From 9954d87608c3b8252d06c9dc778c000787644795 Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Fri, 10 Apr 2026 14:41:28 -0400 Subject: [PATCH 3/3] add unit tests for new modifiers, update example --- .../components/Table/__tests__/Table.test.tsx | 36 +++++++++++++++++++ .../examples/TableStickyHeaderDynamic.tsx | 10 +++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/react-table/src/components/Table/__tests__/Table.test.tsx b/packages/react-table/src/components/Table/__tests__/Table.test.tsx index e5a6dc373a2..e49d1a9f6d1 100644 --- a/packages/react-table/src/components/Table/__tests__/Table.test.tsx +++ b/packages/react-table/src/components/Table/__tests__/Table.test.tsx @@ -186,3 +186,39 @@ test(`Does not render with class ${styles.modifiers.noPlainOnGlass} when isNoPla expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.noPlainOnGlass); }); + +test(`Renders with class ${styles.modifiers.stickyHeaderBase} when isStickyHeaderBase is true`, () => { + render(); + + expect(screen.getByRole('grid', { name: 'Test table' })).toHaveClass(styles.modifiers.stickyHeaderBase); +}); + +test(`Does not render with class ${styles.modifiers.stickyHeaderBase} when isStickyHeaderBase is false`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.stickyHeaderBase); +}); + +test(`Does not render with class ${styles.modifiers.stickyHeaderBase} when isStickyHeaderBase is undefined`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.stickyHeaderBase); +}); + +test(`Renders with class ${styles.modifiers.stickyHeaderStuck} when isStickyHeaderStuck is true`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).toHaveClass(styles.modifiers.stickyHeaderStuck); +}); + +test(`Does not render with class ${styles.modifiers.stickyHeaderStuck} when isStickyHeaderStuck is false`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.stickyHeaderStuck); +}); + +test(`Does not render with class ${styles.modifiers.stickyHeaderStuck} when isStickyHeaderStuck is undefined`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.stickyHeaderStuck); +}); diff --git a/packages/react-table/src/components/Table/examples/TableStickyHeaderDynamic.tsx b/packages/react-table/src/components/Table/examples/TableStickyHeaderDynamic.tsx index 8a72f8272c0..cc116a96c46 100644 --- a/packages/react-table/src/components/Table/examples/TableStickyHeaderDynamic.tsx +++ b/packages/react-table/src/components/Table/examples/TableStickyHeaderDynamic.tsx @@ -81,7 +81,7 @@ export const TableStickyHeaderDynamic: React.FunctionComponent = () => {
{ {facts.map((fact) => ( -
+ {fact.name} - - + + {` ${fact.state}`} - + {fact.detail1}