Skip to content

Commit f37d98b

Browse files
committed
refactor: rewrite Scrollable utilizing platform features
This updates the Scrollable component to be a function component, but also changes the fundamental implementation strategy. Previously, the Scrollable component held internal state for the scroll position and controlled the scroll of the actual DOM node as a side effect. With this change, there is no controlling of the DOM node's scroll, allowing the platform to handle all scrolling interaction and only using native features to supply enhancements for the ScrollTo component, the hint functionality.
1 parent 93eecdb commit f37d98b

File tree

2 files changed

+143
-1
lines changed

2 files changed

+143
-1
lines changed

polaris-react/src/components/Scrollable/Scrollable.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ interface State {
4444
canScroll: boolean;
4545
}
4646

47-
export class Scrollable extends Component<ScrollableProps, State> {
47+
export class ScrollableLegacy extends Component<ScrollableProps, State> {
4848
static ScrollTo = ScrollTo;
4949
static forNode(node: HTMLElement): HTMLElement | Document {
5050
const closestElement = node.closest(scrollable.selector);
@@ -267,3 +267,5 @@ function prefersReducedMotion() {
267267
return false;
268268
}
269269
}
270+
271+
export {Scrollable2 as Scrollable} from './Scrollable2';
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import React, {useEffect, useRef, useState, useCallback} from 'react';
2+
3+
import {debounce} from '../../utilities/debounce';
4+
import {classNames} from '../../utilities/css';
5+
import {
6+
StickyManager,
7+
StickyManagerContext,
8+
} from '../../utilities/sticky-manager';
9+
import {scrollable} from '../shared';
10+
11+
import {ScrollTo} from './components';
12+
import {ScrollableContext} from './context';
13+
import styles from './Scrollable.scss';
14+
15+
export interface ScrollableProps extends React.HTMLProps<HTMLDivElement> {
16+
/** Content to display in scrollable area */
17+
children?: React.ReactNode;
18+
/** Scroll content vertically */
19+
vertical?: boolean;
20+
/** Scroll content horizontally */
21+
horizontal?: boolean;
22+
/** Add a shadow when content is scrollable */
23+
shadow?: boolean;
24+
/** Slightly hints content upon mounting when scrollable */
25+
hint?: boolean;
26+
/** Adds a tabIndex to scrollable when children are not focusable */
27+
focusable?: boolean;
28+
/** Called when scrolled to the bottom of the scroll area */
29+
onScrolledToBottom?(): void;
30+
}
31+
32+
export function Scrollable2({
33+
children,
34+
className,
35+
horizontal,
36+
vertical = true,
37+
shadow,
38+
hint,
39+
focusable,
40+
onScrolledToBottom,
41+
...rest
42+
}: ScrollableProps) {
43+
const [topShadow, setTopShadow] = useState(false);
44+
const [bottomShadow, setBottomShadow] = useState(false);
45+
const stickyManager = useRef(new StickyManager());
46+
const scrollArea = useRef<HTMLDivElement>(null);
47+
const scrollTo = useCallback((scrollY: number) => {
48+
scrollArea.current?.scrollTo({top: scrollY, behavior: 'smooth'});
49+
}, []);
50+
51+
useEffect(() => {
52+
if (hint && scrollArea.current) peek(scrollArea.current);
53+
}, [hint]);
54+
55+
useEffect(() => {
56+
const currentScrollArea = scrollArea.current;
57+
58+
if (!currentScrollArea) {
59+
return;
60+
}
61+
62+
const handleScroll = () => {
63+
const {scrollTop, clientHeight, scrollHeight} = currentScrollArea;
64+
65+
setBottomShadow(
66+
Boolean(shadow && !(scrollTop + clientHeight >= scrollHeight)),
67+
);
68+
setTopShadow(Boolean(shadow && scrollTop > 0));
69+
};
70+
71+
const handleResize = debounce(handleScroll, 50, {trailing: true});
72+
73+
stickyManager.current?.setContainer(currentScrollArea);
74+
75+
window.requestAnimationFrame(handleScroll);
76+
77+
currentScrollArea.addEventListener('scroll', () => {
78+
window.requestAnimationFrame(handleScroll);
79+
});
80+
81+
window.addEventListener('resize', handleResize);
82+
83+
return () => {
84+
currentScrollArea.removeEventListener('scroll', handleScroll);
85+
window.removeEventListener('resize', handleResize);
86+
};
87+
}, [shadow]);
88+
89+
const finalClassName = classNames(
90+
className,
91+
styles.Scrollable,
92+
vertical && styles.vertical,
93+
horizontal && styles.horizontal,
94+
topShadow && styles.hasTopShadow,
95+
bottomShadow && styles.hasBottomShadow,
96+
);
97+
98+
return (
99+
<ScrollableContext.Provider value={scrollTo}>
100+
<StickyManagerContext.Provider value={stickyManager.current}>
101+
<div
102+
className={finalClassName}
103+
{...scrollable.props}
104+
{...rest}
105+
ref={scrollArea}
106+
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
107+
tabIndex={focusable ? 0 : undefined}
108+
>
109+
{children}
110+
</div>
111+
</StickyManagerContext.Provider>
112+
</ScrollableContext.Provider>
113+
);
114+
}
115+
116+
Scrollable2.ScrollTo = ScrollTo;
117+
118+
function prefersReducedMotion() {
119+
try {
120+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
121+
} catch (err) {
122+
return false;
123+
}
124+
}
125+
126+
function peek(elem?: HTMLDivElement) {
127+
if (!elem || prefersReducedMotion()) {
128+
return;
129+
}
130+
131+
function goBack() {
132+
if (elem?.scrollTop! > 99) {
133+
elem?.removeEventListener('scroll', goBack);
134+
elem?.scrollTo({top: 0, behavior: 'smooth'});
135+
}
136+
}
137+
138+
elem.addEventListener('scroll', goBack);
139+
elem.scrollTo({top: 100, behavior: 'smooth'});
140+
}

0 commit comments

Comments
 (0)