1
1
'use client' ;
2
2
3
3
import React from 'react' ;
4
+ import { atom , selectorFamily , useRecoilValue , useSetRecoilState } from 'recoil' ;
4
5
6
+ import { useHash , useIsMounted } from '@/components/hooks' ;
5
7
import { ClassValue , tcls } from '@/lib/tailwind' ;
6
8
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
+
7
33
/**
8
34
* Client side component for the tabs, taking care of interactions.
9
35
*/
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 ] ) ;
21
105
22
106
return (
23
107
< div
@@ -52,11 +136,11 @@ export function DynamicTabs(props: {
52
136
< button
53
137
key = { tab . id }
54
138
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 ) }
58
142
onClick = { ( ) => {
59
- setActive ( tab . id ) ;
143
+ onSelectTab ( tab ) ;
60
144
} }
61
145
className = { tcls (
62
146
//prev from active-tab
@@ -102,7 +186,7 @@ export function DynamicTabs(props: {
102
186
'truncate' ,
103
187
'max-w-full' ,
104
188
105
- active === tab . id
189
+ active . id === tab . id
106
190
? [
107
191
'shrink-0' ,
108
192
'active-tab' ,
@@ -121,17 +205,96 @@ export function DynamicTabs(props: {
121
205
</ button >
122
206
) ) }
123
207
</ div >
124
- { tabs . map ( ( tab ) => (
208
+ { tabs . map ( ( tab , index ) => (
125
209
< div
126
210
key = { tab . id }
127
211
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 ) }
131
215
>
132
- { tab . children }
216
+ { tabsBody [ index ] }
133
217
</ div >
134
218
) ) }
135
219
</ div >
136
220
) ;
137
221
}
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