1
1
'use client' ;
2
2
3
- import type { SiteSection } from '@gitbook/api' ;
4
- import { type IconName } from '@gitbook/icons' ;
3
+ import type { SiteSection , SiteSectionGroup } from '@gitbook/api' ;
4
+ import { Icon , type IconName } from '@gitbook/icons' ;
5
+ import { motion } from 'framer-motion' ;
5
6
import React from 'react' ;
6
7
7
8
import { SectionsList } from '@/lib/api' ;
8
9
import { ClassValue , tcls } from '@/lib/tailwind' ;
9
10
10
11
import { Link } from '../primitives' ;
11
12
import { SectionIcon } from './SectionIcon' ;
12
- import { useIsMounted } from '../hooks' ;
13
+ import { useIsMounted , useToggleAnimation } from '../hooks' ;
13
14
import { TOCScrollContainer , useScrollToActiveTOCItem } from '../TableOfContents/TOCScroller' ;
14
15
15
16
const MAX_ITEMS = 5 ; // If there are more sections than this, they'll be shown below the fold in a scrollview.
@@ -19,12 +20,12 @@ const MAX_ITEMS = 5; // If there are more sections than this, they'll be shown b
19
20
*/
20
21
export function SiteSectionList ( props : { sections : SectionsList ; className : ClassValue } ) {
21
22
const {
22
- sections : { list : sections , index : currentIndex } ,
23
+ sections : { list : sectionsAndGroups , current : currentSection } ,
23
24
className,
24
25
} = props ;
25
26
26
27
return (
27
- sections . length > 0 && (
28
+ sectionsAndGroups . length > 0 && (
28
29
< nav
29
30
aria-label = "Sections"
30
31
className = { tcls (
@@ -39,21 +40,37 @@ export function SiteSectionList(props: { sections: SectionsList; className: Clas
39
40
style = { { maxHeight : `${ MAX_ITEMS * 3 + 2 } rem` } }
40
41
className = "overflow-y-auto px-2 pb-6 gutter-stable"
41
42
>
42
- { sections . map ( ( section , index ) => (
43
- < SiteSectionListItem
44
- section = { section }
45
- isActive = { index === currentIndex }
46
- key = { section . id }
47
- />
48
- ) ) }
43
+ { sectionsAndGroups . map ( ( item ) => {
44
+ if ( item . object === 'site-section-group' ) {
45
+ return (
46
+ < SiteSectionGroupItem
47
+ key = { item . id }
48
+ group = { item }
49
+ currentSection = { currentSection }
50
+ />
51
+ ) ;
52
+ }
53
+
54
+ return (
55
+ < SiteSectionListItem
56
+ section = { item }
57
+ isActive = { item . id === currentSection . id }
58
+ key = { item . id }
59
+ />
60
+ ) ;
61
+ } ) }
49
62
</ TOCScrollContainer >
50
63
</ nav >
51
64
)
52
65
) ;
53
66
}
54
67
55
- export function SiteSectionListItem ( props : { section : SiteSection ; isActive : boolean } ) {
56
- const { section, isActive, ...otherProps } = props ;
68
+ export function SiteSectionListItem ( props : {
69
+ section : SiteSection ;
70
+ isActive : boolean ;
71
+ className ?: string ;
72
+ } ) {
73
+ const { section, isActive, className, ...otherProps } = props ;
57
74
58
75
const isMounted = useIsMounted ( ) ;
59
76
React . useEffect ( ( ) => { } , [ isMounted ] ) ; // This updates the useScrollToActiveTOCItem hook once we're mounted, so we can actually scroll to the this item
@@ -75,12 +92,13 @@ export function SiteSectionListItem(props: { section: SiteSection; isActive: boo
75
92
? `text-primary hover:text-primary-strong contrast-more:text-primary-strong font-semibold
76
93
hover:bg-primary-hover contrast-more:hover:ring-1 contrast-more:hover:ring-primary-hover`
77
94
: null ,
95
+ className ,
78
96
) }
79
97
{ ...otherProps }
80
98
>
81
99
< div
82
100
className = { tcls (
83
- `size-8 flex items-center justify-center
101
+ `shrink-0 size-8 flex items-center justify-center
84
102
bg-tint-subtle shadow-sm shadow-tint
85
103
dark:shadow-none rounded-md straight-corners:rounded-none leading-none
86
104
ring-1 ring-tint-subtle
@@ -107,3 +125,129 @@ export function SiteSectionListItem(props: { section: SiteSection; isActive: boo
107
125
</ Link >
108
126
) ;
109
127
}
128
+
129
+ export function SiteSectionGroupItem ( props : {
130
+ group : SiteSectionGroup ;
131
+ currentSection : SiteSection ;
132
+ } ) {
133
+ const { group, currentSection } = props ;
134
+
135
+ const hasDescendants = group . sections . length > 0 ;
136
+ const isActiveGroup = group . sections . some ( ( section ) => section . id === currentSection . id ) ;
137
+ const [ isVisible , setIsVisible ] = React . useState ( isActiveGroup ) ;
138
+
139
+ // Update the visibility of the children, if we are navigating to a descendant.
140
+ React . useEffect ( ( ) => {
141
+ if ( ! hasDescendants ) {
142
+ return ;
143
+ }
144
+
145
+ setIsVisible ( ( prev ) => prev || isActiveGroup ) ;
146
+ } , [ isActiveGroup , hasDescendants ] ) ;
147
+
148
+ const { show, hide, scope } = useToggleAnimation ( { hasDescendants, isVisible } ) ;
149
+
150
+ return (
151
+ < >
152
+ < button
153
+ onClick = { ( event ) => {
154
+ event . preventDefault ( ) ;
155
+ event . stopPropagation ( ) ;
156
+ setIsVisible ( ( prev ) => ! prev ) ;
157
+ } }
158
+ className = { `w-full flex flex-row items-center gap-3 px-3 py-2
159
+ hover:bg-tint-hover contrast-more:hover:ring-1 contrast-more:hover:ring-tint
160
+ hover:text-tint-strong
161
+ rounded-md straight-corners:rounded-none transition-all group/section-link
162
+ ${
163
+ isActiveGroup
164
+ ? `text-primary hover:text-primary-strong contrast-more:text-primary-strong font-semibold
165
+ hover:bg-primary-hover contrast-more:hover:ring-1 contrast-more:hover:ring-primary-hover`
166
+ : null
167
+ } `}
168
+ >
169
+ < div
170
+ className = { tcls (
171
+ `shrink-0 size-8 flex items-center justify-center
172
+ bg-tint-subtle shadow-sm shadow-tint
173
+ dark:shadow-none rounded-md straight-corners:rounded-none leading-none
174
+ ring-1 ring-tint-subtle
175
+ text-tint contrast-more:text-tint-strong
176
+ group-hover/section-link:scale-110 group-active/section-link:scale-90 group-active/section-link:shadow-none group-hover/section-link:ring-tint-hover
177
+ transition-transform text-lg` ,
178
+ isActiveGroup
179
+ ? `bg-primary ring-primary group-hover/section-link:ring-primary-hover,
180
+ shadow-md shadow-primary
181
+ contrast-more:ring-2 contrast-more:ring-primary
182
+ text-primary contrast-more:text-primary-strong tint:bg-primary-solid tint:text-contrast-primary-solid`
183
+ : null ,
184
+ ) }
185
+ >
186
+ { group . icon ? (
187
+ < SectionIcon icon = { group . icon as IconName } isActive = { isActiveGroup } />
188
+ ) : (
189
+ < span className = { `opacity-8 text-sm ${ isActiveGroup && 'opacity-10' } ` } >
190
+ { group . title . substring ( 0 , 2 ) }
191
+ </ span >
192
+ ) }
193
+ </ div >
194
+ { group . title }
195
+ < span
196
+ className = { tcls (
197
+ 'ml-auto' ,
198
+ 'group' ,
199
+ 'relative' ,
200
+ 'rounded-full' ,
201
+ 'straight-corners:rounded-sm' ,
202
+ 'w-5' ,
203
+ 'h-5' ,
204
+ 'after:grid-area-1-1' ,
205
+ 'after:absolute' ,
206
+ 'after:-top-1' ,
207
+ 'after:grid' ,
208
+ 'after:-left-1' ,
209
+ 'after:w-7' ,
210
+ 'after:h-7' ,
211
+ 'hover:bg-tint-active' ,
212
+ 'hover:text-current' ,
213
+ isActiveGroup ? [ 'hover:bg-tint-hover' ] : [ ] ,
214
+ ) }
215
+ >
216
+ < Icon
217
+ icon = "chevron-right"
218
+ className = { tcls (
219
+ 'grid' ,
220
+ 'flex-shrink-0' ,
221
+ 'size-3' ,
222
+ 'm-1' ,
223
+ 'transition-[opacity]' ,
224
+ 'text-current' ,
225
+ 'transition-transform' ,
226
+ 'opacity-6' ,
227
+ 'group-hover:opacity-11' ,
228
+ 'contrast-more:opacity-11' ,
229
+
230
+ isVisible ? [ 'rotate-90' ] : [ 'rotate-0' ] ,
231
+ ) }
232
+ />
233
+ </ span >
234
+ </ button >
235
+ { hasDescendants ? (
236
+ < motion . div
237
+ ref = { scope }
238
+ className = { tcls ( isVisible ? null : '[&_ul>li]:opacity-1' ) }
239
+ initial = { isVisible ? show : hide }
240
+ >
241
+ { group . sections . map ( ( section ) => (
242
+ < SiteSectionListItem
243
+ section = { section }
244
+ isActive = { section . id === currentSection . id }
245
+ key = { section . id }
246
+ className = "pl-5"
247
+ />
248
+ ) ) }
249
+ </ motion . div >
250
+ ) : null }
251
+ </ >
252
+ ) ;
253
+ }
0 commit comments