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