diff --git a/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx b/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx index f997225a2..8fd4d3570 100644 --- a/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx +++ b/packages/gitbook/src/components/DocumentView/Expandable/Details.tsx @@ -17,29 +17,34 @@ export function Details(props: { }) { const { children, id, className } = props; - const detailsRef = React.useRef(null); + const ref = React.useRef(null); const [openFromHash, setOpenFromHash] = React.useState(false); const hash = useHash(); + /** * Open the details element if the url hash refers to the id of the details element * or the id of some element contained within the details element. */ React.useEffect(() => { - if (!hash || !detailsRef.current) { + if (!hash || !ref.current) { return; } + if (hash === id) { setOpenFromHash(true); + return; } + const activeElement = document.getElementById(hash); - setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement))); + const isOpen = Boolean(activeElement && ref.current.contains(activeElement)); + setOpenFromHash(isOpen); }, [hash, id]); return (
{ const input = { id, tabs }; @@ -79,28 +87,33 @@ export function DynamicTabs(props: { ); }, [id, tabs, tabsState]); + // Track if the tab has been touched by the user. + const touchedRef = useRef(false); + // To avoid issue with hydration, we only use the state from localStorage - // once the component has been mounted. + // once the component has been initialized (=mounted). // Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time // and get stuck into an inconsistent state. - const mounted = useIsMounted(); - const active = mounted ? activeState : tabs[0]; + const active = initialized ? activeState : tabs[0]; // When clicking to select a tab, we: // - update the URL hash // - mark this specific ID as selected // - store the ID to auto-select other tabs with the same title const selectTab = useCallback( - (tabId: string) => { + (tabId: string, manual = true) => { const tab = tabs.find((tab) => tab.id === tabId); if (!tab) { return; } - const href = `#${tab.id}`; - if (window.location.hash !== href) { - router.replace(href, { scroll: false }); + if (manual) { + touchedRef.current = true; + const href = `#${tab.id}`; + if (window.location.hash !== href) { + router.replace(href, { scroll: false }); + } } setTabsState((prev) => { @@ -125,12 +138,14 @@ export function DynamicTabs(props: { ); // When the hash changes, we try to select the tab containing the targetted element. - React.useEffect(() => { + React.useLayoutEffect(() => { + setInitialized(true); + if (hash) { // First check if the hash matches a tab ID. const hashIsTab = tabs.some((tab) => tab.id === hash); if (hashIsTab) { - selectTab(hash); + selectTab(hash, false); return; } @@ -145,10 +160,39 @@ export function DynamicTabs(props: { return; } - selectTab(tabPanel.id); + selectTab(tabPanel.id, false); } }, [selectTab, tabs, hash]); + // Scroll to active element in the tab. + React.useLayoutEffect(() => { + // If there is no hash or active tab, nothing to scroll. + if (!hash || !active) { + return; + } + + // If the tab is touched, we don't want to scroll. + if (touchedRef.current) { + return; + } + + // If the hash matches a tab, then the scroll is already done. + const hashIsTab = tabs.some((tab) => tab.id === hash); + if (hashIsTab) { + return; + } + + const activeElement = document.getElementById(hash); + if (!activeElement) { + return; + } + + activeElement.scrollIntoView({ + block: 'start', + behavior: 'instant', + }); + }, [active, tabs, hash]); + return (
- {tab.body} +
); });