Skip to content

Commit 6bd522b

Browse files
authored
Update site section tabs with better column calc and animations (#4252)
1 parent 74fc3f7 commit 6bd522b

1 file changed

Lines changed: 124 additions & 60 deletions

File tree

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

Lines changed: 124 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@ import type {
1717
ClientSiteSections,
1818
} from './encodeClientSiteSections';
1919

20+
const DESKTOP_BREAKPOINT = 768;
2021
const SCREEN_OFFSET = 16; // 1rem
2122
const MAX_ITEMS_PER_COLUMN = 10; // number of items per column
22-
const GROUP_MASONRY_THRESHOLD = 6; // if a section group has more than this many child groups, it will be shown in a masonry grid
23+
const GROUP_MASONRY_THRESHOLD = 3; // if a section group has more than this many child groups, it will be shown in a masonry grid
24+
const COLUMN_WIDTH = '18rem';
25+
const COLUMN_GAP = '2rem';
26+
const MAX_MASONRY_COLUMNS = 4;
2327

2428
/**
2529
* A set of navigational links representing site sections for multi-section sites
@@ -37,24 +41,19 @@ export function SiteSectionTabs(props: {
3741

3842
const containerRef = React.useRef<HTMLDivElement>(null);
3943
const currentTriggerRef = React.useRef<HTMLButtonElement | null>(null);
40-
const [offset, setOffset] = React.useState<number | null>(null);
4144
const [value, setValue] = React.useState<string | undefined>();
4245

43-
const isMobile = useIsMobile(768);
44-
45-
React.useEffect(() => {
46-
const trigger = currentTriggerRef.current;
47-
const container = containerRef.current;
48-
if (!value || !trigger || !container) {
49-
return;
50-
}
51-
52-
const triggerWidth = trigger.getBoundingClientRect().width - SCREEN_OFFSET;
53-
const triggerLeft =
54-
trigger.getBoundingClientRect().left -
55-
(window.innerWidth - container.getBoundingClientRect().width) / 2;
56-
setOffset(triggerLeft + triggerWidth / 2);
57-
}, [value]);
46+
const isMobile = useIsMobile(DESKTOP_BREAKPOINT);
47+
const offset = useNavigationMenuViewportOffset({
48+
value,
49+
isMobile,
50+
triggerRef: currentTriggerRef,
51+
containerRef,
52+
});
53+
const viewportLeft =
54+
!isMobile && offset !== null
55+
? `clamp(0px, calc(${offset - SCREEN_OFFSET}px - var(--radix-navigation-menu-viewport-width, 0px)/2), calc(100% - var(--radix-navigation-menu-viewport-width, 0px)))`
56+
: '0px';
5857

5958
return structure.length > 0 ? (
6059
<NavigationMenu.Root
@@ -65,6 +64,12 @@ export function SiteSectionTabs(props: {
6564
className
6665
)}
6766
ref={containerRef}
67+
style={
68+
{
69+
'--site-section-column-width': COLUMN_WIDTH,
70+
'--site-section-column-gap': COLUMN_GAP,
71+
} as React.CSSProperties
72+
}
6873
value={value}
6974
onValueChange={setValue}
7075
skipDelayDuration={500}
@@ -122,11 +127,22 @@ export function SiteSectionTabs(props: {
122127
icon={icon as IconName}
123128
/>
124129
</NavigationMenu.Trigger>
125-
<NavigationMenu.Content>
126-
<SectionGroupTileList
127-
items={structureItem.children}
128-
currentSection={currentSection}
129-
/>
130+
<NavigationMenu.Content
131+
className={tcls([
132+
'absolute top-0 left-0 w-full md:w-auto',
133+
'data-[motion=from-start]:*:animate-[enterFromLeft_300ms_ease_both] data-[motion=to-end]:*:animate-[exitToRight_300ms_ease_both] data-[motion=to-start]:*:animate-[exitToLeft_300ms_ease_both] motion-safe:data-[motion=from-end]:*:animate-[enterFromRight_300ms_ease_both]',
134+
])}
135+
>
136+
<div
137+
className={tcls(
138+
'max-h-[calc(100vh-8rem)] w-full overflow-y-auto overflow-x-hidden'
139+
)}
140+
>
141+
<SectionGroupTileList
142+
items={structureItem.children}
143+
currentSection={currentSection}
144+
/>
145+
</div>
130146
</NavigationMenu.Content>
131147
</>
132148
) : (
@@ -153,30 +169,60 @@ export function SiteSectionTabs(props: {
153169

154170
<div
155171
className="absolute top-full left-0 z-20 flex w-full"
156-
style={{
157-
padding: `0 ${SCREEN_OFFSET}px 0 ${SCREEN_OFFSET}px`,
158-
}}
172+
style={{ paddingInline: `${SCREEN_OFFSET}px` }}
159173
>
160174
<NavigationMenu.Viewport
161175
className={tcls(
162-
'relative origin-top overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg ease-in-out',
163-
'-mt-0.5 w-full md:w-max',
176+
'relative origin-[center_top] overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg',
177+
'-mt-0.5 h-(--radix-navigation-menu-viewport-height) w-full max-w-full md:w-(--radix-navigation-menu-viewport-width)',
164178
'max-h-[calc(100vh-8rem)] data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in',
165-
"[&:not([style*='--radix-navigation-menu-viewport-width'])]:hidden" // The viewport width is only calculated once it's triggered, and can take a while. We hide the viewport until it's ready.
179+
'ease has-[&[data-motion]]:transition-[left,width,height] has-[&[data-motion]]:duration-300'
166180
)}
167181
style={{
168-
translate:
169-
!isMobile && offset
170-
? `clamp(0px, calc(${offset}px - var(--radix-navigation-menu-viewport-width, 0px)/2), calc(100vw - var(--radix-navigation-menu-viewport-width, 0px) - ${SCREEN_OFFSET * 3}px)) 0 0`
171-
: '0 0 0', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari
172-
display: offset === null ? 'none' : undefined,
182+
left: viewportLeft,
183+
translate: '0 0 0', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari
173184
}}
174185
/>
175186
</div>
176187
</NavigationMenu.Root>
177188
) : null;
178189
}
179190

191+
function useNavigationMenuViewportOffset(args: {
192+
value: string | undefined;
193+
isMobile: boolean;
194+
triggerRef: React.RefObject<HTMLButtonElement | null>;
195+
containerRef: React.RefObject<HTMLDivElement | null>;
196+
}) {
197+
const { value, isMobile, triggerRef, containerRef } = args;
198+
const [offset, setOffset] = React.useState<number | null>(null);
199+
200+
React.useLayoutEffect(() => {
201+
if (isMobile) {
202+
setOffset(null);
203+
return;
204+
}
205+
206+
if (!value) {
207+
return;
208+
}
209+
210+
const trigger = triggerRef.current;
211+
const container = containerRef.current;
212+
if (!trigger || !container) {
213+
return;
214+
}
215+
216+
const containerLeft = container.getBoundingClientRect().left;
217+
const triggerWidth = trigger.getBoundingClientRect().width;
218+
const triggerLeft = trigger.getBoundingClientRect().left - containerLeft;
219+
220+
setOffset(triggerLeft + triggerWidth / 2);
221+
}, [containerRef, isMobile, triggerRef, value]);
222+
223+
return offset;
224+
}
225+
180226
/**
181227
* A tab representing a section or section group
182228
*/
@@ -222,14 +268,16 @@ function SectionGroupTileList(props: {
222268

223269
const hasSections = sections.length > 0;
224270
const hasGroups = groups.length > 0;
271+
const isMasonryLayout = groups.length > GROUP_MASONRY_THRESHOLD;
272+
const masonryColumnCount = Math.min(Math.ceil(groups.length / 2), MAX_MASONRY_COLUMNS);
225273

226274
return (
227-
<div className="flex flex-col md:flex-row">
275+
<div className="flex w-full flex-col md:flex-row">
228276
{/* Non-grouped sections */}
229277
{hasSections && (
230278
<ul
231279
className={tcls(
232-
'flex min-w-48 shrink-0 grid-flow-row flex-col gap-x-2 gap-y-1 self-start p-3 md:grid',
280+
'flex w-full shrink-0 grid-flow-row flex-col gap-x-2 gap-y-0.5 self-stretch p-3 md:sticky md:top-0 md:grid md:w-max md:self-start',
233281
hasGroups ? 'bg-tint-base' : ''
234282
)}
235283
style={{
@@ -248,25 +296,38 @@ function SectionGroupTileList(props: {
248296

249297
{/* Grouped sections */}
250298
{hasGroups && (
251-
<ul
299+
<div
252300
className={tcls(
253-
'grow gap-x-8 space-y-8 p-3',
254-
groups.length > GROUP_MASONRY_THRESHOLD
255-
? 'w-screen md:columns-[15rem]'
256-
: 'flex flex-col justify-start md:flex-row md:items-start',
301+
'w-full md:w-max md:min-w-0 md:max-w-full',
257302
hasSections
258303
? 'border-tint-subtle bg-tint-subtle max-md:border-t md:border-l'
259304
: ''
260305
)}
261306
>
262-
{groups.map((group) => (
263-
<SectionGroupTile
264-
key={group.id}
265-
child={group}
266-
currentSection={currentSection}
267-
/>
268-
))}
269-
</ul>
307+
<ul
308+
className={tcls(
309+
'p-3',
310+
isMasonryLayout
311+
? 'w-full max-md:space-y-8 md:w-max md:max-w-full md:gap-x-[var(--site-section-column-gap)] md:[column-count:var(--masonry-columns)] md:[&>li]:mb-4'
312+
: 'flex w-full flex-col justify-start space-y-8 md:w-max md:flex-row md:items-start md:gap-[var(--site-section-column-gap)] md:space-y-0'
313+
)}
314+
style={
315+
isMasonryLayout
316+
? ({
317+
'--masonry-columns': String(masonryColumnCount),
318+
} as React.CSSProperties)
319+
: undefined
320+
}
321+
>
322+
{groups.map((group) => (
323+
<SectionGroupTile
324+
key={group.id}
325+
child={group}
326+
currentSection={currentSection}
327+
/>
328+
))}
329+
</ul>
330+
</div>
270331
)}
271332
</div>
272333
);
@@ -278,38 +339,40 @@ function SectionGroupTileList(props: {
278339
function SectionGroupTile(props: {
279340
child: ClientSiteSection | ClientSiteSectionGroup;
280341
currentSection: ClientSiteSection;
342+
invertIcon?: boolean;
281343
}) {
282-
const { child, currentSection } = props;
344+
const { child, currentSection, invertIcon } = props;
283345

284346
if (child.object === 'site-section') {
285347
const { url, icon, title, description } = child;
286348
const isActive = child.id === currentSection.id;
287349
return (
288-
<li className="group/section-tile flex shrink-0 grow md:max-w-68">
350+
<li className="group/section-tile flex w-full min-w-0 shrink-0 grow md:max-w-[var(--site-section-column-width)]">
289351
<Link
290352
href={url}
291353
className={tcls(
292-
'grow circular-corners:rounded-2xl rounded-corners:rounded-lg px-3 py-2 transition-colors',
354+
'grow circular-corners:rounded-2xl rounded-corners:rounded-lg px-2.5 py-1.5 transition-colors',
293355
isActive
294356
? 'bg-primary-active text-primary-strong'
295357
: 'text-tint-strong hover:bg-tint-hover'
296358
)}
297359
>
298-
<div className="mb-auto flex grow items-center gap-2">
360+
<div className="mb-auto flex min-w-0 grow items-center gap-2">
299361
{icon && (
300362
<div
301363
className={tcls(
302364
'-ml-1 self-start circular-corners:rounded-2xl rounded-corners:rounded-lg p-2 transition-colors',
365+
isActive || invertIcon ? 'bg-primary-base' : 'bg-tint',
303366
isActive
304-
? 'bg-primary-base text-primary-subtle'
305-
: 'bg-tint text-tint-strong group-hover/section-tile:bg-tint-base'
367+
? 'text-primary-subtle'
368+
: 'text-tint-strong group-hover/section-tile:bg-tint-base'
306369
)}
307370
>
308371
<SectionIcon isActive={isActive} icon={icon as IconName} />
309372
</div>
310373
)}
311-
<div className="flex flex-col gap-1">
312-
{title}
374+
<div className="flex min-w-0 flex-col gap-0.5">
375+
<span className="block min-w-0 whitespace-normal">{title}</span>
313376
{description && (
314377
<p className={isActive ? 'text-primary' : 'text-tint'}>
315378
{description}
@@ -326,15 +389,15 @@ function SectionGroupTile(props: {
326389
const { title, icon, children } = child;
327390

328391
return (
329-
<li className="flex shrink-0 break-inside-avoid flex-col gap-1">
330-
<div className="mt-3 mb-1 flex gap-2.5 px-3 font-semibold text-tint-subtle text-xs uppercase tracking-wider">
392+
<li className="flex w-full min-w-0 shrink-0 break-inside-avoid flex-col gap-1 md:w-auto">
393+
<div className="mt-2 mb-1 flex min-w-0 gap-2 px-2.5 font-semibold text-tint-subtle text-xs">
331394
{icon && (
332395
<SectionIcon className="mt-0.5" isActive={false} icon={icon as IconName} />
333396
)}
334-
{title}
397+
<span className="min-w-0 flex-1 whitespace-normal">{title}</span>
335398
</div>
336399
<ul
337-
className="flex grid-flow-row flex-col gap-x-2 gap-y-1 md:grid"
400+
className="flex w-full grid-flow-row flex-col gap-x-2 gap-y-0.5 md:grid"
338401
style={{
339402
gridTemplateColumns: `repeat(${Math.ceil(children.length / MAX_ITEMS_PER_COLUMN)}, minmax(0, auto))`,
340403
}}
@@ -344,6 +407,7 @@ function SectionGroupTile(props: {
344407
key={nestedChild.id}
345408
child={nestedChild}
346409
currentSection={currentSection}
410+
invertIcon={true}
347411
/>
348412
))}
349413
</ul>

0 commit comments

Comments
 (0)