From 9e56259a0138c4842abe5f1fb9c0c9d80e03e0db Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Thu, 2 Apr 2026 19:18:24 -0400 Subject: [PATCH 1/2] feat(Table): separate sticky and pinned styles --- .../src/components/Table/Table.tsx | 6 +- .../src/components/Table/Thead.tsx | 99 ++++++++++++++++--- 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/packages/react-table/src/components/Table/Table.tsx b/packages/react-table/src/components/Table/Table.tsx index fc2305011a2..3bb3f4b3f2f 100644 --- a/packages/react-table/src/components/Table/Table.tsx +++ b/packages/react-table/src/components/Table/Table.tsx @@ -84,12 +84,14 @@ interface TableContextProps { registerSelectableRow?: () => void; hasAnimations?: boolean; variant?: TableVariant | 'compact'; + isStickyHeader?: boolean; } export const TableContext = createContext({ registerSelectableRow: () => {}, hasAnimations: false, - variant: undefined + variant: undefined, + isStickyHeader: false }); const TableBase: React.FunctionComponent = ({ @@ -214,7 +216,7 @@ const TableBase: React.FunctionComponent = ({ }; return ( - + { + let parent = node.parentElement; + while (parent) { + const style = getComputedStyle(parent); + if (/(auto|scroll|overlay)/.test(style.overflowY) || /(auto|scroll|overlay)/.test(style.overflowX)) { + return parent; + } + parent = parent.parentElement; + } + return null; +}; + +const assignRef = (ref: React.Ref | undefined, value: T | null) => { + if (!ref) { + return; + } + if (typeof ref === 'function') { + ref(value); + } else { + (ref as React.MutableRefObject).current = value; + } +}; export interface TheadProps extends React.HTMLProps { /** Content rendered inside the row group */ @@ -22,20 +49,62 @@ const TheadBase: React.FunctionComponent = ({ innerRef, hasNestedHeader, ...props -}: TheadProps) => ( - - {children} - -); +}: TheadProps) => { + const { isStickyHeader } = useContext(TableContext); + const observeStickyPin = !!isStickyHeader; + const [isPinned, setIsPinned] = useState(false); + const theadElRef = useRef(null); + + const setTheadRef = useCallback( + (node: HTMLTableSectionElement | null) => { + theadElRef.current = node; + assignRef(innerRef, node); + }, + [innerRef] + ); + + useEffect(() => { + if (!observeStickyPin || typeof IntersectionObserver === 'undefined') { + setIsPinned(false); + return; + } + + const el = theadElRef.current; + if (!el) { + return; + } + + const scrollRoot = getOverflowScrollParent(el); + + // Requires sticky thead `inset-block-start: -1px` in CSS (see table.css). + const observer = new IntersectionObserver( + ([entry]) => { + // console.log(scrollRoot, entry, entry.intersectionRatio); + setIsPinned(entry.intersectionRatio < PINNED_INTERSECTION_RATIO); + }, + { threshold: [0, 1], root: scrollRoot } + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [observeStickyPin]); + + return ( + + {children} + + ); +}; export const Thead = forwardRef((props: TheadProps, ref: React.Ref) => ( From de460735f08d7cf651421bf631bd10820dc2d312 Mon Sep 17 00:00:00 2001 From: Katie McFaul Date: Tue, 7 Apr 2026 16:54:02 -0400 Subject: [PATCH 2/2] scroll event approach --- .../components/Toolbar/examples/Toolbar.md | 4 +- .../Toolbar/examples/ToolbarSticky.tsx | 46 ++++- .../components/Table/InnerScrollContainer.tsx | 13 +- .../src/components/Table/Thead.tsx | 106 ++-------- .../src/components/Table/examples/Table.md | 11 +- ...ableStickyColumnsAndHeaderScrollPinned.tsx | 192 ++++++++++++++++++ 6 files changed, 276 insertions(+), 96 deletions(-) create mode 100644 packages/react-table/src/components/Table/examples/TableStickyColumnsAndHeaderScrollPinned.tsx diff --git a/packages/react-core/src/components/Toolbar/examples/Toolbar.md b/packages/react-core/src/components/Toolbar/examples/Toolbar.md index 89639b516c7..913241a9a3d 100644 --- a/packages/react-core/src/components/Toolbar/examples/Toolbar.md +++ b/packages/react-core/src/components/Toolbar/examples/Toolbar.md @@ -5,7 +5,7 @@ propComponents: ['Toolbar', 'ToolbarContent', 'ToolbarGroup', 'ToolbarItem', 'To section: components --- -import { Fragment, useState } from 'react'; +import { Fragment, useState, useRef, useLayoutEffect } from 'react'; import EditIcon from '@patternfly/react-icons/dist/esm/icons/edit-icon'; import CloneIcon from '@patternfly/react-icons/dist/esm/icons/clone-icon'; @@ -114,11 +114,13 @@ When all of a toolbar's required elements cannot fit in a single line, you can s ``` ## Examples with spacers and wrapping + You may adjust the space between toolbar items to arrange them into groups. Read our spacers documentation to learn more about using spacers. Items are spaced “16px” apart by default and can be modified by changing their or their parents' `gap`, `columnGap`, and `rowGap` properties. You can set the property values at multiple breakpoints, including "default", "md", "lg", "xl", and "2xl". ### Toolbar content wrapping + The toolbar content section will wrap by default, but you can set the `rowRap` property to `noWrap` to make it not wrap. ```ts file="./ToolbarContentWrap.tsx" diff --git a/packages/react-core/src/components/Toolbar/examples/ToolbarSticky.tsx b/packages/react-core/src/components/Toolbar/examples/ToolbarSticky.tsx index 3ee2d58361d..b0cf9375799 100644 --- a/packages/react-core/src/components/Toolbar/examples/ToolbarSticky.tsx +++ b/packages/react-core/src/components/Toolbar/examples/ToolbarSticky.tsx @@ -1,6 +1,32 @@ -import { Fragment, useState } from 'react'; +import { Fragment, useLayoutEffect, useRef, useState } from 'react'; import { Toolbar, ToolbarItem, ToolbarContent, SearchInput, Checkbox } from '@patternfly/react-core'; +const useTheadPinnedFromScrollParent = ({ track, scrollRootRef, theadRef }): { isPinned } => { + const [isPinned, setIsPinned] = useState(false); + + useLayoutEffect(() => { + if (!track) { + setIsPinned(false); + return; + } + + const scrollRoot = scrollRootRef.current; + if (!scrollRoot) { + setIsPinned(false); + return; + } + + const syncFromScroll = () => { + setIsPinned(scrollRoot.scrollTop > 0); + }; + syncFromScroll(); + scrollRoot.addEventListener('scroll', syncFromScroll, { passive: true }); + return () => scrollRoot.removeEventListener('scroll', syncFromScroll); + }, [track, scrollRootRef, theadRef]); + + return { isPinned }; +}; + export const ToolbarSticky = () => { const [isSticky, setIsSticky] = useState(true); const [showEvenOnly, setShowEvenOnly] = useState(true); @@ -8,10 +34,24 @@ export const ToolbarSticky = () => { const array = Array.from(Array(30), (_, x) => x); // create array of numbers from 1-30 for demo purposes const numbers = showEvenOnly ? array.filter((number) => number % 2 === 0) : array; + const innerScrollRef = useRef(null); + const toolbarRef = useRef(null); + const { isPinned } = useTheadPinnedFromScrollParent({ + track: true, + scrollRootRef: innerScrollRef, + theadRef: toolbarRef + }); + return ( -
- +
+ { /** Content rendered inside the inner scroll container */ @@ -8,14 +9,14 @@ export interface InnerScrollContainerProps extends React.HTMLProps = ({ - children, - className, - ...props -}: InnerScrollContainerProps) => ( -
+const InnerScrollContainerBase = ( + { children, className, ...props }: InnerScrollContainerProps, + ref: React.ForwardedRef +) => ( +
{children}
); +export const InnerScrollContainer = forwardRef(InnerScrollContainerBase); InnerScrollContainer.displayName = 'InnerScrollContainer'; diff --git a/packages/react-table/src/components/Table/Thead.tsx b/packages/react-table/src/components/Table/Thead.tsx index 019f1159775..595936d5b1c 100644 --- a/packages/react-table/src/components/Table/Thead.tsx +++ b/packages/react-table/src/components/Table/Thead.tsx @@ -1,33 +1,6 @@ -import { forwardRef, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { forwardRef } from 'react'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Table/table'; -import { TableContext } from './Table'; - -/** Ratio must be below this to count as “pinned” (avoids doc-layout subpixel + strict threshold: [1] never hitting exactly 1). */ -const PINNED_INTERSECTION_RATIO = 0.999; - -const getOverflowScrollParent = (node: HTMLElement): Element | null => { - let parent = node.parentElement; - while (parent) { - const style = getComputedStyle(parent); - if (/(auto|scroll|overlay)/.test(style.overflowY) || /(auto|scroll|overlay)/.test(style.overflowX)) { - return parent; - } - parent = parent.parentElement; - } - return null; -}; - -const assignRef = (ref: React.Ref | undefined, value: T | null) => { - if (!ref) { - return; - } - if (typeof ref === 'function') { - ref(value); - } else { - (ref as React.MutableRefObject).current = value; - } -}; export interface TheadProps extends React.HTMLProps { /** Content rendered inside the
row group */ @@ -40,6 +13,11 @@ export interface TheadProps extends React.HTMLProps { innerRef?: React.Ref; /** Indicates the contains a nested header */ hasNestedHeader?: boolean; + /** + * When true, applies the placeholder `PINNED` class for styling while the sticky header is scrolled + * within its scroll container. Drive this from app logic or a hook (see table examples). + */ + isPinned?: boolean; } const TheadBase: React.FunctionComponent = ({ @@ -48,63 +26,23 @@ const TheadBase: React.FunctionComponent = ({ noWrap = false, innerRef, hasNestedHeader, + isPinned, ...props -}: TheadProps) => { - const { isStickyHeader } = useContext(TableContext); - const observeStickyPin = !!isStickyHeader; - const [isPinned, setIsPinned] = useState(false); - const theadElRef = useRef(null); - - const setTheadRef = useCallback( - (node: HTMLTableSectionElement | null) => { - theadElRef.current = node; - assignRef(innerRef, node); - }, - [innerRef] - ); - - useEffect(() => { - if (!observeStickyPin || typeof IntersectionObserver === 'undefined') { - setIsPinned(false); - return; - } - - const el = theadElRef.current; - if (!el) { - return; - } - - const scrollRoot = getOverflowScrollParent(el); - - // Requires sticky thead `inset-block-start: -1px` in CSS (see table.css). - const observer = new IntersectionObserver( - ([entry]) => { - // console.log(scrollRoot, entry, entry.intersectionRatio); - setIsPinned(entry.intersectionRatio < PINNED_INTERSECTION_RATIO); - }, - { threshold: [0, 1], root: scrollRoot } - ); - - observer.observe(el); - return () => observer.disconnect(); - }, [observeStickyPin]); - - return ( - - {children} - - ); -}; +}: TheadProps) => ( + + {children} + +); export const Thead = forwardRef((props: TheadProps, ref: React.Ref) => ( diff --git a/packages/react-table/src/components/Table/examples/Table.md b/packages/react-table/src/components/Table/examples/Table.md index 85c2410f1ec..a69d82b407e 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, useLayoutEffect, useCallback, useEffect, useRef, useState } 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. @@ -427,6 +426,14 @@ To maintain proper sticky behavior across sticky columns and header, `Table` mus ``` +### Sticky columns and header (scroll-pinned class) + +This example matches [Sticky columns and header](#sticky-columns-and-header) but uses the `useTheadPinnedFromScrollParent` hook with refs on `InnerScrollContainer` and `Thead` to toggle `isPinned` and apply the placeholder `PINNED` class when the inner scroll container has been scrolled. If the scroll-root ref is not set, the hook falls back to the exported `getOverflowScrollParent` helper using the thead ref. + +```ts file="TableStickyColumnsAndHeaderScrollPinned.tsx" + +``` + ### Nested column headers To make a nested column header: diff --git a/packages/react-table/src/components/Table/examples/TableStickyColumnsAndHeaderScrollPinned.tsx b/packages/react-table/src/components/Table/examples/TableStickyColumnsAndHeaderScrollPinned.tsx new file mode 100644 index 00000000000..02f787f919a --- /dev/null +++ b/packages/react-table/src/components/Table/examples/TableStickyColumnsAndHeaderScrollPinned.tsx @@ -0,0 +1,192 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { + Table, + Thead, + Tr, + Th, + Tbody, + Td, + InnerScrollContainer, + OuterScrollContainer, + ThProps, + ISortBy +} from '@patternfly/react-table'; +import BlueprintIcon from '@patternfly/react-icons/dist/esm/icons/blueprint-icon'; + +const useTheadPinnedFromScrollParent = ({ track, scrollRootRef, theadRef }): { isPinned } => { + const [isPinned, setIsPinned] = useState(false); + + useLayoutEffect(() => { + if (!track) { + setIsPinned(false); + return; + } + + const scrollRoot = scrollRootRef.current; + if (!scrollRoot) { + setIsPinned(false); + return; + } + + const syncFromScroll = () => { + setIsPinned(scrollRoot.scrollTop > 0); + }; + syncFromScroll(); + scrollRoot.addEventListener('scroll', syncFromScroll, { passive: true }); + return () => scrollRoot.removeEventListener('scroll', syncFromScroll); + }, [track, scrollRootRef, theadRef]); + + return { isPinned }; +}; + +interface Fact { + name: string; + state: string; + detail1: string; + detail2: string; + detail3: string; + detail4: string; + detail5: string; + detail6: string; + detail7: string; +} + +export const TableStickyColumnsAndHeaderScrollPinned: React.FunctionComponent = () => { + 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' + }; + + const [activeSortIndex, setActiveSortIndex] = useState(-1); + const [activeSortDirection, setActiveSortDirection] = useState(); + + const innerScrollRef = useRef(null); + const theadRef = useRef(null); + + const { isPinned } = useTheadPinnedFromScrollParent({ + track: true, + scrollRootRef: innerScrollRef, + theadRef + }); + + const getSortableRowValues = (fact: Fact): (string | number)[] => { + const { name, state, detail1, detail2, detail3, detail4, detail5, detail6, detail7 } = fact; + return [name, state, detail1, detail2, detail3, detail4, detail5, detail6, detail7]; + }; + + let sortedFacts = facts; + if (activeSortIndex > -1) { + sortedFacts = facts.sort((a, b) => { + const aValue = getSortableRowValues(a)[activeSortIndex]; + const bValue = getSortableRowValues(b)[activeSortIndex]; + if (aValue === bValue) { + return 0; + } + if (activeSortDirection === 'asc') { + return aValue > bValue ? 1 : -1; + } else { + return bValue > aValue ? 1 : -1; + } + }); + } + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex + }); + + return ( +
+ + +
+ + + + + + + + + + + + + + + {sortedFacts.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} +
+ + + + ); +};