1- import React , { Component } from 'react' ;
2- import { flushSync } from 'react-dom' ;
1+ import React , { useEffect , useRef , useState , useCallback } from 'react' ;
32
43import { debounce } from '../../utilities/debounce' ;
54import { classNames } from '../../utilities/css' ;
@@ -13,11 +12,7 @@ import {ScrollTo} from './components';
1312import { ScrollableContext } from './context' ;
1413import 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 ;
2116const LOW_RES_BUFFER = 2 ;
2217
2318export 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
263117function 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