|
|
|
|
@@ -53,11 +53,19 @@ type ScrollController = {
|
|
|
|
|
enableScrollEvents: () => void
|
|
|
|
|
/** Disable scroll events in `useScrollPosition`. */
|
|
|
|
|
disableScrollEvents: () => void
|
|
|
|
|
/** Retrieves the scrollable element. By default, it's window. */
|
|
|
|
|
getScrollableElement: () => Element | Window
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useScrollControllerContextValue(): ScrollController {
|
|
|
|
|
function useScrollControllerContextValue(
|
|
|
|
|
scrollableSelector: string
|
|
|
|
|
): ScrollController {
|
|
|
|
|
const scrollEventsEnabledRef = useRef(true)
|
|
|
|
|
|
|
|
|
|
const getScrollableElement = useCallback(() => {
|
|
|
|
|
return (document.querySelector(scrollableSelector) as Element) || window
|
|
|
|
|
}, [scrollableSelector])
|
|
|
|
|
|
|
|
|
|
return useMemo(
|
|
|
|
|
() => ({
|
|
|
|
|
scrollEventsEnabledRef,
|
|
|
|
|
@@ -67,8 +75,9 @@ function useScrollControllerContextValue(): ScrollController {
|
|
|
|
|
disableScrollEvents: () => {
|
|
|
|
|
scrollEventsEnabledRef.current = false
|
|
|
|
|
},
|
|
|
|
|
getScrollableElement,
|
|
|
|
|
}),
|
|
|
|
|
[]
|
|
|
|
|
[getScrollableElement]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -78,10 +87,12 @@ const ScrollMonitorContext = React.createContext<ScrollController | undefined>(
|
|
|
|
|
|
|
|
|
|
export function ScrollControllerProvider({
|
|
|
|
|
children,
|
|
|
|
|
scrollableSelector = "",
|
|
|
|
|
}: {
|
|
|
|
|
children: ReactNode
|
|
|
|
|
scrollableSelector?: string
|
|
|
|
|
}): JSX.Element {
|
|
|
|
|
const value = useScrollControllerContextValue()
|
|
|
|
|
const value = useScrollControllerContextValue(scrollableSelector)
|
|
|
|
|
return (
|
|
|
|
|
<ScrollMonitorContext.Provider value={value}>
|
|
|
|
|
{children}
|
|
|
|
|
@@ -165,6 +176,7 @@ type UseScrollPositionSaver = {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useScrollPositionSaver(): UseScrollPositionSaver {
|
|
|
|
|
const { getScrollableElement } = useScrollController()
|
|
|
|
|
const lastElementRef = useRef<{ elem: HTMLElement | null; top: number }>({
|
|
|
|
|
elem: null,
|
|
|
|
|
top: 0,
|
|
|
|
|
@@ -187,7 +199,7 @@ function useScrollPositionSaver(): UseScrollPositionSaver {
|
|
|
|
|
const newTop = elem.getBoundingClientRect().top
|
|
|
|
|
const heightDiff = newTop - top
|
|
|
|
|
if (heightDiff) {
|
|
|
|
|
window.scrollBy({ left: 0, top: heightDiff })
|
|
|
|
|
getScrollableElement().scrollBy({ left: 0, top: heightDiff })
|
|
|
|
|
}
|
|
|
|
|
lastElementRef.current = { elem: null, top: 0 }
|
|
|
|
|
|
|
|
|
|
@@ -258,74 +270,3 @@ export function useScrollPositionBlocker(): {
|
|
|
|
|
blockElementScrollPositionUntilNextRender,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CancelScrollTop = () => void
|
|
|
|
|
|
|
|
|
|
function smoothScrollNative(top: number): CancelScrollTop {
|
|
|
|
|
window.scrollTo({ top, behavior: "smooth" })
|
|
|
|
|
return () => {
|
|
|
|
|
// Nothing to cancel, it's natively cancelled if user tries to scroll down
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function smoothScrollPolyfill(top: number): CancelScrollTop {
|
|
|
|
|
let raf: number | null = null
|
|
|
|
|
const isUpScroll = document.documentElement.scrollTop > top
|
|
|
|
|
function rafRecursion() {
|
|
|
|
|
const currentScroll = document.documentElement.scrollTop
|
|
|
|
|
if (
|
|
|
|
|
(isUpScroll && currentScroll > top) ||
|
|
|
|
|
(!isUpScroll && currentScroll < top)
|
|
|
|
|
) {
|
|
|
|
|
raf = requestAnimationFrame(rafRecursion)
|
|
|
|
|
window.scrollTo(0, Math.floor((currentScroll - top) * 0.85) + top)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
rafRecursion()
|
|
|
|
|
|
|
|
|
|
// Break the recursion. Prevents the user from "fighting" against that
|
|
|
|
|
// recursion producing a weird UX
|
|
|
|
|
return () => raf && cancelAnimationFrame(raf)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A "smart polyfill" of `window.scrollTo({ top, behavior: "smooth" })`.
|
|
|
|
|
* This currently always uses a polyfilled implementation unless
|
|
|
|
|
* `scroll-behavior: smooth` has been set in CSS, because native support
|
|
|
|
|
* detection for scroll behavior seems unreliable.
|
|
|
|
|
*
|
|
|
|
|
* This hook does not do anything by itself: it returns a start and a stop
|
|
|
|
|
* handle. You can execute either handle at any time.
|
|
|
|
|
*/
|
|
|
|
|
export function useSmoothScrollTo(): {
|
|
|
|
|
/**
|
|
|
|
|
* Start the scroll.
|
|
|
|
|
*
|
|
|
|
|
* @param top The final scroll top position.
|
|
|
|
|
*/
|
|
|
|
|
startScroll: (top: number) => void
|
|
|
|
|
/**
|
|
|
|
|
* A cancel function, because the non-native smooth scroll-top
|
|
|
|
|
* implementation must be interrupted if user scrolls down. If there's no
|
|
|
|
|
* existing animation or the scroll is using native behavior, this is a no-op.
|
|
|
|
|
*/
|
|
|
|
|
cancelScroll: CancelScrollTop
|
|
|
|
|
} {
|
|
|
|
|
const cancelRef = useRef<CancelScrollTop | null>(null)
|
|
|
|
|
// Not all have support for smooth scrolling (particularly Safari mobile iOS)
|
|
|
|
|
// TODO proper detection is currently unreliable!
|
|
|
|
|
// see https://github.com/wessberg/scroll-behavior-polyfill/issues/16
|
|
|
|
|
// For now, we only use native scroll behavior if smooth is already set,
|
|
|
|
|
// because otherwise the polyfill produces a weird UX when both CSS and JS try
|
|
|
|
|
// to scroll a page, and they cancel each other.
|
|
|
|
|
const supportsNativeSmoothScrolling =
|
|
|
|
|
getComputedStyle(document.documentElement).scrollBehavior === "smooth"
|
|
|
|
|
return {
|
|
|
|
|
startScroll: (top: number) => {
|
|
|
|
|
cancelRef.current = supportsNativeSmoothScrolling
|
|
|
|
|
? smoothScrollNative(top)
|
|
|
|
|
: smoothScrollPolyfill(top)
|
|
|
|
|
},
|
|
|
|
|
cancelScroll: () => cancelRef.current?.(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|