Persist state of tabs and dynamically sync them based on title (#2472)

This commit is contained in:
Samy Pessé
2024-09-19 09:40:54 +02:00
committed by GitHub
parent 2ff7ed1b52
commit b32e40c72a
6 changed files with 227 additions and 32 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Persist state of tabs and dynamically sync them based on title
@@ -1,23 +1,107 @@
'use client';
import React from 'react';
import { atom, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil';
import { useHash, useIsMounted } from '@/components/hooks';
import { ClassValue, tcls } from '@/lib/tailwind';
// How many titles are remembered:
const TITLES_MAX = 5;
export interface TabsItem {
id: string;
title: string;
}
// https://github.com/facebookexperimental/Recoil/issues/629#issuecomment-914273925
type SelectorMapper<Type> = {
[Property in keyof Type]: Type[Property];
};
type TabsInput = {
id: string;
tabs: SelectorMapper<TabsItem>[];
};
interface TabsState {
activeIds: {
[tabsBlockId: string]: string;
};
activeTitles: string[];
}
/**
* Client side component for the tabs, taking care of interactions.
*/
export function DynamicTabs(props: {
tabs: Array<{
id: string;
title: string;
children: React.ReactNode;
}>;
style: ClassValue;
}) {
const { tabs, style } = props;
export function DynamicTabs(
props: TabsInput & {
tabsBody: React.ReactNode[];
style: ClassValue;
},
) {
const { id, tabs, tabsBody, style } = props;
const [active, setActive] = React.useState<null | string>(tabs[0].id);
const hash = useHash();
const activeState = useRecoilValue(tabsActiveSelector({ id, tabs }));
// To avoid issue with hydration, we only use the state from recoil (which is loaded from localstorage),
// once the component has been 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 setTabsState = useSetRecoilState(tabsAtom);
/**
* When clicking to select a tab, we:
* - mark this specific ID as selected
* - store the ID to auto-select other tabs with the same title
*/
const onSelectTab = React.useCallback(
(tab: TabsItem) => {
setTabsState((prev) => ({
activeIds: {
...prev.activeIds,
[id]: tab.id,
},
activeTitles: tab.title
? prev.activeTitles
.filter((t) => t !== tab.title)
.concat([tab.title])
.slice(-TITLES_MAX)
: prev.activeTitles,
}));
},
[id, setTabsState],
);
/**
* When the hash changes, we try to select the tab containing the targetted element.
*/
React.useEffect(() => {
if (!hash) {
return;
}
const activeElement = document.getElementById(hash);
if (!activeElement) {
return;
}
const tabAncestor = activeElement.closest('[role="tabpanel"]');
if (!tabAncestor) {
return;
}
const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id);
if (!tab) {
return;
}
onSelectTab(tab);
}, [hash, tabs, onSelectTab]);
return (
<div
@@ -52,11 +136,11 @@ export function DynamicTabs(props: {
<button
key={tab.id}
role="tab"
aria-selected={active === tab.id}
aria-controls={`tabpanel-${tab.id}`}
id={`tab-${tab.id}`}
aria-selected={active.id === tab.id}
aria-controls={getTabPanelId(tab.id)}
id={getTabButtonId(tab.id)}
onClick={() => {
setActive(tab.id);
onSelectTab(tab);
}}
className={tcls(
//prev from active-tab
@@ -102,7 +186,7 @@ export function DynamicTabs(props: {
'truncate',
'max-w-full',
active === tab.id
active.id === tab.id
? [
'shrink-0',
'active-tab',
@@ -121,17 +205,96 @@ export function DynamicTabs(props: {
</button>
))}
</div>
{tabs.map((tab) => (
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`tabpanel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
className={tcls('p-4', tab.id !== active ? 'hidden' : null)}
id={getTabPanelId(tab.id)}
aria-labelledby={getTabButtonId(tab.id)}
className={tcls('p-4', tab.id !== active.id ? 'hidden' : null)}
>
{tab.children}
{tabsBody[index]}
</div>
))}
</div>
);
}
const tabsAtom = atom<TabsState>({
key: 'tabsAtom',
default: {
activeIds: {},
activeTitles: [],
},
effects: [
// Persist the state to local storage
({ trigger, setSelf, onSet }) => {
if (typeof localStorage === 'undefined') {
return;
}
const localStorageKey = '@gitbook/tabsState';
if (trigger === 'get') {
const stored = localStorage.getItem(localStorageKey);
if (stored) {
setSelf(JSON.parse(stored));
}
}
onSet((newState) => {
localStorage.setItem(localStorageKey, JSON.stringify(newState));
});
},
],
});
const tabsActiveSelector = selectorFamily<TabsItem, SelectorMapper<TabsInput>>({
key: 'tabsActiveSelector',
get:
(input) =>
({ get }) => {
const state = get(tabsAtom);
return getTabBySelection(input, state) ?? getTabByTitle(input, state) ?? input.tabs[0];
},
});
/**
* Get the ID for a tab button.
*/
function getTabButtonId(tabId: string) {
return `tab-${tabId}`;
}
/**
* Get the ID for a tab panel.
*/
function getTabPanelId(tabId: string) {
return `tabpanel-${tabId}`;
}
/**
* Get explicitly selected tab in a set of tabs.
*/
function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null {
const activeId = state.activeIds[input.id];
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
}
/**
* Get the best selected tab in a set of tabs by taking only title into account.
*/
function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null {
return (
input.tabs
.map((item) => {
return {
item,
score: state.activeTitles.indexOf(item.title),
};
})
.filter(({ score }) => score >= 0)
// .sortBy(({ score }) => -score)
.sort(({ score: a }, { score: b }) => b - a)
.map(({ item }) => item)[0] ?? null
);
}
@@ -2,17 +2,23 @@ import { DocumentBlockTabs } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { DynamicTabs } from './DynamicTabs';
import { DynamicTabs, TabsItem } from './DynamicTabs';
import { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
const { block, ancestorBlocks, document, style, context } = props;
const tabs = block.nodes.map((tab, index) => ({
id: tab.key!,
title: tab.data.title ?? '',
children: (
const tabs: TabsItem[] = [];
const tabsBody: React.ReactNode[] = [];
block.nodes.forEach((tab, index) => {
tabs.push({
id: tab.key!,
title: tab.data.title ?? '',
});
tabsBody.push(
<Blocks
nodes={tab.nodes}
document={document}
@@ -20,20 +26,26 @@ export function Tabs(props: BlockProps<DocumentBlockTabs>) {
context={context}
blockStyle={tcls('flip-heading-hash')}
style={tcls('w-full', 'space-y-4')}
/>
),
}));
/>,
);
});
if (context.mode === 'print') {
// When printing, we display the tab, one after the other
return (
<>
{tabs.map((tab) => (
<DynamicTabs key={tab.id} tabs={[tab]} style={style} />
{tabs.map((tab, index) => (
<DynamicTabs
key={tab.id}
id={block.key!}
tabs={[tab]}
tabsBody={[tabsBody[index]]}
style={style}
/>
))}
</>
);
}
return <DynamicTabs tabs={tabs} style={style} />;
return <DynamicTabs id={block.key!} tabs={tabs} tabsBody={tabsBody} style={style} />;
}
@@ -1,3 +1,4 @@
export * from './useScrollActiveId';
export * from './useScrollToHash';
export * from './useHash';
export * from './useIsMounted';
@@ -3,7 +3,7 @@ import React from 'react';
export function useHash() {
const params = useParams();
const [hash, setHash] = React.useState<string>(global.location?.hash?.slice(1));
const [hash, setHash] = React.useState<string | null>(global.location?.hash?.slice(1) ?? null);
React.useEffect(() => {
function updateHash() {
setHash(global.location?.hash?.slice(1));
@@ -0,0 +1,14 @@
import React from 'react';
/**
* Hook to check if a component is mounted.
*/
export function useIsMounted() {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
return mounted;
}