mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Persist state of tabs and dynamically sync them based on title (#2472)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user