Skip to content

Commit b32e40c

Browse files
authored
Persist state of tabs and dynamically sync them based on title (#2472)
1 parent 2ff7ed1 commit b32e40c

File tree

6 files changed

+228
-33
lines changed

6 files changed

+228
-33
lines changed

.changeset/mighty-apples-design.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'gitbook': minor
3+
---
4+
5+
Persist state of tabs and dynamically sync them based on title

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

Lines changed: 184 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,107 @@
11
'use client';
22

33
import React from 'react';
4+
import { atom, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil';
45

6+
import { useHash, useIsMounted } from '@/components/hooks';
57
import { ClassValue, tcls } from '@/lib/tailwind';
68

9+
// How many titles are remembered:
10+
const TITLES_MAX = 5;
11+
12+
export interface TabsItem {
13+
id: string;
14+
title: string;
15+
}
16+
17+
// https://github.com/facebookexperimental/Recoil/issues/629#issuecomment-914273925
18+
type SelectorMapper<Type> = {
19+
[Property in keyof Type]: Type[Property];
20+
};
21+
type TabsInput = {
22+
id: string;
23+
tabs: SelectorMapper<TabsItem>[];
24+
};
25+
26+
interface TabsState {
27+
activeIds: {
28+
[tabsBlockId: string]: string;
29+
};
30+
activeTitles: string[];
31+
}
32+
733
/**
834
* Client side component for the tabs, taking care of interactions.
935
*/
10-
export function DynamicTabs(props: {
11-
tabs: Array<{
12-
id: string;
13-
title: string;
14-
children: React.ReactNode;
15-
}>;
16-
style: ClassValue;
17-
}) {
18-
const { tabs, style } = props;
19-
20-
const [active, setActive] = React.useState<null | string>(tabs[0].id);
36+
export function DynamicTabs(
37+
props: TabsInput & {
38+
tabsBody: React.ReactNode[];
39+
style: ClassValue;
40+
},
41+
) {
42+
const { id, tabs, tabsBody, style } = props;
43+
44+
const hash = useHash();
45+
46+
const activeState = useRecoilValue(tabsActiveSelector({ id, tabs }));
47+
48+
// To avoid issue with hydration, we only use the state from recoil (which is loaded from localstorage),
49+
// once the component has been mounted.
50+
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
51+
// and get stuck into an inconsistent state.
52+
const mounted = useIsMounted();
53+
const active = mounted ? activeState : tabs[0];
54+
55+
const setTabsState = useSetRecoilState(tabsAtom);
56+
57+
/**
58+
* When clicking to select a tab, we:
59+
* - mark this specific ID as selected
60+
* - store the ID to auto-select other tabs with the same title
61+
*/
62+
const onSelectTab = React.useCallback(
63+
(tab: TabsItem) => {
64+
setTabsState((prev) => ({
65+
activeIds: {
66+
...prev.activeIds,
67+
[id]: tab.id,
68+
},
69+
activeTitles: tab.title
70+
? prev.activeTitles
71+
.filter((t) => t !== tab.title)
72+
.concat([tab.title])
73+
.slice(-TITLES_MAX)
74+
: prev.activeTitles,
75+
}));
76+
},
77+
[id, setTabsState],
78+
);
79+
80+
/**
81+
* When the hash changes, we try to select the tab containing the targetted element.
82+
*/
83+
React.useEffect(() => {
84+
if (!hash) {
85+
return;
86+
}
87+
88+
const activeElement = document.getElementById(hash);
89+
if (!activeElement) {
90+
return;
91+
}
92+
93+
const tabAncestor = activeElement.closest('[role="tabpanel"]');
94+
if (!tabAncestor) {
95+
return;
96+
}
97+
98+
const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id);
99+
if (!tab) {
100+
return;
101+
}
102+
103+
onSelectTab(tab);
104+
}, [hash, tabs, onSelectTab]);
21105

22106
return (
23107
<div
@@ -52,11 +136,11 @@ export function DynamicTabs(props: {
52136
<button
53137
key={tab.id}
54138
role="tab"
55-
aria-selected={active === tab.id}
56-
aria-controls={`tabpanel-${tab.id}`}
57-
id={`tab-${tab.id}`}
139+
aria-selected={active.id === tab.id}
140+
aria-controls={getTabPanelId(tab.id)}
141+
id={getTabButtonId(tab.id)}
58142
onClick={() => {
59-
setActive(tab.id);
143+
onSelectTab(tab);
60144
}}
61145
className={tcls(
62146
//prev from active-tab
@@ -102,7 +186,7 @@ export function DynamicTabs(props: {
102186
'truncate',
103187
'max-w-full',
104188

105-
active === tab.id
189+
active.id === tab.id
106190
? [
107191
'shrink-0',
108192
'active-tab',
@@ -121,17 +205,96 @@ export function DynamicTabs(props: {
121205
</button>
122206
))}
123207
</div>
124-
{tabs.map((tab) => (
208+
{tabs.map((tab, index) => (
125209
<div
126210
key={tab.id}
127211
role="tabpanel"
128-
id={`tabpanel-${tab.id}`}
129-
aria-labelledby={`tab-${tab.id}`}
130-
className={tcls('p-4', tab.id !== active ? 'hidden' : null)}
212+
id={getTabPanelId(tab.id)}
213+
aria-labelledby={getTabButtonId(tab.id)}
214+
className={tcls('p-4', tab.id !== active.id ? 'hidden' : null)}
131215
>
132-
{tab.children}
216+
{tabsBody[index]}
133217
</div>
134218
))}
135219
</div>
136220
);
137221
}
222+
223+
const tabsAtom = atom<TabsState>({
224+
key: 'tabsAtom',
225+
default: {
226+
activeIds: {},
227+
activeTitles: [],
228+
},
229+
effects: [
230+
// Persist the state to local storage
231+
({ trigger, setSelf, onSet }) => {
232+
if (typeof localStorage === 'undefined') {
233+
return;
234+
}
235+
236+
const localStorageKey = '@gitbook/tabsState';
237+
if (trigger === 'get') {
238+
const stored = localStorage.getItem(localStorageKey);
239+
if (stored) {
240+
setSelf(JSON.parse(stored));
241+
}
242+
}
243+
244+
onSet((newState) => {
245+
localStorage.setItem(localStorageKey, JSON.stringify(newState));
246+
});
247+
},
248+
],
249+
});
250+
251+
const tabsActiveSelector = selectorFamily<TabsItem, SelectorMapper<TabsInput>>({
252+
key: 'tabsActiveSelector',
253+
get:
254+
(input) =>
255+
({ get }) => {
256+
const state = get(tabsAtom);
257+
return getTabBySelection(input, state) ?? getTabByTitle(input, state) ?? input.tabs[0];
258+
},
259+
});
260+
261+
/**
262+
* Get the ID for a tab button.
263+
*/
264+
function getTabButtonId(tabId: string) {
265+
return `tab-${tabId}`;
266+
}
267+
268+
/**
269+
* Get the ID for a tab panel.
270+
*/
271+
function getTabPanelId(tabId: string) {
272+
return `tabpanel-${tabId}`;
273+
}
274+
275+
/**
276+
* Get explicitly selected tab in a set of tabs.
277+
*/
278+
function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null {
279+
const activeId = state.activeIds[input.id];
280+
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
281+
}
282+
283+
/**
284+
* Get the best selected tab in a set of tabs by taking only title into account.
285+
*/
286+
function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null {
287+
return (
288+
input.tabs
289+
.map((item) => {
290+
return {
291+
item,
292+
score: state.activeTitles.indexOf(item.title),
293+
};
294+
})
295+
.filter(({ score }) => score >= 0)
296+
// .sortBy(({ score }) => -score)
297+
.sort(({ score: a }, { score: b }) => b - a)
298+
.map(({ item }) => item)[0] ?? null
299+
);
300+
}

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

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,50 @@ import { DocumentBlockTabs } from '@gitbook/api';
22

33
import { tcls } from '@/lib/tailwind';
44

5-
import { DynamicTabs } from './DynamicTabs';
5+
import { DynamicTabs, TabsItem } from './DynamicTabs';
66
import { BlockProps } from '../Block';
77
import { Blocks } from '../Blocks';
88

99
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
1010
const { block, ancestorBlocks, document, style, context } = props;
1111

12-
const tabs = block.nodes.map((tab, index) => ({
13-
id: tab.key!,
14-
title: tab.data.title ?? '',
15-
children: (
12+
const tabs: TabsItem[] = [];
13+
const tabsBody: React.ReactNode[] = [];
14+
15+
block.nodes.forEach((tab, index) => {
16+
tabs.push({
17+
id: tab.key!,
18+
title: tab.data.title ?? '',
19+
});
20+
21+
tabsBody.push(
1622
<Blocks
1723
nodes={tab.nodes}
1824
document={document}
1925
ancestorBlocks={[...ancestorBlocks, block, tab]}
2026
context={context}
2127
blockStyle={tcls('flip-heading-hash')}
2228
style={tcls('w-full', 'space-y-4')}
23-
/>
24-
),
25-
}));
29+
/>,
30+
);
31+
});
2632

2733
if (context.mode === 'print') {
2834
// When printing, we display the tab, one after the other
2935
return (
3036
<>
31-
{tabs.map((tab) => (
32-
<DynamicTabs key={tab.id} tabs={[tab]} style={style} />
37+
{tabs.map((tab, index) => (
38+
<DynamicTabs
39+
key={tab.id}
40+
id={block.key!}
41+
tabs={[tab]}
42+
tabsBody={[tabsBody[index]]}
43+
style={style}
44+
/>
3345
))}
3446
</>
3547
);
3648
}
3749

38-
return <DynamicTabs tabs={tabs} style={style} />;
50+
return <DynamicTabs id={block.key!} tabs={tabs} tabsBody={tabsBody} style={style} />;
3951
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './useScrollActiveId';
22
export * from './useScrollToHash';
33
export * from './useHash';
4+
export * from './useIsMounted';

packages/gitbook/src/components/hooks/useHash.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React from 'react';
33

44
export function useHash() {
55
const params = useParams();
6-
const [hash, setHash] = React.useState<string>(global.location?.hash?.slice(1));
6+
const [hash, setHash] = React.useState<string | null>(global.location?.hash?.slice(1) ?? null);
77
React.useEffect(() => {
88
function updateHash() {
99
setHash(global.location?.hash?.slice(1));
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from 'react';
2+
3+
/**
4+
* Hook to check if a component is mounted.
5+
*/
6+
export function useIsMounted() {
7+
const [mounted, setMounted] = React.useState(false);
8+
9+
React.useEffect(() => {
10+
setMounted(true);
11+
}, []);
12+
13+
return mounted;
14+
}

0 commit comments

Comments
 (0)