From b3a297e89b316227030f8c67c47f174a312191c6 Mon Sep 17 00:00:00 2001 From: Gabi Podolnikova Date: Tue, 29 Sep 2026 09:34:59 +0200 Subject: [PATCH 1/2] feat(Table): add dynamic sticky footer support --- .../src/components/Table/Table.tsx | 8 +++ .../components/Table/__tests__/Table.test.tsx | 36 ++++++++++++ .../src/components/Table/examples/Table.md | 10 ++++ .../examples/TableStickyFooterDynamic.tsx | 56 +++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 packages/react-table/src/components/Table/examples/TableStickyFooterDynamic.tsx diff --git a/packages/react-table/src/components/Table/Table.tsx b/packages/react-table/src/components/Table/Table.tsx index 499f7b31d70..dafb267608e 100644 --- a/packages/react-table/src/components/Table/Table.tsx +++ b/packages/react-table/src/components/Table/Table.tsx @@ -60,6 +60,10 @@ export interface TableProps extends React.HTMLProps, OUIAProps isStickyHeaderStuck?: boolean; /** Flag indicating the table footer should stick to the bottom of its scroll container. */ isStickyFooter?: boolean; + /** @beta Flag indicating the table footer should have sticky positioning to the bottom of its scroll container. */ + isStickyFooterBase?: boolean; + /** @beta Flag indicating the table footer should have stuck styling, when the footer is not at the bottom of the scroll container. */ + isStickyFooterStuck?: boolean; /** @hide Forwarded ref */ innerRef?: React.RefObject; /** Flag indicating table is a tree table */ @@ -107,6 +111,8 @@ const TableBase: React.FunctionComponent = ({ isStickyHeaderBase = false, isStickyHeaderStuck = false, isStickyFooter = false, + isStickyFooterBase = false, + isStickyFooterStuck = false, isPlain = false, isNoPlainOnGlass = false, gridBreakPoint = TableGridBreakpoint.gridMd, @@ -237,6 +243,8 @@ const TableBase: React.FunctionComponent = ({ isStickyHeaderBase && styles.modifiers.stickyHeaderBase, isStickyHeaderStuck && styles.modifiers.stickyHeaderStuck, isStickyFooter && styles.modifiers.stickyFooter, + isStickyFooterBase && styles.modifiers.stickyFooterBase, + isStickyFooterStuck && styles.modifiers.stickyFooterStuck, isTreeTable && stylesTreeView.modifiers.treeView, isStriped && styles.modifiers.striped, isExpandable && styles.modifiers.expandable, 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 8f79767c533..200969c796c 100644 --- a/packages/react-table/src/components/Table/__tests__/Table.test.tsx +++ b/packages/react-table/src/components/Table/__tests__/Table.test.tsx @@ -234,3 +234,39 @@ test(`Does not render with class ${styles.modifiers.stickyFooter} when isStickyF expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.stickyFooter); }); + +test(`Renders with class ${styles.modifiers.stickyFooterBase} when isStickyFooterBase is true`, () => { + render(); + + expect(screen.getByRole('grid', { name: 'Test table' })).toHaveClass(styles.modifiers.stickyFooterBase); +}); + +test(`Does not render with class ${styles.modifiers.stickyFooterBase} when isStickyFooterBase is false`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.stickyFooterBase); +}); + +test(`Renders with class ${styles.modifiers.stickyFooterStuck} when isStickyFooterStuck is true`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).toHaveClass(styles.modifiers.stickyFooterStuck); +}); + +test(`Does not render with class ${styles.modifiers.stickyFooterStuck} when isStickyFooterStuck is false`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.stickyFooterStuck); +}); + +test(`Does not render with class ${styles.modifiers.stickyFooterBase} when isStickyFooterBase is undefined`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.stickyFooterBase); +}); + +test(`Does not render with class ${styles.modifiers.stickyFooterStuck} when isStickyFooterStuck is undefined`, () => { + render(
); + + expect(screen.getByRole('grid', { name: 'Test table' })).not.toHaveClass(styles.modifiers.stickyFooterStuck); +}); diff --git a/packages/react-table/src/components/Table/examples/Table.md b/packages/react-table/src/components/Table/examples/Table.md index a5f2db70d8c..2b237e9ebf0 100644 --- a/packages/react-table/src/components/Table/examples/Table.md +++ b/packages/react-table/src/components/Table/examples/Table.md @@ -489,6 +489,16 @@ Use `Tfoot` for semantic table footer rows. Set `isStickyFooter` on `Table` to k ``` +### Dynamic sticky footer + +A sticky footer may alternatively be implemented with two properties: `isStickyFooterBase` and `isStickyFooterStuck` - which allows separate control of the sticky position and sticky styling. `isStickyFooterBase` should always be applied to make the footer position sticky, and `isStickyFooterStuck` may be applied dynamically to enable the sticky styling, such as when the sticky footer is not at the bottom of the scroll parent as shown in the example. + +`isStickyFooter` acts as if both properties are present and true when applied, and is useful when dynamic sticky styling is not necessary. + +```ts file="TableStickyFooterDynamic.tsx" + +``` + ### Striped To apply striping to a basic table, add the `isStriped` property to `Table`. diff --git a/packages/react-table/src/components/Table/examples/TableStickyFooterDynamic.tsx b/packages/react-table/src/components/Table/examples/TableStickyFooterDynamic.tsx new file mode 100644 index 00000000000..53ff80ba8c8 --- /dev/null +++ b/packages/react-table/src/components/Table/examples/TableStickyFooterDynamic.tsx @@ -0,0 +1,56 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { Table, Thead, Tfoot, Tr, Th, Tbody, Td, InnerScrollContainer } from '@patternfly/react-table'; + +const useIsStuckFromScrollParent = (scrollParentRef: React.RefObject): boolean => { + const [isStuck, setIsStuck] = useState(false); + + useLayoutEffect(() => { + const scrollElement = scrollParentRef.current; + if (!scrollElement) { + return; + } + + const syncFromScroll = () => { + setIsStuck(scrollElement.scrollTop + scrollElement.clientHeight < scrollElement.scrollHeight); + }; + syncFromScroll(); + scrollElement.addEventListener('scroll', syncFromScroll, { passive: true }); + return () => scrollElement.removeEventListener('scroll', syncFromScroll); + }, [scrollParentRef]); + + return isStuck; +}; + +export const TableStickyFooterDynamic: React.FunctionComponent = () => { + const scrollContainerRef = useRef(null); + const isStuck = useIsStuckFromScrollParent(scrollContainerRef); + const rows = Array.from({ length: 12 }, (_, index) => index + 1); + + return ( +
+ +
+ + + + + + + + {rows.map((row) => ( + + + + + ))} + + + + + + +
ItemValue
Item {row}Value {row}
Total: {rows.length} items
+ + + ); +}; From 0466009b8f763aac9f8d81c9efca12ca471adbf1 Mon Sep 17 00:00:00 2001 From: Gabi Podolnikova Date: Tue, 29 Sep 2026 13:03:20 +0200 Subject: [PATCH 2/2] fix(Table): update dynamic sticky footer on resize --- .../components/Table/examples/TableStickyFooterDynamic.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-table/src/components/Table/examples/TableStickyFooterDynamic.tsx b/packages/react-table/src/components/Table/examples/TableStickyFooterDynamic.tsx index 53ff80ba8c8..943a4223cc4 100644 --- a/packages/react-table/src/components/Table/examples/TableStickyFooterDynamic.tsx +++ b/packages/react-table/src/components/Table/examples/TableStickyFooterDynamic.tsx @@ -1,4 +1,5 @@ import { useLayoutEffect, useRef, useState } from 'react'; +import { getResizeObserver } from '@patternfly/react-core'; import { Table, Thead, Tfoot, Tr, Th, Tbody, Td, InnerScrollContainer } from '@patternfly/react-table'; const useIsStuckFromScrollParent = (scrollParentRef: React.RefObject): boolean => { @@ -15,7 +16,11 @@ const useIsStuckFromScrollParent = (scrollParentRef: React.RefObject scrollElement.removeEventListener('scroll', syncFromScroll); + const observer = getResizeObserver(scrollElement, syncFromScroll, true); + return () => { + scrollElement.removeEventListener('scroll', syncFromScroll); + observer(); + }; }, [scrollParentRef]); return isStuck;