Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mighty-apples-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': minor
---

Persist state of tabs and dynamically sync them based on title
205 changes: 184 additions & 21 deletions packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx
Original file line number Diff line number Diff line change
@@ -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;

const [active, setActive] = React.useState<null | string>(tabs[0].id);
export function DynamicTabs(
props: TabsInput & {
tabsBody: React.ReactNode[];
style: ClassValue;
},
) {
const { id, tabs, tabsBody, style } = props;

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -102,7 +186,7 @@ export function DynamicTabs(props: {
'truncate',
'max-w-full',

active === tab.id
active.id === tab.id
? [
'shrink-0',
'active-tab',
Expand All @@ -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
);
}
34 changes: 23 additions & 11 deletions packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,50 @@ 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}
ancestorBlocks={[...ancestorBlocks, block, tab]}
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 change: 1 addition & 0 deletions packages/gitbook/src/components/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './useScrollActiveId';
export * from './useScrollToHash';
export * from './useHash';
export * from './useIsMounted';
2 changes: 1 addition & 1 deletion packages/gitbook/src/components/hooks/useHash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
14 changes: 14 additions & 0 deletions packages/gitbook/src/components/hooks/useIsMounted.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading