mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-06 21:57:14 +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 {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, {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue