11'use client' ;
22
33import React from 'react' ;
4+ import { atom , selectorFamily , useRecoilValue , useSetRecoilState } from 'recoil' ;
45
6+ import { useHash , useIsMounted } from '@/components/hooks' ;
57import { 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+ }
0 commit comments