Skip to content

Commit 38061bd

Browse files
authored
RND-5646: section groups navigation menu (#2789)
1 parent d9029c7 commit 38061bd

File tree

12 files changed

+546
-139
lines changed

12 files changed

+546
-139
lines changed

.changeset/swift-avocados-film.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'gitbook': patch
3+
---
4+
5+
Add section groups to section tabs

bun.lock

Lines changed: 41 additions & 1 deletion
Large diffs are not rendered by default.

packages/gitbook/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@gitbook/react-math": "workspace:*",
2525
"@gitbook/react-openapi": "workspace:*",
2626
"@radix-ui/react-checkbox": "^1.0.4",
27+
"@radix-ui/react-navigation-menu": "^1.2.3",
2728
"@radix-ui/react-popover": "^1.0.7",
2829
"@sentry/nextjs": "8.35.0",
2930
"@sindresorhus/fnv1a": "^3.1.0",

packages/gitbook/src/components/Header/Header.tsx

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ export function Header(props: {
3232
const { context, space, site, spaces, sections, customization, withTopHeader } = props;
3333
const isCustomizationDefault =
3434
customization.header.preset === CustomizationHeaderPreset.Default;
35-
const hasSiteSections = sections && sections.list.length > 1;
3635
const isMultiVariants = site && spaces.length > 1;
3736

3837
return (
@@ -203,18 +202,7 @@ export function Header(props: {
203202
</div>
204203
</div>
205204
</div>
206-
{sections ? (
207-
<div
208-
className={tcls(
209-
'scroll-nojump',
210-
'w-full',
211-
// Handle long section tabs, particularly on smaller screens.
212-
'overflow-x-auto hide-scroll',
213-
)}
214-
>
215-
<SiteSectionTabs sections={sections} />
216-
</div>
217-
) : null}
205+
{sections ? <SiteSectionTabs sections={sections} /> : null}
218206
</header>
219207
);
220208
}

packages/gitbook/src/components/SiteSections/SectionIcon.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export function SectionIcon(props: { icon: IconName; isActive: boolean }) {
1313
<Icon
1414
icon={icon}
1515
className={tcls(
16-
'size-[1em] text-inherit opacity-8',
16+
'size-[1em] shrink-0 text-inherit opacity-8',
1717
isActive && 'text-inherit opacity-10',
1818
)}
1919
/>

packages/gitbook/src/components/SiteSections/SiteSectionList.tsx

Lines changed: 159 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
'use client';
22

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';
56
import React from 'react';
67

78
import { SectionsList } from '@/lib/api';
89
import { ClassValue, tcls } from '@/lib/tailwind';
910

1011
import { Link } from '../primitives';
1112
import { SectionIcon } from './SectionIcon';
12-
import { useIsMounted } from '../hooks';
13+
import { useIsMounted, useToggleAnimation } from '../hooks';
1314
import { TOCScrollContainer, useScrollToActiveTOCItem } from '../TableOfContents/TOCScroller';
1415

1516
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
1920
*/
2021
export function SiteSectionList(props: { sections: SectionsList; className: ClassValue }) {
2122
const {
22-
sections: { list: sections, index: currentIndex },
23+
sections: { list: sectionsAndGroups, current: currentSection },
2324
className,
2425
} = props;
2526

2627
return (
27-
sections.length > 0 && (
28+
sectionsAndGroups.length > 0 && (
2829
<nav
2930
aria-label="Sections"
3031
className={tcls(
@@ -39,21 +40,37 @@ export function SiteSectionList(props: { sections: SectionsList; className: Clas
3940
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
4041
className="overflow-y-auto px-2 pb-6 gutter-stable"
4142
>
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+
})}
4962
</TOCScrollContainer>
5063
</nav>
5164
)
5265
);
5366
}
5467

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;
5774

5875
const isMounted = useIsMounted();
5976
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
7592
? `text-primary hover:text-primary-strong contrast-more:text-primary-strong font-semibold
7693
hover:bg-primary-hover contrast-more:hover:ring-1 contrast-more:hover:ring-primary-hover`
7794
: null,
95+
className,
7896
)}
7997
{...otherProps}
8098
>
8199
<div
82100
className={tcls(
83-
`size-8 flex items-center justify-center
101+
`shrink-0 size-8 flex items-center justify-center
84102
bg-tint-subtle shadow-sm shadow-tint
85103
dark:shadow-none rounded-md straight-corners:rounded-none leading-none
86104
ring-1 ring-tint-subtle
@@ -107,3 +125,129 @@ export function SiteSectionListItem(props: { section: SiteSection; isActive: boo
107125
</Link>
108126
);
109127
}
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

Comments
 (0)