mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 19:32:07 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8e7ec1967 | |||
| c7f0daa4d3 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the table-of-contents feeling unstable: sidebar groups no longer expand/collapse or lose their expanded context when navigating between pages.
|
||||
@@ -15,6 +15,7 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
|
||||
return (
|
||||
<li className="page-document-item flex flex-col [.page-group-item+&]:mt-4">
|
||||
<ToggleableLinkItem
|
||||
id={page.id}
|
||||
href={page.href ?? '#'}
|
||||
pathnames={page.pathnames}
|
||||
insights={{
|
||||
|
||||
@@ -9,12 +9,13 @@ import { ToggleChevron } from '../primitives';
|
||||
import { PagesList } from './PagesList';
|
||||
import { TOCPageIcon } from './TOCPageIcon';
|
||||
import { ToCButtonItemStyles } from './styles';
|
||||
import { useTOCGroupState } from './useTOCGroupState';
|
||||
|
||||
export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boolean }) {
|
||||
const { page, isFirst } = props;
|
||||
const descendants = page.descendants ?? [];
|
||||
const hasDescendants = descendants.length > 0;
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
const [isOpen, setIsOpen] = useTOCGroupState(page.id, true);
|
||||
const { sentinelRef, isSticking } = useIsSticking();
|
||||
|
||||
const handleToggle = () => {
|
||||
@@ -22,7 +23,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen((prev) => !prev);
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import React, { useRef } from 'react';
|
||||
import type React from 'react';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { Button, Link, type LinkInsightsProps, type LinkProps, ToggleChevron } from '../primitives';
|
||||
import { useTOCGroupState } from './useTOCGroupState';
|
||||
|
||||
/**
|
||||
* Client component for a page document to toggle its children and be marked as active.
|
||||
*/
|
||||
export function ToggleableLinkItem(
|
||||
props: {
|
||||
id: string;
|
||||
href: string;
|
||||
pathnames: string[];
|
||||
children: React.ReactNode;
|
||||
@@ -17,30 +19,15 @@ export function ToggleableLinkItem(
|
||||
tag?: React.ReactNode;
|
||||
} & LinkInsightsProps
|
||||
) {
|
||||
const { href, children, descendants, pathnames, insights, icon, tag } = props;
|
||||
const { id, href, children, descendants, pathnames, insights, icon, tag } = props;
|
||||
|
||||
const currentPagePath = useCurrentPagePath();
|
||||
const isActive = pathnames.some((pathname) => pathname === currentPagePath);
|
||||
// Auto-expand to reveal the active page; the store keeps this open across navigations and
|
||||
// remembers the visitor's own toggles instead of re-deriving (and flickering) on every remount.
|
||||
const defaultIsOpen =
|
||||
isActive || pathnames.some((pathname) => currentPagePath.startsWith(`${pathname}/`));
|
||||
const [isOpen, setIsOpen] = React.useState(defaultIsOpen);
|
||||
const hasBeenToggled = useRef(false);
|
||||
|
||||
// Update the visibility of the children if one of the descendants becomes active.
|
||||
React.useEffect(() => {
|
||||
if (defaultIsOpen && !hasBeenToggled.current) {
|
||||
setIsOpen(defaultIsOpen);
|
||||
}
|
||||
}, [defaultIsOpen]);
|
||||
|
||||
const handleToggle = (newState: boolean | ((prev: boolean) => boolean)) => {
|
||||
hasBeenToggled.current = true;
|
||||
if (typeof newState === 'function') {
|
||||
setIsOpen(newState);
|
||||
} else {
|
||||
setIsOpen(newState);
|
||||
}
|
||||
};
|
||||
const [isOpen, setIsOpen] = useTOCGroupState(id, defaultIsOpen);
|
||||
|
||||
if (!descendants) {
|
||||
return (
|
||||
@@ -59,14 +46,18 @@ export function ToggleableLinkItem(
|
||||
}
|
||||
|
||||
return (
|
||||
<DescendantsRenderer descendants={descendants} isOpen={isOpen} setIsOpen={handleToggle}>
|
||||
<DescendantsRenderer
|
||||
descendants={descendants}
|
||||
isOpen={isOpen}
|
||||
onToggle={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{({ descendants, toggler }) => (
|
||||
<>
|
||||
<LinkItem
|
||||
href={href}
|
||||
insights={insights}
|
||||
isActive={isActive}
|
||||
onActiveClick={() => handleToggle(!isOpen)}
|
||||
onActiveClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{icon}
|
||||
{tag ? (
|
||||
@@ -126,24 +117,16 @@ function LinkItem(
|
||||
function DescendantsRenderer(props: {
|
||||
descendants: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onToggle: () => void;
|
||||
children: (renderProps: {
|
||||
descendants: React.ReactNode;
|
||||
toggler: React.ReactNode;
|
||||
}) => React.ReactNode;
|
||||
}) {
|
||||
const { descendants, isOpen, setIsOpen } = props;
|
||||
const { descendants, isOpen, onToggle } = props;
|
||||
|
||||
return props.children({
|
||||
toggler: (
|
||||
<Toggler
|
||||
isLinkActive={isOpen}
|
||||
isOpen={isOpen}
|
||||
onToggle={() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
toggler: <Toggler isLinkActive={isOpen} isOpen={isOpen} onToggle={onToggle} />,
|
||||
descendants: <Descendants isVisible={isOpen}>{descendants}</Descendants>,
|
||||
});
|
||||
}
|
||||
@@ -193,8 +176,11 @@ function Descendants(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { isVisible, children } = props;
|
||||
// `initial={false}` renders an already-open group without replaying the expand animation on
|
||||
// mount: the layout remounts on navigation, and animating every time looked like the sidebar
|
||||
// flickering. Visitor-initiated toggles still animate since they happen after mount.
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{isVisible ? (
|
||||
<motion.div
|
||||
initial={hide}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
type TOCGroupState = {
|
||||
isOpen: boolean;
|
||||
/** Whether the visitor toggled the group themselves, as opposed to it following the active page. */
|
||||
userToggled: boolean;
|
||||
};
|
||||
|
||||
// The table of contents lives in the site layout, whose client subtree is remounted on
|
||||
// client-side navigation (see useClearRouterCache and https://github.com/vercel/next.js/issues/67542).
|
||||
// Keeping each group's open/closed state in component-local `useState`/`useRef` therefore resets it on
|
||||
// every navigation, making the sidebar expand/collapse or lose its expanded context as visitors move
|
||||
// between pages. We keep the state in a module-level store so it survives those remounts.
|
||||
const useStore = zustand.create<{
|
||||
groups: Record<string, TOCGroupState>;
|
||||
setOpen: (id: string, isOpen: boolean) => void;
|
||||
}>((set) => ({
|
||||
groups: {},
|
||||
setOpen: (id, isOpen) =>
|
||||
set((state) => ({
|
||||
groups: { ...state.groups, [id]: { isOpen, userToggled: true } },
|
||||
})),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Persisted open/collapsed state for a collapsible table-of-contents group.
|
||||
*
|
||||
* Until the visitor toggles the group, it follows `defaultOpen` (e.g. the group containing the
|
||||
* active page). Once toggled, their choice is remembered across navigations for the session, so
|
||||
* navigating between pages no longer resets the sidebar.
|
||||
*/
|
||||
export function useTOCGroupState(
|
||||
id: string,
|
||||
defaultOpen: boolean
|
||||
): [boolean, (isOpen: boolean) => void] {
|
||||
const stored = useStore((state) => state.groups[id]);
|
||||
const setOpen = useStore((state) => state.setOpen);
|
||||
|
||||
const isOpen = stored?.userToggled ? stored.isOpen : defaultOpen;
|
||||
|
||||
const setIsOpen = useCallback((next: boolean) => setOpen(id, next), [id, setOpen]);
|
||||
|
||||
return [isOpen, setIsOpen];
|
||||
}
|
||||
Reference in New Issue
Block a user