mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 18:43:29 +00:00
Fix scrolling hash into tabs / expandable (#3757)
This commit is contained in:
@@ -17,29 +17,34 @@ export function Details(props: {
|
||||
}) {
|
||||
const { children, id, className } = props;
|
||||
|
||||
const detailsRef = React.useRef<HTMLDetailsElement>(null);
|
||||
const ref = React.useRef<HTMLDetailsElement>(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 (
|
||||
<details
|
||||
ref={detailsRef}
|
||||
ref={ref}
|
||||
id={id}
|
||||
open={props.open || openFromHash}
|
||||
className={tcls(
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo, useCallback, useMemo, type ComponentPropsWithRef } from 'react';
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithRef,
|
||||
} from 'react';
|
||||
|
||||
import { useHash, useIsMounted, useListOverflow } from '@/components/hooks';
|
||||
import { useHash, useListOverflow } from '@/components/hooks';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
@@ -71,6 +78,7 @@ export function DynamicTabs(props: {
|
||||
const router = useRouter();
|
||||
|
||||
const hash = useHash();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [tabsState, setTabsState] = useTabsState();
|
||||
const activeState = useMemo(() => {
|
||||
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 (
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -177,12 +221,11 @@ const TabPanel = memo(function TabPanel(props: {
|
||||
role="tabpanel"
|
||||
id={tab.id}
|
||||
aria-labelledby={getTabButtonId(tab.id)}
|
||||
className={tcls(
|
||||
'scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*12)] p-4',
|
||||
isActive ? null : 'hidden'
|
||||
)}
|
||||
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
|
||||
>
|
||||
{tab.body}
|
||||
<div className="p-4" hidden={!isActive}>
|
||||
{tab.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user