feat(v2): add back to top button (#4912)

* feat(v2): add back to top button

* Test on mobiles

* Use clean-btn class

* Fix case

* clearer useScrollPosition() hook

* fix useScrollPosition typing + dangerous 0 fallback value + refactor a bit backToTop button

* useless fallback

* Handle both browsers with/without native smooth scrollBehavior support

* fix SupportsNativeSmoothScrolling using document on SSR

* revert to smoothScrollTopPolyfill usage

Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
Alexey Pyltsyn 2021-07-28 23:10:17 +03:00 committed by GitHub
parent bb0c9eed0d
commit c935fe2a37
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 187 additions and 11 deletions

View file

@ -0,0 +1,122 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useRef, useState} from 'react';
import clsx from 'clsx';
import useScrollPosition from '@theme/hooks/useScrollPosition';
import styles from './styles.module.css';
const threshold = 300;
// 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
const SupportsNativeSmoothScrolling = false;
// const SupportsNativeSmoothScrolling = ExecutionEnvironment.canUseDOM && 'scrollBehavior' in document.documentElement.style;
type CancelScrollTop = () => void;
function smoothScrollTopNative(): CancelScrollTop {
window.scrollTo({top: 0, behavior: 'smooth'});
return () => {
// Nothing to cancel, it's natively cancelled if user tries to scroll down
};
}
function smoothScrollTopPolyfill(): CancelScrollTop {
let raf: number | null = null;
function rafRecursion() {
const currentScroll = document.documentElement.scrollTop;
if (currentScroll > 0) {
raf = requestAnimationFrame(rafRecursion);
window.scrollTo(0, Math.floor(currentScroll * 0.85));
}
}
rafRecursion();
return () => {
// Break the recursion
// Prevents the user from "fighting" against that recursion producing a weird UX
raf && cancelAnimationFrame(raf);
};
}
type UseSmoothScrollTopReturn = {
// We use a cancel function because the non-native smooth scroll-top implementation must be interrupted if user scroll down
smoothScrollTop: () => void;
cancelScrollToTop: CancelScrollTop;
};
function useSmoothScrollToTop(): UseSmoothScrollTopReturn {
const lastCancelRef = useRef<CancelScrollTop | null>(null);
function smoothScrollTop(): void {
lastCancelRef.current = SupportsNativeSmoothScrolling
? smoothScrollTopNative()
: smoothScrollTopPolyfill();
}
return {
smoothScrollTop,
cancelScrollToTop: () => lastCancelRef.current?.(),
};
}
function BackToTopButton(): JSX.Element {
const {smoothScrollTop, cancelScrollToTop} = useSmoothScrollToTop();
const [show, setShow] = useState(false);
useScrollPosition(({scrollY: scrollTop}, lastPosition) => {
// No lastPosition means component is just being mounted.
// Not really a scroll event from the user, so we ignore it
if (!lastPosition) {
return;
}
const lastScrollTop = lastPosition.scrollY;
const isScrollingUp = scrollTop < lastScrollTop;
if (!isScrollingUp) {
cancelScrollToTop();
}
if (scrollTop < threshold) {
setShow(false);
return;
}
if (isScrollingUp) {
const documentHeight = document.documentElement.scrollHeight;
const windowHeight = window.innerHeight;
if (scrollTop + windowHeight < documentHeight) {
setShow(true);
}
} else {
setShow(false);
}
}, []);
return (
<button
className={clsx('clean-btn', styles.backToTopButton, {
[styles.backToTopButtonShow]: show,
})}
type="button"
title="Scroll to top"
onClick={() => smoothScrollTop()}>
<svg viewBox="0 0 24 24" width="28">
<path
d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"
fill="currentColor"
/>
</svg>
</button>
);
}
export default BackToTopButton;

View file

@ -0,0 +1,39 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
:root {
--docusaurus-btt-background: var(--ifm-color-primary);
--docusaurus-btt-color: #fff;
}
.backToTopButton {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
right: 1.3rem;
bottom: 1.3rem;
border-radius: 50%;
background: var(--docusaurus-btt-background);
color: var(--docusaurus-btt-color);
width: 3rem;
height: 3rem;
z-index: var(--ifm-z-index-fixed);
box-shadow: 0 0.125rem 0.3125rem 0 rgba(0, 0, 0, 0.3);
transition: all var(--ifm-transition-fast) ease-in-out;
opacity: 0;
transform: scale(0);
}
.backToTopButton:hover {
opacity: 0.8;
}
.backToTopButtonShow {
opacity: 1;
transform: scale(1);
}

View file

@ -18,6 +18,7 @@ import NotFound from '@theme/NotFound';
import type {DocumentRoute} from '@theme/DocItem';
import type {Props} from '@theme/DocPage';
import IconArrow from '@theme/IconArrow';
import BackToTopButton from '@theme/BackToTopButton';
import {matchPath} from '@docusaurus/router';
import {translate} from '@docusaurus/Translate';
@ -64,6 +65,8 @@ function DocPageContent({
tag: docVersionSearchTag(pluginId, version),
}}>
<div className={styles.docPage}>
<BackToTopButton />
{sidebar && (
<aside
className={clsx(styles.docSidebarContainer, {

View file

@ -23,7 +23,9 @@ const useHideableNavbar = (hideOnScroll: boolean): useHideableNavbarReturns => {
}, []);
useScrollPosition(
({scrollY: scrollTop}, {scrollY: lastScrollTop}) => {
(currentPosition, lastPosition) => {
const scrollTop = currentPosition.scrollY;
const lastScrollTop = lastPosition?.scrollY;
if (!hideOnScroll) {
return;
}

View file

@ -9,25 +9,32 @@ import {useEffect, useRef} from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import type {ScrollPosition} from '@theme/hooks/useScrollPosition';
const getScrollPosition = (): ScrollPosition => ({
scrollX: ExecutionEnvironment.canUseDOM ? window.pageXOffset : 0,
scrollY: ExecutionEnvironment.canUseDOM ? window.pageYOffset : 0,
});
const getScrollPosition = (): ScrollPosition | null => {
return ExecutionEnvironment.canUseDOM
? {
scrollX: window.pageXOffset,
scrollY: window.pageYOffset,
}
: null;
};
const useScrollPosition = (
effect?: (position: ScrollPosition, lastPosition: ScrollPosition) => void,
effect: (
position: ScrollPosition,
lastPosition: ScrollPosition | null,
) => void,
deps = [],
): void => {
const scrollPosition = useRef(getScrollPosition());
const lastPositionRef = useRef<ScrollPosition | null>(getScrollPosition());
const handleScroll = () => {
const currentScrollPosition = getScrollPosition();
const currentPosition = getScrollPosition()!;
if (effect) {
effect(currentScrollPosition, scrollPosition.current);
effect(currentPosition, lastPositionRef.current);
}
scrollPosition.current = currentScrollPosition;
lastPositionRef.current = currentPosition;
};
useEffect(() => {

View file

@ -189,7 +189,10 @@ declare module '@theme/hooks/useScrollPosition' {
export type ScrollPosition = {scrollX: number; scrollY: number};
const useScrollPosition: (
effect?: (position: ScrollPosition, lastPosition: ScrollPosition) => void,
effect: (
position: ScrollPosition,
lastPosition: ScrollPosition | null,
) => void,
deps?: unknown[],
) => void;
export default useScrollPosition;