mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-07 22:27:11 +02:00
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:
parent
bb0c9eed0d
commit
c935fe2a37
6 changed files with 187 additions and 11 deletions
|
@ -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;
|
|
@ -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);
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ import NotFound from '@theme/NotFound';
|
||||||
import type {DocumentRoute} from '@theme/DocItem';
|
import type {DocumentRoute} from '@theme/DocItem';
|
||||||
import type {Props} from '@theme/DocPage';
|
import type {Props} from '@theme/DocPage';
|
||||||
import IconArrow from '@theme/IconArrow';
|
import IconArrow from '@theme/IconArrow';
|
||||||
|
import BackToTopButton from '@theme/BackToTopButton';
|
||||||
import {matchPath} from '@docusaurus/router';
|
import {matchPath} from '@docusaurus/router';
|
||||||
import {translate} from '@docusaurus/Translate';
|
import {translate} from '@docusaurus/Translate';
|
||||||
|
|
||||||
|
@ -64,6 +65,8 @@ function DocPageContent({
|
||||||
tag: docVersionSearchTag(pluginId, version),
|
tag: docVersionSearchTag(pluginId, version),
|
||||||
}}>
|
}}>
|
||||||
<div className={styles.docPage}>
|
<div className={styles.docPage}>
|
||||||
|
<BackToTopButton />
|
||||||
|
|
||||||
{sidebar && (
|
{sidebar && (
|
||||||
<aside
|
<aside
|
||||||
className={clsx(styles.docSidebarContainer, {
|
className={clsx(styles.docSidebarContainer, {
|
||||||
|
|
|
@ -23,7 +23,9 @@ const useHideableNavbar = (hideOnScroll: boolean): useHideableNavbarReturns => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useScrollPosition(
|
useScrollPosition(
|
||||||
({scrollY: scrollTop}, {scrollY: lastScrollTop}) => {
|
(currentPosition, lastPosition) => {
|
||||||
|
const scrollTop = currentPosition.scrollY;
|
||||||
|
const lastScrollTop = lastPosition?.scrollY;
|
||||||
if (!hideOnScroll) {
|
if (!hideOnScroll) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,25 +9,32 @@ import {useEffect, useRef} from 'react';
|
||||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
||||||
import type {ScrollPosition} from '@theme/hooks/useScrollPosition';
|
import type {ScrollPosition} from '@theme/hooks/useScrollPosition';
|
||||||
|
|
||||||
const getScrollPosition = (): ScrollPosition => ({
|
const getScrollPosition = (): ScrollPosition | null => {
|
||||||
scrollX: ExecutionEnvironment.canUseDOM ? window.pageXOffset : 0,
|
return ExecutionEnvironment.canUseDOM
|
||||||
scrollY: ExecutionEnvironment.canUseDOM ? window.pageYOffset : 0,
|
? {
|
||||||
});
|
scrollX: window.pageXOffset,
|
||||||
|
scrollY: window.pageYOffset,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
const useScrollPosition = (
|
const useScrollPosition = (
|
||||||
effect?: (position: ScrollPosition, lastPosition: ScrollPosition) => void,
|
effect: (
|
||||||
|
position: ScrollPosition,
|
||||||
|
lastPosition: ScrollPosition | null,
|
||||||
|
) => void,
|
||||||
deps = [],
|
deps = [],
|
||||||
): void => {
|
): void => {
|
||||||
const scrollPosition = useRef(getScrollPosition());
|
const lastPositionRef = useRef<ScrollPosition | null>(getScrollPosition());
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const currentScrollPosition = getScrollPosition();
|
const currentPosition = getScrollPosition()!;
|
||||||
|
|
||||||
if (effect) {
|
if (effect) {
|
||||||
effect(currentScrollPosition, scrollPosition.current);
|
effect(currentPosition, lastPositionRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollPosition.current = currentScrollPosition;
|
lastPositionRef.current = currentPosition;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -189,7 +189,10 @@ declare module '@theme/hooks/useScrollPosition' {
|
||||||
export type ScrollPosition = {scrollX: number; scrollY: number};
|
export type ScrollPosition = {scrollX: number; scrollY: number};
|
||||||
|
|
||||||
const useScrollPosition: (
|
const useScrollPosition: (
|
||||||
effect?: (position: ScrollPosition, lastPosition: ScrollPosition) => void,
|
effect: (
|
||||||
|
position: ScrollPosition,
|
||||||
|
lastPosition: ScrollPosition | null,
|
||||||
|
) => void,
|
||||||
deps?: unknown[],
|
deps?: unknown[],
|
||||||
) => void;
|
) => void;
|
||||||
export default useScrollPosition;
|
export default useScrollPosition;
|
||||||
|
|
Loading…
Add table
Reference in a new issue