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 > {
@@ -36,218 +32,86 @@ export interface ScrollableProps extends React.HTMLProps<HTMLDivElement> {
3632 onScrolledToBottom ?( ) : void ;
3733}
3834
39- interface State {
40- topShadow : boolean ;
41- bottomShadow : boolean ;
42- scrollPosition : number ;
43- canScroll : boolean ;
44- }
45-
46- export class Scrollable extends Component < ScrollableProps , State > {
47- static ScrollTo = ScrollTo ;
48- static forNode ( node : HTMLElement ) : HTMLElement | Document {
49- const closestElement = node . closest ( scrollable . selector ) ;
50- return closestElement instanceof HTMLElement ? closestElement : document ;
51- }
52-
53- state : State = {
54- topShadow : false ,
55- bottomShadow : false ,
56- scrollPosition : 0 ,
57- canScroll : false ,
58- } ;
59-
60- private stickyManager = new StickyManager ( ) ;
61-
62- private scrollArea : HTMLElement | null = null ;
63-
64- private handleResize = debounce (
65- ( ) => {
66- this . handleScroll ( ) ;
67- } ,
68- 50 ,
69- { trailing : true } ,
70- ) ;
71-
72- componentDidMount ( ) {
73- if ( this . scrollArea == null ) {
74- return ;
75- }
76- this . stickyManager . setContainer ( this . scrollArea ) ;
77- this . scrollArea . addEventListener ( 'scroll' , ( ) => {
78- window . requestAnimationFrame ( this . handleScroll ) ;
79- } ) ;
80- window . addEventListener ( 'resize' , this . handleResize ) ;
81- window . requestAnimationFrame ( ( ) => {
82- this . handleScroll ( ) ;
83- if ( this . props . hint ) {
84- this . scrollHint ( ) ;
85- }
86- } ) ;
87- }
88-
89- componentWillUnmount ( ) {
90- if ( this . scrollArea == null ) {
91- 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 ) ;
9257 }
93- this . scrollArea . removeEventListener ( 'scroll' , this . handleScroll ) ;
94- window . removeEventListener ( 'resize' , this . handleResize ) ;
95- this . stickyManager . removeScrollListener ( ) ;
96- }
97-
98- componentDidUpdate ( ) {
99- const { scrollPosition} = this . state ;
100- if ( scrollPosition && this . scrollArea && scrollPosition > 0 ) {
101- this . scrollArea . scrollTop = scrollPosition ;
102- }
103- }
104-
105- render ( ) {
106- const { topShadow, bottomShadow, canScroll} = this . state ;
107- const {
108- children,
109- className,
110- horizontal,
111- vertical = true ,
112- shadow,
113- hint,
114- focusable,
115- onScrolledToBottom,
116- ...rest
117- } = this . props ;
58+ } , [ hint ] ) ;
11859
119- const finalClassName = classNames (
120- className ,
121- styles . Scrollable ,
122- vertical && styles . vertical ,
123- horizontal && styles . horizontal ,
124- topShadow && styles . hasTopShadow ,
125- bottomShadow && styles . hasBottomShadow ,
126- vertical && canScroll && styles . verticalHasScrolling ,
127- ) ;
60+ useEffect ( ( ) => {
61+ const currentScrollArea = scrollArea . current ;
12862
129- return (
130- < ScrollableContext . Provider value = { this . scrollToPosition } >
131- < StickyManagerContext . Provider value = { this . stickyManager } >
132- < div
133- className = { finalClassName }
134- { ...scrollable . props }
135- { ...rest }
136- ref = { this . setScrollArea }
137- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
138- tabIndex = { focusable ? 0 : undefined }
139- >
140- { children }
141- </ div >
142- </ StickyManagerContext . Provider >
143- </ ScrollableContext . Provider >
144- ) ;
145- }
146-
147- private setScrollArea = ( scrollArea : HTMLElement | null ) => {
148- this . scrollArea = scrollArea ;
149- } ;
150-
151- private handleScroll = ( ) => {
152- const { scrollArea} = this ;
153- const { scrollPosition} = this . state ;
154- const { shadow, onScrolledToBottom} = this . props ;
155- if ( scrollArea == null ) {
63+ if ( ! currentScrollArea ) {
15664 return ;
15765 }
158- const { scrollTop, clientHeight, scrollHeight} = scrollArea ;
159- const shouldBottomShadow = Boolean (
160- shadow && ! ( scrollTop + clientHeight >= scrollHeight ) ,
161- ) ;
162- const shouldTopShadow = Boolean (
163- shadow && scrollTop > 0 && scrollPosition > 0 ,
164- ) ;
16566
166- const canScroll = scrollHeight > clientHeight ;
167- const hasScrolledToBottom =
168- scrollHeight - scrollTop <= clientHeight + LOW_RES_BUFFER ;
67+ const handleScroll = ( ) => {
68+ const { scrollTop, clientHeight, scrollHeight} = currentScrollArea ;
16969
170- if ( canScroll && hasScrolledToBottom && onScrolledToBottom ) {
171- onScrolledToBottom ( ) ;
172- }
70+ setBottomShadow (
71+ Boolean ( shadow && ! ( scrollTop + clientHeight >= scrollHeight ) ) ,
72+ ) ;
73+ setTopShadow ( Boolean ( shadow && scrollTop > 0 ) ) ;
74+ } ;
17375
174- this . setState ( {
175- topShadow : shouldTopShadow ,
176- bottomShadow : shouldBottomShadow ,
177- scrollPosition : scrollTop ,
178- canScroll,
179- } ) ;
180- } ;
76+ const handleResize = debounce ( handleScroll , 50 , { trailing : true } ) ;
18177
182- private scrollHint = ( ) => {
183- const { scrollArea} = this ;
184- if ( scrollArea == null ) {
185- return ;
186- }
187- const { clientHeight, scrollHeight} = scrollArea ;
188- if (
189- PREFERS_REDUCED_MOTION ||
190- this . state . scrollPosition > 0 ||
191- scrollHeight <= clientHeight
192- ) {
193- return ;
194- }
78+ stickyManager . current ?. setContainer ( currentScrollArea ) ;
79+ currentScrollArea . addEventListener ( 'scroll' , handleScroll ) ;
80+ window . addEventListener ( 'resize' , handleResize ) ;
19581
196- const scrollDistance = scrollHeight - clientHeight ;
197- this . toggleLock ( ) ;
198- this . setState (
199- {
200- scrollPosition :
201- scrollDistance > MAX_SCROLL_DISTANCE
202- ? MAX_SCROLL_DISTANCE
203- : scrollDistance ,
204- } ,
205- ( ) => {
206- window . requestAnimationFrame ( this . scrollStep ) ;
207- } ,
208- ) ;
209- } ;
82+ handleScroll ( ) ;
21083
211- private scrollStep = ( ) => {
212- this . setState (
213- ( { scrollPosition} ) => {
214- const delta = scrollPosition * DELTA_PERCENTAGE ;
215- return {
216- scrollPosition : delta < DELTA_THRESHOLD ? 0 : scrollPosition - delta ,
217- } ;
218- } ,
219- ( ) => {
220- if ( this . state . scrollPosition > 0 ) {
221- window . requestAnimationFrame ( this . scrollStep ) ;
222- } else {
223- this . toggleLock ( false ) ;
224- }
225- } ,
226- ) ;
227- } ;
84+ return ( ) => {
85+ currentScrollArea . removeEventListener ( 'scroll' , handleScroll ) ;
86+ window . removeEventListener ( 'resize' , handleResize ) ;
87+ } ;
88+ } , [ shadow ] ) ;
22889
229- private toggleLock ( shouldLock = true ) {
230- const { scrollArea} = this ;
231- if ( scrollArea == null ) {
232- return ;
233- }
234-
235- EVENTS_TO_LOCK . forEach ( ( eventName ) => {
236- if ( shouldLock ) {
237- scrollArea . addEventListener ( eventName , prevent ) ;
238- } else {
239- scrollArea . removeEventListener ( eventName , prevent ) ;
240- }
241- } ) ;
242- }
243-
244- private scrollToPosition = ( scrollY : number ) => {
245- this . setState ( { scrollPosition : scrollY } ) ;
246- } ;
247- }
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+ ) ;
24898
249- function prevent ( evt : Event ) {
250- 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+ ) ;
251115}
252116
253117function prefersReducedMotion ( ) {
@@ -257,3 +121,30 @@ function prefersReducedMotion() {
257121 return false ;
258122 }
259123}
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