feat: maintain page position for clicked grouped tabs (#5618)

Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
Shrugsy 2021-10-14 04:08:00 +11:00 committed by GitHub
parent 4a4f8497b3
commit 7868df13f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 260 additions and 118 deletions

View file

@ -9,10 +9,9 @@ import React, {useRef, useState} from 'react';
import clsx from 'clsx';
import {useLocation} from '@docusaurus/router';
import {translate} from '@docusaurus/Translate';
import useScrollPosition from '@theme/hooks/useScrollPosition';
import styles from './styles.module.css';
import {ThemeClassNames} from '@docusaurus/theme-common';
import {ThemeClassNames, useScrollPosition} from '@docusaurus/theme-common';
const threshold = 300;

View file

@ -13,9 +13,9 @@ import {
MobileSecondaryMenuFiller,
MobileSecondaryMenuComponent,
ThemeClassNames,
useScrollPosition,
} from '@docusaurus/theme-common';
import useWindowSize from '@theme/hooks/useWindowSize';
import useScrollPosition from '@theme/hooks/useScrollPosition';
import Logo from '@theme/Logo';
import IconArrow from '@theme/IconArrow';
import {translate} from '@docusaurus/Translate';

View file

@ -12,6 +12,7 @@ import {
AnnouncementBarProvider,
DocsPreferredVersionContextProvider,
MobileSecondaryMenuProvider,
ScrollControllerProvider,
} from '@docusaurus/theme-common';
import type {Props} from '@theme/LayoutProviders';
@ -20,11 +21,13 @@ export default function LayoutProviders({children}: Props): JSX.Element {
<ThemeProvider>
<AnnouncementBarProvider>
<UserPreferencesProvider>
<ScrollControllerProvider>
<DocsPreferredVersionContextProvider>
<MobileSecondaryMenuProvider>
{children}
</MobileSecondaryMenuProvider>
</DocsPreferredVersionContextProvider>
</ScrollControllerProvider>
</UserPreferencesProvider>
</AnnouncementBarProvider>
</ThemeProvider>

View file

@ -8,6 +8,7 @@
import React, {useState, cloneElement, Children, ReactElement} from 'react';
import useIsBrowser from '@docusaurus/useIsBrowser';
import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext';
import {useScrollPositionBlocker} from '@docusaurus/theme-common';
import type {Props} from '@theme/Tabs';
import type {Props as TabItemProps} from '@theme/TabItem';
@ -15,13 +16,6 @@ import clsx from 'clsx';
import styles from './styles.module.css';
function isInViewport(element: HTMLElement): boolean {
const {top, left, bottom, right} = element.getBoundingClientRect();
const {innerHeight, innerWidth} = window;
return top >= 0 && right <= innerWidth && bottom <= innerHeight && left >= 0;
}
function TabsComponent(props: Props): JSX.Element {
const {
lazy,
@ -50,6 +44,8 @@ function TabsComponent(props: Props): JSX.Element {
const {tabGroupChoices, setTabGroupChoices} = useUserPreferencesContext();
const [selectedValue, setSelectedValue] = useState(defaultValue);
const tabRefs: (HTMLLIElement | null)[] = [];
const {blockElementScrollPositionUntilNextRender} =
useScrollPositionBlocker();
if (groupId != null) {
const relevantTabGroupChoice = tabGroupChoices[groupId];
@ -65,31 +61,17 @@ function TabsComponent(props: Props): JSX.Element {
const handleTabChange = (
event: React.FocusEvent<HTMLLIElement> | React.MouseEvent<HTMLLIElement>,
) => {
const selectedTab = event.currentTarget;
const selectedTabIndex = tabRefs.indexOf(selectedTab);
const selectedTabValue = values[selectedTabIndex].value;
const newTab = event.currentTarget;
const newTabIndex = tabRefs.indexOf(newTab);
const newTabValue = values[newTabIndex].value;
setSelectedValue(selectedTabValue);
if (newTabValue !== selectedValue) {
blockElementScrollPositionUntilNextRender(newTab);
setSelectedValue(newTabValue);
if (groupId != null) {
setTabGroupChoices(groupId, selectedTabValue);
setTimeout(() => {
if (isInViewport(selectedTab)) {
return;
setTabGroupChoices(groupId, newTabValue);
}
selectedTab.scrollIntoView({
block: 'center',
behavior: 'smooth',
});
selectedTab.classList.add(styles.tabItemActive);
setTimeout(
() => selectedTab.classList.remove(styles.tabItemActive),
2000,
);
}, 150);
}
};

View file

@ -8,16 +8,3 @@
.tabItem {
margin-top: 0 !important;
}
.tabItemActive {
animation: blink 0.5s ease-in-out 5;
}
@keyframes blink {
0% {
background-color: var(--ifm-hover-overlay);
}
100% {
background-color: rgba(0, 0, 0, 0);
}
}

View file

@ -7,8 +7,7 @@
import {useState, useCallback, useEffect, useRef} from 'react';
import {useLocation} from '@docusaurus/router';
import useScrollPosition from '@theme/hooks/useScrollPosition';
import {useLocationChange} from '@docusaurus/theme-common';
import {useLocationChange, useScrollPosition} from '@docusaurus/theme-common';
import type {useHideableNavbarReturns} from '@theme/hooks/useHideableNavbar';
const useHideableNavbar = (hideOnScroll: boolean): useHideableNavbarReturns => {

View file

@ -1,52 +0,0 @@
/**
* 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 {useEffect, useRef} from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import type {ScrollPosition} from '@theme/hooks/useScrollPosition';
const getScrollPosition = (): ScrollPosition | null => {
return ExecutionEnvironment.canUseDOM
? {
scrollX: window.pageXOffset,
scrollY: window.pageYOffset,
}
: null;
};
const useScrollPosition = (
effect: (
position: ScrollPosition,
lastPosition: ScrollPosition | null,
) => void,
deps = [],
): void => {
const lastPositionRef = useRef<ScrollPosition | null>(getScrollPosition());
const handleScroll = () => {
const currentPosition = getScrollPosition()!;
if (effect) {
effect(currentPosition, lastPositionRef.current);
}
lastPositionRef.current = currentPosition;
};
useEffect(() => {
const opts: AddEventListenerOptions & EventListenerOptions = {
passive: true,
};
handleScroll();
window.addEventListener('scroll', handleScroll, opts);
return () => window.removeEventListener('scroll', handleScroll, opts);
}, deps);
};
export default useScrollPosition;

View file

@ -219,19 +219,6 @@ declare module '@theme/hooks/usePrismTheme' {
export default usePrismTheme;
}
declare module '@theme/hooks/useScrollPosition' {
export type ScrollPosition = {scrollX: number; scrollY: number};
const useScrollPosition: (
effect: (
position: ScrollPosition,
lastPosition: ScrollPosition | null,
) => void,
deps?: unknown[],
) => void;
export default useScrollPosition;
}
declare module '@theme/hooks/useTabGroupChoice' {
export type useTabGroupChoiceReturns = {
readonly tabGroupChoices: {readonly [groupId: string]: string};

View file

@ -79,3 +79,10 @@ export {default as useTOCHighlight} from './utils/useTOCHighlight';
export type {TOCHighlightConfig} from './utils/useTOCHighlight';
export {useTOCFilter} from './utils/tocUtils';
export {
ScrollControllerProvider,
useScrollController,
useScrollPosition,
useScrollPositionBlocker,
} from './utils/scrollUtils';

View file

@ -0,0 +1,230 @@
/**
* 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, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
/**
* We need a way to update the scroll position while ignoring scroll events
* without affecting Navbar/BackToTop visibility
*
* This API permits to temporarily disable/ignore scroll events
* Motivated by https://github.com/facebook/docusaurus/pull/5618
*/
type ScrollController = {
/**
* A boolean ref tracking whether scroll events are enabled
*/
scrollEventsEnabledRef: React.MutableRefObject<boolean>;
/**
* Enables scroll events in `useScrollPosition`
*/
enableScrollEvents: () => void;
/**
* Disables scroll events in `useScrollPosition`
*/
disableScrollEvents: () => void;
};
function useScrollControllerContextValue(): ScrollController {
const scrollEventsEnabledRef = useRef(true);
return useMemo(
() => ({
scrollEventsEnabledRef,
enableScrollEvents: () => {
scrollEventsEnabledRef.current = true;
},
disableScrollEvents: () => {
scrollEventsEnabledRef.current = false;
},
}),
[],
);
}
const ScrollMonitorContext = createContext<ScrollController | undefined>(
undefined,
);
export function ScrollControllerProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<ScrollMonitorContext.Provider value={useScrollControllerContextValue()}>
{children}
</ScrollMonitorContext.Provider>
);
}
export function useScrollController(): ScrollController {
const context = useContext(ScrollMonitorContext);
if (context == null) {
throw new Error(
'"useScrollController" is used but no context provider was found in the React tree.',
);
}
return context;
}
const getScrollPosition = (): ScrollPosition | null => {
return ExecutionEnvironment.canUseDOM
? {
scrollX: window.pageXOffset,
scrollY: window.pageYOffset,
}
: null;
};
type ScrollPosition = {scrollX: number; scrollY: number};
export function useScrollPosition(
effect: (
position: ScrollPosition,
lastPosition: ScrollPosition | null,
) => void,
deps: unknown[] = [],
): void {
const {scrollEventsEnabledRef} = useScrollController();
const lastPositionRef = useRef<ScrollPosition | null>(getScrollPosition());
const handleScroll = () => {
if (!scrollEventsEnabledRef.current) {
return;
}
const currentPosition = getScrollPosition()!;
if (effect) {
effect(currentPosition, lastPositionRef.current);
}
lastPositionRef.current = currentPosition;
};
useEffect(() => {
const opts: AddEventListenerOptions & EventListenerOptions = {
passive: true,
};
handleScroll();
window.addEventListener('scroll', handleScroll, opts);
return () => window.removeEventListener('scroll', handleScroll, opts);
}, deps);
}
type UseScrollPositionSaver = {
/**
* Measure the top of an element, and store the details
*/
save: (elem: HTMLElement) => void;
/**
* Restore the page position to keep the stored element's position from
* the top of the viewport, and remove the stored details
*/
restore: () => {restored: boolean};
};
function useScrollPositionSaver(): UseScrollPositionSaver {
const lastElementRef = useRef<{elem: HTMLElement | null; top: number}>({
elem: null,
top: 0,
});
const save = useCallback((elem: HTMLElement) => {
lastElementRef.current = {
elem,
top: elem.getBoundingClientRect().top,
};
}, []);
const restore = useCallback(() => {
const {
current: {elem, top},
} = lastElementRef;
if (!elem) {
return {restored: false};
}
const newTop = elem.getBoundingClientRect().top;
const heightDiff = newTop - top;
if (heightDiff) {
window.scrollBy({left: 0, top: heightDiff});
}
lastElementRef.current = {elem: null, top: 0};
return {restored: heightDiff !== 0};
}, []);
return useMemo(() => ({save, restore}), []);
}
type UseScrollPositionBlockerReturn = {
blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void;
};
/**
* This hook permits to "block" the scroll position of a dom element
* The idea is that we should be able to update DOM content above this element
* but the screen position of this element should not change
*
* Feature motivated by the Tabs groups:
* clicking on a tab may affect tabs of the same group upper in the tree
* Yet to avoid a bad UX, the clicked tab must remain under the user mouse!
* See GIF here: https://github.com/facebook/docusaurus/pull/5618
*/
export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn {
const scrollController = useScrollController();
const scrollPositionSaver = useScrollPositionSaver();
const nextLayoutEffectCallbackRef = useRef<(() => void) | undefined>(
undefined,
);
const blockElementScrollPositionUntilNextRender = useCallback(
(el: HTMLElement) => {
scrollPositionSaver.save(el);
scrollController.disableScrollEvents();
nextLayoutEffectCallbackRef.current = () => {
const {restored} = scrollPositionSaver.restore();
nextLayoutEffectCallbackRef.current = undefined;
// Restoring the former scroll position will trigger a scroll event
// We need to wait for next scroll event to happen
// before enabling again the scrollController events
if (restored) {
const handleScrollRestoreEvent = () => {
scrollController.enableScrollEvents();
window.removeEventListener('scroll', handleScrollRestoreEvent);
};
window.addEventListener('scroll', handleScrollRestoreEvent);
} else {
scrollController.enableScrollEvents();
}
};
},
[scrollController],
);
useLayoutEffect(() => {
nextLayoutEffectCallbackRef.current?.();
});
return {
blockElementScrollPositionUntilNextRender,
};
}