Skip to content

Commit 56c2ce4

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 56c2ce4

File tree

1 file changed

+96
-215
lines changed

1 file changed

+96
-215
lines changed
Lines changed: 96 additions & 215 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import React, {Component} from 'react';
2-
import {flushSync} from 'react-dom';
1+
import React, {useEffect, useRef, useState, useCallback} from 'react';
32

43
import {debounce} from '../../utilities/debounce';
54
import {classNames} from '../../utilities/css';
@@ -13,11 +12,7 @@ import {ScrollTo} from './components';
1312
import {ScrollableContext} from './context';
1413
import styles from './Scrollable.scss';
1514

16-
const MAX_SCROLL_DISTANCE = 100;
17-
const DELTA_THRESHOLD = 0.2;
18-
const DELTA_PERCENTAGE = 0.2;
19-
const EVENTS_TO_LOCK = ['scroll', 'touchmove', 'wheel'];
20-
const PREFERS_REDUCED_MOTION = prefersReducedMotion();
15+
const MAX_SCROLL_HINT_DISTANCE = 100;
2116
const LOW_RES_BUFFER = 2;
2217

2318
export interface ScrollableProps extends React.HTMLProps<HTMLDivElement> {
@@ -37,227 +32,86 @@ export interface ScrollableProps extends React.HTMLProps<HTMLDivElement> {
3732
onScrolledToBottom?(): void;
3833
}
3934

40-
interface State {
41-
topShadow: boolean;
42-
bottomShadow: boolean;
43-
scrollPosition: number;
44-
canScroll: boolean;
45-
}
46-
47-
export class Scrollable extends Component<ScrollableProps, State> {
48-
static ScrollTo = ScrollTo;
49-
static forNode(node: HTMLElement): HTMLElement | Document {
50-
const closestElement = node.closest(scrollable.selector);
51-
return closestElement instanceof HTMLElement ? closestElement : document;
52-
}
53-
54-
state: State = {
55-
topShadow: false,
56-
bottomShadow: false,
57-
scrollPosition: 0,
58-
canScroll: false,
59-
};
60-
61-
private stickyManager = new StickyManager();
62-
63-
private scrollArea: HTMLElement | null = null;
64-
65-
private handleResize = debounce(
66-
() => {
67-
this.handleScroll();
68-
},
69-
50,
70-
{trailing: true},
71-
);
72-
73-
componentDidMount() {
74-
if (this.scrollArea == null) {
75-
return;
35+
export function Scrollable({
36+
children,
37+
className,
38+
horizontal,
39+
vertical = true,
40+
shadow,
41+
hint,
42+
focusable,
43+
onScrolledToBottom,
44+
...rest
45+
}: ScrollableProps) {
46+
const [topShadow, setTopShadow] = useState(false);
47+
const [bottomShadow, setBottomShadow] = useState(false);
48+
const stickyManager = useRef(new StickyManager());
49+
const scrollArea = useRef<HTMLDivElement>(null);
50+
const scrollTo = useCallback((scrollY: number) => {
51+
scrollArea.current?.scrollTo({top: scrollY, behavior: 'smooth'});
52+
}, []);
53+
54+
useEffect(() => {
55+
if (hint) {
56+
performScrollHint(scrollArea.current);
7657
}
77-
this.stickyManager.setContainer(this.scrollArea);
78-
this.scrollArea.addEventListener('scroll', () => {
79-
window.requestAnimationFrame(this.handleScroll);
80-
});
81-
window.addEventListener('resize', this.handleResize);
82-
window.requestAnimationFrame(() => {
83-
this.handleScroll();
84-
if (this.props.hint) {
85-
this.scrollHint();
86-
}
87-
});
88-
}
58+
}, [hint]);
8959

90-
componentWillUnmount() {
91-
if (this.scrollArea == null) {
92-
return;
93-
}
94-
this.scrollArea.removeEventListener('scroll', this.handleScroll);
95-
window.removeEventListener('resize', this.handleResize);
96-
this.stickyManager.removeScrollListener();
97-
}
60+
useEffect(() => {
61+
const currentScrollArea = scrollArea.current;
9862

99-
componentDidUpdate() {
100-
if (!this.scrollArea) {
63+
if (!currentScrollArea) {
10164
return;
10265
}
10366

104-
const {scrollPosition} = this.state;
105-
const availableScrollHeight =
106-
this.scrollArea.scrollHeight - this.scrollArea.clientHeight;
107-
108-
if (scrollPosition > 0 && scrollPosition < availableScrollHeight) {
109-
this.scrollArea.scrollTop = scrollPosition;
110-
}
111-
}
112-
113-
render() {
114-
const {topShadow, bottomShadow, canScroll} = this.state;
115-
const {
116-
children,
117-
className,
118-
horizontal,
119-
vertical = true,
120-
shadow,
121-
hint,
122-
focusable,
123-
onScrolledToBottom,
124-
...rest
125-
} = this.props;
126-
127-
const finalClassName = classNames(
128-
className,
129-
styles.Scrollable,
130-
vertical && styles.vertical,
131-
horizontal && styles.horizontal,
132-
topShadow && styles.hasTopShadow,
133-
bottomShadow && styles.hasBottomShadow,
134-
vertical && canScroll && styles.verticalHasScrolling,
135-
);
136-
137-
return (
138-
<ScrollableContext.Provider value={this.scrollToPosition}>
139-
<StickyManagerContext.Provider value={this.stickyManager}>
140-
<div
141-
className={finalClassName}
142-
{...scrollable.props}
143-
{...rest}
144-
ref={this.setScrollArea}
145-
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
146-
tabIndex={focusable ? 0 : undefined}
147-
>
148-
{children}
149-
</div>
150-
</StickyManagerContext.Provider>
151-
</ScrollableContext.Provider>
152-
);
153-
}
67+
const handleScroll = () => {
68+
const {scrollTop, clientHeight, scrollHeight} = currentScrollArea;
15469

155-
private setScrollArea = (scrollArea: HTMLElement | null) => {
156-
this.scrollArea = scrollArea;
157-
};
70+
setBottomShadow(
71+
Boolean(shadow && !(scrollTop + clientHeight >= scrollHeight)),
72+
);
73+
setTopShadow(Boolean(shadow && scrollTop > 0));
74+
};
15875

159-
private handleScroll = () => {
160-
const {scrollArea} = this;
161-
const {scrollPosition} = this.state;
162-
const {shadow, onScrolledToBottom} = this.props;
163-
if (scrollArea == null) {
164-
return;
165-
}
166-
const {scrollTop, clientHeight, scrollHeight} = scrollArea;
167-
const shouldBottomShadow = Boolean(
168-
shadow && !(scrollTop + clientHeight >= scrollHeight),
169-
);
170-
const shouldTopShadow = Boolean(
171-
shadow && scrollTop > 0 && scrollPosition > 0,
172-
);
76+
const handleResize = debounce(handleScroll, 50, {trailing: true});
17377

174-
const canScroll = scrollHeight > clientHeight;
175-
const hasScrolledToBottom =
176-
scrollHeight - scrollTop <= clientHeight + LOW_RES_BUFFER;
78+
stickyManager.current?.setContainer(currentScrollArea);
79+
currentScrollArea.addEventListener('scroll', handleScroll);
80+
window.addEventListener('resize', handleResize);
17781

178-
if (canScroll && hasScrolledToBottom && onScrolledToBottom) {
179-
onScrolledToBottom();
180-
}
82+
handleScroll();
18183

182-
flushSync(() => {
183-
this.setState({
184-
topShadow: shouldTopShadow,
185-
bottomShadow: shouldBottomShadow,
186-
scrollPosition: scrollTop,
187-
canScroll,
188-
});
189-
});
190-
};
84+
return () => {
85+
currentScrollArea.removeEventListener('scroll', handleScroll);
86+
window.removeEventListener('resize', handleResize);
87+
};
88+
}, [shadow]);
19189

192-
private scrollHint = () => {
193-
const {scrollArea} = this;
194-
if (scrollArea == null) {
195-
return;
196-
}
197-
const {clientHeight, scrollHeight} = scrollArea;
198-
if (
199-
PREFERS_REDUCED_MOTION ||
200-
this.state.scrollPosition > 0 ||
201-
scrollHeight <= clientHeight
202-
) {
203-
return;
204-
}
205-
206-
const scrollDistance = scrollHeight - clientHeight;
207-
this.toggleLock();
208-
this.setState(
209-
{
210-
scrollPosition:
211-
scrollDistance > MAX_SCROLL_DISTANCE
212-
? MAX_SCROLL_DISTANCE
213-
: scrollDistance,
214-
},
215-
() => {
216-
window.requestAnimationFrame(this.scrollStep);
217-
},
218-
);
219-
};
220-
221-
private scrollStep = () => {
222-
this.setState(
223-
({scrollPosition}) => {
224-
const delta = scrollPosition * DELTA_PERCENTAGE;
225-
return {
226-
scrollPosition: delta < DELTA_THRESHOLD ? 0 : scrollPosition - delta,
227-
};
228-
},
229-
() => {
230-
if (this.state.scrollPosition > 0) {
231-
window.requestAnimationFrame(this.scrollStep);
232-
} else {
233-
this.toggleLock(false);
234-
}
235-
},
236-
);
237-
};
238-
239-
private toggleLock(shouldLock = true) {
240-
const {scrollArea} = this;
241-
if (scrollArea == null) {
242-
return;
243-
}
244-
245-
EVENTS_TO_LOCK.forEach((eventName) => {
246-
if (shouldLock) {
247-
scrollArea.addEventListener(eventName, prevent);
248-
} else {
249-
scrollArea.removeEventListener(eventName, prevent);
250-
}
251-
});
252-
}
253-
254-
private scrollToPosition = (scrollY: number) => {
255-
this.setState({scrollPosition: scrollY});
256-
};
257-
}
90+
const finalClassName = classNames(
91+
className,
92+
styles.Scrollable,
93+
vertical && styles.vertical,
94+
horizontal && styles.horizontal,
95+
topShadow && styles.hasTopShadow,
96+
bottomShadow && styles.hasBottomShadow,
97+
);
25898

259-
function prevent(evt: Event) {
260-
evt.preventDefault();
99+
return (
100+
<ScrollableContext.Provider value={scrollTo}>
101+
<StickyManagerContext.Provider value={stickyManager.current}>
102+
<div
103+
className={finalClassName}
104+
{...scrollable.props}
105+
{...rest}
106+
ref={scrollArea}
107+
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
108+
tabIndex={focusable ? 0 : undefined}
109+
>
110+
{children}
111+
</div>
112+
</StickyManagerContext.Provider>
113+
</ScrollableContext.Provider>
114+
);
261115
}
262116

263117
function prefersReducedMotion() {
@@ -267,3 +121,30 @@ function prefersReducedMotion() {
267121
return false;
268122
}
269123
}
124+
125+
function performScrollHint(elem?: HTMLDivElement | null) {
126+
if (!elem || prefersReducedMotion()) {
127+
return;
128+
}
129+
130+
const scrollableDistance = elem.scrollHeight - elem.clientHeight;
131+
const distanceToPeek =
132+
Math.min(MAX_SCROLL_HINT_DISTANCE, scrollableDistance) - LOW_RES_BUFFER;
133+
134+
const goBackToTop = () => {
135+
if (elem.scrollTop >= distanceToPeek) {
136+
elem.removeEventListener('scroll', goBackToTop);
137+
elem.scrollTo({top: 0, behavior: 'smooth'});
138+
}
139+
};
140+
141+
elem.addEventListener('scroll', goBackToTop);
142+
elem.scrollTo({top: MAX_SCROLL_HINT_DISTANCE, behavior: 'smooth'});
143+
}
144+
145+
Scrollable.ScrollTo = ScrollTo;
146+
147+
Scrollable.forNode = (node: HTMLElement): HTMLElement | Document => {
148+
const closestElement = node.closest(scrollable.selector);
149+
return closestElement instanceof HTMLElement ? closestElement : document;
150+
};

0 commit comments

Comments
 (0)