Add navigation loader and rename context provider (#3642)

Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
This commit is contained in:
conico974
2025-09-23 15:00:27 +02:00
committed by GitHub
parent 2fc21272ff
commit b42b0b3910
6 changed files with 112 additions and 18 deletions
@@ -5,7 +5,7 @@ import type React from 'react';
import { TranslateContext } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations';
import { TooltipProvider } from '@radix-ui/react-tooltip';
import { HashProvider } from '../hooks';
import { NavigationStatusProvider } from '../hooks';
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
/**
@@ -20,9 +20,9 @@ export function RootLayoutClientContexts(props: {
return (
<TranslateContext.Provider value={language}>
<TooltipProvider delayDuration={200}>
<HashProvider>
<NavigationStatusProvider>
<LoadingStateProvider>{children}</LoadingStateProvider>
</HashProvider>
</NavigationStatusProvider>
</TooltipProvider>
</TranslateContext.Provider>
);
@@ -25,6 +25,7 @@ import { InsightsProvider, VisitorSessionProvider } from '../Insights';
import { SearchContainer } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { CurrentContentProvider } from '../hooks';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
type SpaceLayoutProps = {
@@ -125,6 +126,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
<SpaceLayoutServerContext {...props}>
<Announcement context={context} />
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
<NavigationLoader />
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
<AIChat trademark={customization.trademark.enabled} />
) : null}
@@ -1,17 +1,26 @@
'use client';
import { usePathname } from 'next/navigation';
import React from 'react';
export const HashContext = React.createContext<{
export const NavigationStatusContext = React.createContext<{
hash: string | null;
/**
* Updates the hash value from the URL provided here.
* It will then be used by the `useHash` hook.
* Updates the navigation state from the URL provided here.
* URL can be relative or absolute.
*/
updateHashFromUrl: (href: string) => void;
onNavigationClick: (href: string) => void;
/**
* Indicates if a link has been clicked recently.
* Becomes true after a click and resets to false when pathname changes.
* It is debounced to avoid flickering on fast navigations.
* Debounce time is 400ms (= doherty threshold for responsiveness).
*/
isNavigating: boolean;
}>({
hash: null,
updateHashFromUrl: () => {},
onNavigationClick: () => {},
isNavigating: false,
});
function getHash(): string | null {
@@ -21,20 +30,62 @@ function getHash(): string | null {
return window.location.hash.slice(1);
}
export const HashProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [hash, setHash] = React.useState<string | null>(getHash);
const updateHashFromUrl = React.useCallback((href: string) => {
const [isNavigating, setIsNavigating] = React.useState(false);
const timeoutRef = React.useRef<number | null>(null);
const pathname = usePathname();
const pathnameRef = React.useRef(pathname);
// Reset isNavigating when pathname changes
React.useEffect(() => {
if (pathnameRef.current !== pathname) {
setIsNavigating(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
pathnameRef.current = pathname;
}
}, [pathname]);
// Cleanup timeout on unmount
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const onNavigationClick = React.useCallback((href: string) => {
const url = new URL(
href,
typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
);
setHash(url.hash.slice(1));
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (pathnameRef.current !== url.pathname) {
timeoutRef.current = window.setTimeout(() => {
setIsNavigating(true);
timeoutRef.current = null;
return;
}, 400); // 400ms timeout - doherty threshold for responsiveness
}
}, []);
const memoizedValue = React.useMemo(
() => ({ hash, updateHashFromUrl }),
[hash, updateHashFromUrl]
() => ({ hash, onNavigationClick, isNavigating }),
[hash, onNavigationClick, isNavigating]
);
return (
<NavigationStatusContext.Provider value={memoizedValue}>
{children}
</NavigationStatusContext.Provider>
);
return <HashContext.Provider value={memoizedValue}>{children}</HashContext.Provider>;
};
/**
@@ -45,8 +96,16 @@ export const HashProvider: React.FC<React.PropsWithChildren<{}>> = ({ children }
* Since we have a single Link component that handles all links, we can use a context to share the hash.
*/
export function useHash() {
// const params = useParams();
const { hash } = React.useContext(HashContext);
const { hash } = React.useContext(NavigationStatusContext);
return hash;
}
/**
* Hook to get the current navigation state.
* @returns True if a navigation has been triggered recently. False otherwise, it also resets to false when the navigation is complete.
*/
export function useIsNavigating() {
const { isNavigating: hasBeenClicked } = React.useContext(NavigationStatusContext);
return hasBeenClicked;
}
@@ -6,7 +6,7 @@ import React from 'react';
import { tcls } from '@/lib/tailwind';
import { SiteExternalLinksTarget } from '@gitbook/api';
import { type TrackEventInput, useTrackEvent } from '../Insights';
import { HashContext } from '../hooks';
import { NavigationStatusContext } from '../hooks';
import { isExternalLink } from '../utils/link';
import { type DesignTokenName, useClassnames } from './StyleProvider';
@@ -72,7 +72,7 @@ export const Link = React.forwardRef(function Link(
) {
const { href, prefetch, children, insights, classNames, className, ...domProps } = props;
const { externalLinksTarget } = React.useContext(LinkSettingsContext);
const { updateHashFromUrl } = React.useContext(HashContext);
const { onNavigationClick } = React.useContext(NavigationStatusContext);
const trackEvent = useTrackEvent();
const forwardedClassNames = useClassnames(classNames || []);
const isExternal = isExternalLink(href);
@@ -81,7 +81,7 @@ export const Link = React.forwardRef(function Link(
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
if (!isExternal) {
updateHashFromUrl(href);
onNavigationClick(href);
}
if (insights) {
@@ -0,0 +1,18 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { useIsNavigating } from '../hooks';
export const NavigationLoader = () => {
const isNavigating = useIsNavigating();
return (
<div
className={tcls(
'pointer-events-none fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden',
isNavigating ? 'block' : 'hidden animate-fade-out-slow'
)}
>
<div className={tcls('h-full w-full origin-left animate-crawl bg-primary-solid')} />
</div>
);
};
+15
View File
@@ -329,6 +329,7 @@ const config: Config = {
exitToRight: 'exitToRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
heightIn: 'heightIn 200ms ease both',
crawl: 'crawl 2s ease-in-out infinite',
},
keyframes: {
bounceSmall: {
@@ -498,6 +499,20 @@ const config: Config = {
from: { height: '0' },
to: { height: 'max-content' },
},
crawl: {
'0%': {
scale: '0 1',
translate: '0 0',
},
'40%': {
scale: '1 1',
translate: '100% 0',
},
'100%': {
scale: '0 1',
translate: '100% 0',
},
},
},
boxShadow: {
thinbottom: '0px 1px 0px rgba(0, 0, 0, 0.05)',