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/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 ( - + { 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 = ({ @@ -21,6 +26,7 @@ const TheadBase: React.FunctionComponent = ({ noWrap = false, innerRef, hasNestedHeader, + isPinned, ...props }: TheadProps) => ( = ({ styles.tableThead, className, noWrap && styles.modifiers.nowrap, - hasNestedHeader && styles.modifiers.nestedColumnHeader + hasNestedHeader && styles.modifiers.nestedColumnHeader, + isPinned && 'PINNED' )} ref={innerRef} {...props} 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} +
+ + +
+ ); +};