Skip to content

Commit 6c98939

Browse files
authored
Fix scrolling hash into tabs / expandable (#3757)
1 parent a2fb627 commit 6c98939

File tree

2 files changed

+70
-22
lines changed

2 files changed

+70
-22
lines changed

packages/gitbook/src/components/DocumentView/Expandable/Details.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,29 +17,34 @@ export function Details(props: {
1717
}) {
1818
const { children, id, className } = props;
1919

20-
const detailsRef = React.useRef<HTMLDetailsElement>(null);
20+
const ref = React.useRef<HTMLDetailsElement>(null);
2121

2222
const [openFromHash, setOpenFromHash] = React.useState(false);
2323

2424
const hash = useHash();
25+
2526
/**
2627
* Open the details element if the url hash refers to the id of the details element
2728
* or the id of some element contained within the details element.
2829
*/
2930
React.useEffect(() => {
30-
if (!hash || !detailsRef.current) {
31+
if (!hash || !ref.current) {
3132
return;
3233
}
34+
3335
if (hash === id) {
3436
setOpenFromHash(true);
37+
return;
3538
}
39+
3640
const activeElement = document.getElementById(hash);
37-
setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
41+
const isOpen = Boolean(activeElement && ref.current.contains(activeElement));
42+
setOpenFromHash(isOpen);
3843
}, [hash, id]);
3944

4045
return (
4146
<details
42-
ref={detailsRef}
47+
ref={ref}
4348
id={id}
4449
open={props.open || openFromHash}
4550
className={tcls(

packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
'use client';
22

3-
import React, { memo, useCallback, useMemo, type ComponentPropsWithRef } from 'react';
4-
5-
import { useHash, useIsMounted, useListOverflow } from '@/components/hooks';
3+
import React, {
4+
memo,
5+
useCallback,
6+
useMemo,
7+
useRef,
8+
useState,
9+
type ComponentPropsWithRef,
10+
} from 'react';
11+
12+
import { useHash, useListOverflow } from '@/components/hooks';
613
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
714
import { useLanguage } from '@/intl/client';
815
import { tString } from '@/intl/translate';
@@ -71,6 +78,7 @@ export function DynamicTabs(props: {
7178
const router = useRouter();
7279

7380
const hash = useHash();
81+
const [initialized, setInitialized] = useState(false);
7482
const [tabsState, setTabsState] = useTabsState();
7583
const activeState = useMemo(() => {
7684
const input = { id, tabs };
@@ -79,28 +87,33 @@ export function DynamicTabs(props: {
7987
);
8088
}, [id, tabs, tabsState]);
8189

90+
// Track if the tab has been touched by the user.
91+
const touchedRef = useRef(false);
92+
8293
// To avoid issue with hydration, we only use the state from localStorage
83-
// once the component has been mounted.
94+
// once the component has been initialized (=mounted).
8495
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
8596
// and get stuck into an inconsistent state.
86-
const mounted = useIsMounted();
87-
const active = mounted ? activeState : tabs[0];
97+
const active = initialized ? activeState : tabs[0];
8898

8999
// When clicking to select a tab, we:
90100
// - update the URL hash
91101
// - mark this specific ID as selected
92102
// - store the ID to auto-select other tabs with the same title
93103
const selectTab = useCallback(
94-
(tabId: string) => {
104+
(tabId: string, manual = true) => {
95105
const tab = tabs.find((tab) => tab.id === tabId);
96106

97107
if (!tab) {
98108
return;
99109
}
100110

101-
const href = `#${tab.id}`;
102-
if (window.location.hash !== href) {
103-
router.replace(href, { scroll: false });
111+
if (manual) {
112+
touchedRef.current = true;
113+
const href = `#${tab.id}`;
114+
if (window.location.hash !== href) {
115+
router.replace(href, { scroll: false });
116+
}
104117
}
105118

106119
setTabsState((prev) => {
@@ -125,12 +138,14 @@ export function DynamicTabs(props: {
125138
);
126139

127140
// When the hash changes, we try to select the tab containing the targetted element.
128-
React.useEffect(() => {
141+
React.useLayoutEffect(() => {
142+
setInitialized(true);
143+
129144
if (hash) {
130145
// First check if the hash matches a tab ID.
131146
const hashIsTab = tabs.some((tab) => tab.id === hash);
132147
if (hashIsTab) {
133-
selectTab(hash);
148+
selectTab(hash, false);
134149
return;
135150
}
136151

@@ -145,10 +160,39 @@ export function DynamicTabs(props: {
145160
return;
146161
}
147162

148-
selectTab(tabPanel.id);
163+
selectTab(tabPanel.id, false);
149164
}
150165
}, [selectTab, tabs, hash]);
151166

167+
// Scroll to active element in the tab.
168+
React.useLayoutEffect(() => {
169+
// If there is no hash or active tab, nothing to scroll.
170+
if (!hash || !active) {
171+
return;
172+
}
173+
174+
// If the tab is touched, we don't want to scroll.
175+
if (touchedRef.current) {
176+
return;
177+
}
178+
179+
// If the hash matches a tab, then the scroll is already done.
180+
const hashIsTab = tabs.some((tab) => tab.id === hash);
181+
if (hashIsTab) {
182+
return;
183+
}
184+
185+
const activeElement = document.getElementById(hash);
186+
if (!activeElement) {
187+
return;
188+
}
189+
190+
activeElement.scrollIntoView({
191+
block: 'start',
192+
behavior: 'instant',
193+
});
194+
}, [active, tabs, hash]);
195+
152196
return (
153197
<div
154198
className={tcls(
@@ -177,12 +221,11 @@ const TabPanel = memo(function TabPanel(props: {
177221
role="tabpanel"
178222
id={tab.id}
179223
aria-labelledby={getTabButtonId(tab.id)}
180-
className={tcls(
181-
'scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*12)] p-4',
182-
isActive ? null : 'hidden'
183-
)}
224+
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
184225
>
185-
{tab.body}
226+
<div className="p-4" hidden={!isActive}>
227+
{tab.body}
228+
</div>
186229
</div>
187230
);
188231
});

0 commit comments

Comments
 (0)