Skip to content

Commit de46073

Browse files
committed
scroll event approach
1 parent 9e56259 commit de46073

File tree

6 files changed

+276
-96
lines changed

6 files changed

+276
-96
lines changed

packages/react-core/src/components/Toolbar/examples/Toolbar.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ propComponents: ['Toolbar', 'ToolbarContent', 'ToolbarGroup', 'ToolbarItem', 'To
55
section: components
66
---
77

8-
import { Fragment, useState } from 'react';
8+
import { Fragment, useState, useRef, useLayoutEffect } from 'react';
99

1010
import EditIcon from '@patternfly/react-icons/dist/esm/icons/edit-icon';
1111
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
114114
```
115115

116116
## Examples with spacers and wrapping
117+
117118
You may adjust the space between toolbar items to arrange them into groups. Read our spacers documentation to learn more about using spacers.
118119

119120
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".
120121

121122
### Toolbar content wrapping
123+
122124
The toolbar content section will wrap by default, but you can set the `rowRap` property to `noWrap` to make it not wrap.
123125

124126
```ts file="./ToolbarContentWrap.tsx"

packages/react-core/src/components/Toolbar/examples/ToolbarSticky.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,57 @@
1-
import { Fragment, useState } from 'react';
1+
import { Fragment, useLayoutEffect, useRef, useState } from 'react';
22
import { Toolbar, ToolbarItem, ToolbarContent, SearchInput, Checkbox } from '@patternfly/react-core';
33

4+
const useTheadPinnedFromScrollParent = ({ track, scrollRootRef, theadRef }): { isPinned } => {
5+
const [isPinned, setIsPinned] = useState(false);
6+
7+
useLayoutEffect(() => {
8+
if (!track) {
9+
setIsPinned(false);
10+
return;
11+
}
12+
13+
const scrollRoot = scrollRootRef.current;
14+
if (!scrollRoot) {
15+
setIsPinned(false);
16+
return;
17+
}
18+
19+
const syncFromScroll = () => {
20+
setIsPinned(scrollRoot.scrollTop > 0);
21+
};
22+
syncFromScroll();
23+
scrollRoot.addEventListener('scroll', syncFromScroll, { passive: true });
24+
return () => scrollRoot.removeEventListener('scroll', syncFromScroll);
25+
}, [track, scrollRootRef, theadRef]);
26+
27+
return { isPinned };
28+
};
29+
430
export const ToolbarSticky = () => {
531
const [isSticky, setIsSticky] = useState(true);
632
const [showEvenOnly, setShowEvenOnly] = useState(true);
733
const [searchValue, setSearchValue] = useState('');
834
const array = Array.from(Array(30), (_, x) => x); // create array of numbers from 1-30 for demo purposes
935
const numbers = showEvenOnly ? array.filter((number) => number % 2 === 0) : array;
1036

37+
const innerScrollRef = useRef<HTMLDivElement>(null);
38+
const toolbarRef = useRef<HTMLDivElement>(null);
39+
const { isPinned } = useTheadPinnedFromScrollParent({
40+
track: true,
41+
scrollRootRef: innerScrollRef,
42+
theadRef: toolbarRef
43+
});
44+
1145
return (
1246
<Fragment>
13-
<div style={{ overflowY: 'scroll', height: '200px' }}>
14-
<Toolbar id="toolbar-sticky" inset={{ default: 'insetNone' }} isSticky={isSticky}>
47+
<div style={{ overflowY: 'scroll', height: '200px' }} ref={innerScrollRef}>
48+
<Toolbar
49+
className={isPinned ? 'PINNED' : ''}
50+
id="toolbar-sticky"
51+
inset={{ default: 'insetNone' }}
52+
isSticky={isSticky}
53+
ref={toolbarRef}
54+
>
1555
<ToolbarContent>
1656
<ToolbarItem>
1757
<SearchInput
Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { css } from '@patternfly/react-styles';
22
import styles from '@patternfly/react-styles/css/components/Table/table-scrollable';
3+
import { forwardRef } from 'react';
34

45
export interface InnerScrollContainerProps extends React.HTMLProps<HTMLDivElement> {
56
/** Content rendered inside the inner scroll container */
@@ -8,14 +9,14 @@ export interface InnerScrollContainerProps extends React.HTMLProps<HTMLDivElemen
89
className?: string;
910
}
1011

11-
export const InnerScrollContainer: React.FunctionComponent<InnerScrollContainerProps> = ({
12-
children,
13-
className,
14-
...props
15-
}: InnerScrollContainerProps) => (
16-
<div className={css(className, styles.scrollInnerWrapper)} {...props}>
12+
const InnerScrollContainerBase = (
13+
{ children, className, ...props }: InnerScrollContainerProps,
14+
ref: React.ForwardedRef<HTMLDivElement>
15+
) => (
16+
<div ref={ref} className={css(className, styles.scrollInnerWrapper)} {...props}>
1717
{children}
1818
</div>
1919
);
2020

21+
export const InnerScrollContainer = forwardRef(InnerScrollContainerBase);
2122
InnerScrollContainer.displayName = 'InnerScrollContainer';

packages/react-table/src/components/Table/Thead.tsx

Lines changed: 22 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,6 @@
1-
import { forwardRef, useCallback, useContext, useEffect, useRef, useState } from 'react';
1+
import { forwardRef } from 'react';
22
import { css } from '@patternfly/react-styles';
33
import styles from '@patternfly/react-styles/css/components/Table/table';
4-
import { TableContext } from './Table';
5-
6-
/** Ratio must be below this to count as “pinned” (avoids doc-layout subpixel + strict threshold: [1] never hitting exactly 1). */
7-
const PINNED_INTERSECTION_RATIO = 0.999;
8-
9-
const getOverflowScrollParent = (node: HTMLElement): Element | null => {
10-
let parent = node.parentElement;
11-
while (parent) {
12-
const style = getComputedStyle(parent);
13-
if (/(auto|scroll|overlay)/.test(style.overflowY) || /(auto|scroll|overlay)/.test(style.overflowX)) {
14-
return parent;
15-
}
16-
parent = parent.parentElement;
17-
}
18-
return null;
19-
};
20-
21-
const assignRef = <T,>(ref: React.Ref<T> | undefined, value: T | null) => {
22-
if (!ref) {
23-
return;
24-
}
25-
if (typeof ref === 'function') {
26-
ref(value);
27-
} else {
28-
(ref as React.MutableRefObject<T | null>).current = value;
29-
}
30-
};
314

325
export interface TheadProps extends React.HTMLProps<HTMLTableSectionElement> {
336
/** Content rendered inside the <thead> row group */
@@ -40,6 +13,11 @@ export interface TheadProps extends React.HTMLProps<HTMLTableSectionElement> {
4013
innerRef?: React.Ref<any>;
4114
/** Indicates the <thead> contains a nested header */
4215
hasNestedHeader?: boolean;
16+
/**
17+
* When true, applies the placeholder `PINNED` class for styling while the sticky header is scrolled
18+
* within its scroll container. Drive this from app logic or a hook (see table examples).
19+
*/
20+
isPinned?: boolean;
4321
}
4422

4523
const TheadBase: React.FunctionComponent<TheadProps> = ({
@@ -48,63 +26,23 @@ const TheadBase: React.FunctionComponent<TheadProps> = ({
4826
noWrap = false,
4927
innerRef,
5028
hasNestedHeader,
29+
isPinned,
5130
...props
52-
}: TheadProps) => {
53-
const { isStickyHeader } = useContext(TableContext);
54-
const observeStickyPin = !!isStickyHeader;
55-
const [isPinned, setIsPinned] = useState(false);
56-
const theadElRef = useRef<HTMLTableSectionElement | null>(null);
57-
58-
const setTheadRef = useCallback(
59-
(node: HTMLTableSectionElement | null) => {
60-
theadElRef.current = node;
61-
assignRef(innerRef, node);
62-
},
63-
[innerRef]
64-
);
65-
66-
useEffect(() => {
67-
if (!observeStickyPin || typeof IntersectionObserver === 'undefined') {
68-
setIsPinned(false);
69-
return;
70-
}
71-
72-
const el = theadElRef.current;
73-
if (!el) {
74-
return;
75-
}
76-
77-
const scrollRoot = getOverflowScrollParent(el);
78-
79-
// Requires sticky thead `inset-block-start: -1px` in CSS (see table.css).
80-
const observer = new IntersectionObserver(
81-
([entry]) => {
82-
// console.log(scrollRoot, entry, entry.intersectionRatio);
83-
setIsPinned(entry.intersectionRatio < PINNED_INTERSECTION_RATIO);
84-
},
85-
{ threshold: [0, 1], root: scrollRoot }
86-
);
87-
88-
observer.observe(el);
89-
return () => observer.disconnect();
90-
}, [observeStickyPin]);
91-
92-
return (
93-
<thead
94-
className={css(
95-
styles.tableThead,
96-
className,
97-
noWrap && styles.modifiers.nowrap,
98-
hasNestedHeader && styles.modifiers.nestedColumnHeader,
99-
observeStickyPin && isPinned && 'PINNED'
100-
)}
101-
ref={setTheadRef}
102-
{...props}
103-
>
104-
{children}
105-
</thead>
106-
);
107-
};
31+
}: TheadProps) => (
32+
<thead
33+
className={css(
34+
styles.tableThead,
35+
className,
36+
noWrap && styles.modifiers.nowrap,
37+
hasNestedHeader && styles.modifiers.nestedColumnHeader,
38+
isPinned && 'PINNED'
39+
)}
40+
ref={innerRef}
41+
{...props}
42+
>
43+
{children}
44+
</thead>
45+
);
10846

10947
export const Thead = forwardRef((props: TheadProps, ref: React.Ref<HTMLTableSectionElement>) => (
11048
<TheadBase {...props} innerRef={ref} />

packages/react-table/src/components/Table/examples/Table.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The `Table` component takes an explicit and declarative approach, and its implem
4141

4242
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.
4343

44-
import { Fragment, isValidElement, useCallback, useEffect, useRef, useState } from 'react';
44+
import { Fragment, isValidElement, useLayoutEffect, useCallback, useEffect, useRef, useState } from 'react';
4545
import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon';
4646
import CodeBranchIcon from '@patternfly/react-icons/dist/esm/icons/code-branch-icon';
4747
import CodeIcon from '@patternfly/react-icons/dist/esm/icons/code-icon';
@@ -327,7 +327,6 @@ To enable a tree table:
327327
- `checkAriaLabel` - (optional) accessible label for the checkbox
328328
- `showDetailsAriaLabel` - (optional) accessible label for the show row details button in the responsive view
329329
4. The first `Td` in each row will pass the following to the `treeRow` prop:
330-
331330
- `onCollapse` - Callback when user expands/collapses a row to reveal/hide the row's children.
332331
- `onCheckChange` - (optional) Callback when user changes the checkbox on a row.
333332
- `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
427426

428427
```
429428

429+
### Sticky columns and header (scroll-pinned class)
430+
431+
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.
432+
433+
```ts file="TableStickyColumnsAndHeaderScrollPinned.tsx"
434+
435+
```
436+
430437
### Nested column headers
431438

432439
To make a nested column header:

0 commit comments

Comments
 (0)