1- import React , { Component } from 'react' ;
1+ import React , { useEffect , useRef , useState , useCallback } from 'react' ;
22
33import { debounce } from '../../utilities/debounce' ;
44import { classNames } from '../../utilities/css' ;
@@ -12,11 +12,7 @@ import {ScrollTo} from './components';
1212import { ScrollableContext } from './context' ;
1313import styles from './Scrollable.scss' ;
1414
15- const MAX_SCROLL_DISTANCE = 100 ;
16- const DELTA_THRESHOLD = 0.2 ;
17- const DELTA_PERCENTAGE = 0.2 ;
18- const EVENTS_TO_LOCK = [ 'scroll' , 'touchmove' , 'wheel' ] ;
19- const PREFERS_REDUCED_MOTION = prefersReducedMotion ( ) ;
15+ const MAX_SCROLL_HINT_DISTANCE = 100 ;
2016const LOW_RES_BUFFER = 2 ;
2117
2218export interface ScrollableProps extends React . HTMLProps < HTMLDivElement > {
@@ -40,218 +36,86 @@ export interface ScrollableProps extends React.HTMLProps<HTMLDivElement> {
4036 onScrolledToBottom ?( ) : void ;
4137}
4238
43- interface State {
44- topShadow : boolean ;
45- bottomShadow : boolean ;
46- scrollPosition : number ;
47- canScroll : boolean ;
48- }
49-
50- export class Scrollable extends Component < ScrollableProps , State > {
51- static ScrollTo = ScrollTo ;
52- static forNode ( node : HTMLElement ) : HTMLElement | Document {
53- const closestElement = node . closest ( scrollable . selector ) ;
54- return closestElement instanceof HTMLElement ? closestElement : document ;
55- }
56-
57- state : State = {
58- topShadow : false ,
59- bottomShadow : false ,
60- scrollPosition : 0 ,
61- canScroll : false ,
62- } ;
63-
64- private stickyManager = new StickyManager ( ) ;
65-
66- private scrollArea : HTMLElement | null = null ;
67-
68- private handleResize = debounce (
69- ( ) => {
70- this . handleScroll ( ) ;
71- } ,
72- 50 ,
73- { trailing : true } ,
74- ) ;
75-
76- componentDidMount ( ) {
77- if ( this . scrollArea == null ) {
78- return ;
79- }
80- this . stickyManager . setContainer ( this . scrollArea ) ;
81- this . scrollArea . addEventListener ( 'scroll' , ( ) => {
82- window . requestAnimationFrame ( this . handleScroll ) ;
83- } ) ;
84- window . addEventListener ( 'resize' , this . handleResize ) ;
85- window . requestAnimationFrame ( ( ) => {
86- this . handleScroll ( ) ;
87- if ( this . props . hint ) {
88- this . scrollHint ( ) ;
89- }
90- } ) ;
91- }
92-
93- componentWillUnmount ( ) {
94- if ( this . scrollArea == null ) {
95- return ;
39+ export function Scrollable ( {
40+ children,
41+ className,
42+ horizontal = true ,
43+ vertical = true ,
44+ shadow,
45+ hint,
46+ focusable,
47+ onScrolledToBottom,
48+ ...rest
49+ } : ScrollableProps ) {
50+ const [ topShadow , setTopShadow ] = useState ( false ) ;
51+ const [ bottomShadow , setBottomShadow ] = useState ( false ) ;
52+ const stickyManager = useRef ( new StickyManager ( ) ) ;
53+ const scrollArea = useRef < HTMLDivElement > ( null ) ;
54+ const scrollTo = useCallback ( ( scrollY : number ) => {
55+ scrollArea . current ?. scrollTo ( { top : scrollY , behavior : 'smooth' } ) ;
56+ } , [ ] ) ;
57+
58+ useEffect ( ( ) => {
59+ if ( hint ) {
60+ performScrollHint ( scrollArea . current ) ;
9661 }
97- this . scrollArea . removeEventListener ( 'scroll' , this . handleScroll ) ;
98- window . removeEventListener ( 'resize' , this . handleResize ) ;
99- this . stickyManager . removeScrollListener ( ) ;
100- }
101-
102- componentDidUpdate ( ) {
103- const { scrollPosition} = this . state ;
104- if ( scrollPosition && this . scrollArea && scrollPosition > 0 ) {
105- this . scrollArea . scrollTop = scrollPosition ;
106- }
107- }
108-
109- render ( ) {
110- const { topShadow, bottomShadow, canScroll} = this . state ;
111- const {
112- children,
113- className,
114- horizontal = true ,
115- vertical = true ,
116- shadow,
117- hint,
118- focusable,
119- onScrolledToBottom,
120- ...rest
121- } = this . props ;
62+ } , [ hint ] ) ;
12263
123- const finalClassName = classNames (
124- className ,
125- styles . Scrollable ,
126- vertical && styles . vertical ,
127- horizontal && styles . horizontal ,
128- topShadow && styles . hasTopShadow ,
129- bottomShadow && styles . hasBottomShadow ,
130- vertical && canScroll && styles . verticalHasScrolling ,
131- ) ;
64+ useEffect ( ( ) => {
65+ const currentScrollArea = scrollArea . current ;
13266
133- return (
134- < ScrollableContext . Provider value = { this . scrollToPosition } >
135- < StickyManagerContext . Provider value = { this . stickyManager } >
136- < div
137- className = { finalClassName }
138- { ...scrollable . props }
139- { ...rest }
140- ref = { this . setScrollArea }
141- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
142- tabIndex = { focusable ? 0 : undefined }
143- >
144- { children }
145- </ div >
146- </ StickyManagerContext . Provider >
147- </ ScrollableContext . Provider >
148- ) ;
149- }
150-
151- private setScrollArea = ( scrollArea : HTMLElement | null ) => {
152- this . scrollArea = scrollArea ;
153- } ;
154-
155- private handleScroll = ( ) => {
156- const { scrollArea} = this ;
157- const { scrollPosition} = this . state ;
158- const { shadow, onScrolledToBottom} = this . props ;
159- if ( scrollArea == null ) {
67+ if ( ! currentScrollArea ) {
16068 return ;
16169 }
162- const { scrollTop, clientHeight, scrollHeight} = scrollArea ;
163- const shouldBottomShadow = Boolean (
164- shadow && ! ( scrollTop + clientHeight >= scrollHeight ) ,
165- ) ;
166- const shouldTopShadow = Boolean (
167- shadow && scrollTop > 0 && scrollPosition > 0 ,
168- ) ;
16970
170- const canScroll = scrollHeight > clientHeight ;
171- const hasScrolledToBottom =
172- scrollHeight - scrollTop <= clientHeight + LOW_RES_BUFFER ;
71+ const handleScroll = ( ) => {
72+ const { scrollTop, clientHeight, scrollHeight} = currentScrollArea ;
17373
174- if ( canScroll && hasScrolledToBottom && onScrolledToBottom ) {
175- onScrolledToBottom ( ) ;
176- }
74+ setBottomShadow (
75+ Boolean ( shadow && ! ( scrollTop + clientHeight >= scrollHeight ) ) ,
76+ ) ;
77+ setTopShadow ( Boolean ( shadow && scrollTop > 0 ) ) ;
78+ } ;
17779
178- this . setState ( {
179- topShadow : shouldTopShadow ,
180- bottomShadow : shouldBottomShadow ,
181- scrollPosition : scrollTop ,
182- canScroll,
183- } ) ;
184- } ;
80+ const handleResize = debounce ( handleScroll , 50 , { trailing : true } ) ;
18581
186- private scrollHint = ( ) => {
187- const { scrollArea} = this ;
188- if ( scrollArea == null ) {
189- return ;
190- }
191- const { clientHeight, scrollHeight} = scrollArea ;
192- if (
193- PREFERS_REDUCED_MOTION ||
194- this . state . scrollPosition > 0 ||
195- scrollHeight <= clientHeight
196- ) {
197- return ;
198- }
82+ stickyManager . current ?. setContainer ( currentScrollArea ) ;
83+ currentScrollArea . addEventListener ( 'scroll' , handleScroll ) ;
84+ globalThis . addEventListener ( 'resize' , handleResize ) ;
19985
200- const scrollDistance = scrollHeight - clientHeight ;
201- this . toggleLock ( ) ;
202- this . setState (
203- {
204- scrollPosition :
205- scrollDistance > MAX_SCROLL_DISTANCE
206- ? MAX_SCROLL_DISTANCE
207- : scrollDistance ,
208- } ,
209- ( ) => {
210- window . requestAnimationFrame ( this . scrollStep ) ;
211- } ,
212- ) ;
213- } ;
86+ handleScroll ( ) ;
21487
215- private scrollStep = ( ) => {
216- this . setState (
217- ( { scrollPosition} ) => {
218- const delta = scrollPosition * DELTA_PERCENTAGE ;
219- return {
220- scrollPosition : delta < DELTA_THRESHOLD ? 0 : scrollPosition - delta ,
221- } ;
222- } ,
223- ( ) => {
224- if ( this . state . scrollPosition > 0 ) {
225- window . requestAnimationFrame ( this . scrollStep ) ;
226- } else {
227- this . toggleLock ( false ) ;
228- }
229- } ,
230- ) ;
231- } ;
88+ return ( ) => {
89+ currentScrollArea . removeEventListener ( 'scroll' , handleScroll ) ;
90+ globalThis . removeEventListener ( 'resize' , handleResize ) ;
91+ } ;
92+ } , [ shadow ] ) ;
23293
233- private toggleLock ( shouldLock = true ) {
234- const { scrollArea} = this ;
235- if ( scrollArea == null ) {
236- return ;
237- }
238-
239- EVENTS_TO_LOCK . forEach ( ( eventName ) => {
240- if ( shouldLock ) {
241- scrollArea . addEventListener ( eventName , prevent ) ;
242- } else {
243- scrollArea . removeEventListener ( eventName , prevent ) ;
244- }
245- } ) ;
246- }
247-
248- private scrollToPosition = ( scrollY : number ) => {
249- this . setState ( { scrollPosition : scrollY } ) ;
250- } ;
251- }
94+ const finalClassName = classNames (
95+ className ,
96+ styles . Scrollable ,
97+ vertical && styles . vertical ,
98+ horizontal && styles . horizontal ,
99+ topShadow && styles . hasTopShadow ,
100+ bottomShadow && styles . hasBottomShadow ,
101+ ) ;
252102
253- function prevent ( evt : Event ) {
254- evt . preventDefault ( ) ;
103+ return (
104+ < ScrollableContext . Provider value = { scrollTo } >
105+ < StickyManagerContext . Provider value = { stickyManager . current } >
106+ < div
107+ className = { finalClassName }
108+ { ...scrollable . props }
109+ { ...rest }
110+ ref = { scrollArea }
111+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
112+ tabIndex = { focusable ? 0 : undefined }
113+ >
114+ { children }
115+ </ div >
116+ </ StickyManagerContext . Provider >
117+ </ ScrollableContext . Provider >
118+ ) ;
255119}
256120
257121function prefersReducedMotion ( ) {
@@ -261,3 +125,30 @@ function prefersReducedMotion() {
261125 return false ;
262126 }
263127}
128+
129+ function performScrollHint ( elem ?: HTMLDivElement | null ) {
130+ if ( ! elem || prefersReducedMotion ( ) ) {
131+ return ;
132+ }
133+
134+ const scrollableDistance = elem . scrollHeight - elem . clientHeight ;
135+ const distanceToPeek =
136+ Math . min ( MAX_SCROLL_HINT_DISTANCE , scrollableDistance ) - LOW_RES_BUFFER ;
137+
138+ const goBackToTop = ( ) => {
139+ if ( elem . scrollTop >= distanceToPeek ) {
140+ elem . removeEventListener ( 'scroll' , goBackToTop ) ;
141+ elem . scrollTo ( { top : 0 , behavior : 'smooth' } ) ;
142+ }
143+ } ;
144+
145+ elem . addEventListener ( 'scroll' , goBackToTop ) ;
146+ elem . scrollTo ( { top : MAX_SCROLL_HINT_DISTANCE , behavior : 'smooth' } ) ;
147+ }
148+
149+ Scrollable . ScrollTo = ScrollTo ;
150+
151+ Scrollable . forNode = ( node : HTMLElement ) : HTMLElement | Document => {
152+ const closestElement = node . closest ( scrollable . selector ) ;
153+ return closestElement instanceof HTMLElement ? closestElement : document ;
154+ } ;
0 commit comments