Skip to content

Commit b8388e5

Browse files
authored
Add ScrollContainer component, use for section tabs (#3661)
1 parent 2bce127 commit b8388e5

File tree

14 files changed

+240
-33
lines changed

14 files changed

+240
-33
lines changed

.changeset/slimy-points-talk.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 scrollcontainer component

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

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ import { motion } from 'framer-motion';
55
import React from 'react';
66

77
import { type ClassValue, tcls } from '@/lib/tailwind';
8-
9-
import { TOCScrollContainer, useScrollToActiveTOCItem } from '../TableOfContents/TOCScroller';
10-
import { useIsMounted, useToggleAnimation } from '../hooks';
8+
import { useToggleAnimation } from '../hooks';
119
import { Link } from '../primitives';
10+
import { ScrollContainer } from '../primitives/ScrollContainer';
1211
import { SectionIcon } from './SectionIcon';
1312
import type {
1413
ClientSiteSection,
@@ -36,30 +35,34 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
3635
className
3736
)}
3837
>
39-
<TOCScrollContainer
38+
<ScrollContainer
39+
orientation="vertical"
4040
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
41-
className="overflow-y-auto px-2 pb-4"
41+
className="pb-4"
42+
activeId={currentSection.id}
4243
>
43-
{sectionsAndGroups.map((item) => {
44-
if (item.object === 'site-section-group') {
44+
<div className="flex w-full flex-col px-2">
45+
{sectionsAndGroups.map((item) => {
46+
if (item.object === 'site-section-group') {
47+
return (
48+
<SiteSectionGroupItem
49+
key={item.id}
50+
group={item}
51+
currentSection={currentSection}
52+
/>
53+
);
54+
}
55+
4556
return (
46-
<SiteSectionGroupItem
57+
<SiteSectionListItem
58+
section={item}
59+
isActive={item.id === currentSection.id}
4760
key={item.id}
48-
group={item}
49-
currentSection={currentSection}
5061
/>
5162
);
52-
}
53-
54-
return (
55-
<SiteSectionListItem
56-
section={item}
57-
isActive={item.id === currentSection.id}
58-
key={item.id}
59-
/>
60-
);
61-
})}
62-
</TOCScrollContainer>
63+
})}
64+
</div>
65+
</ScrollContainer>
6366
</nav>
6467
)
6568
);
@@ -72,17 +75,11 @@ export function SiteSectionListItem(props: {
7275
}) {
7376
const { section, isActive, className, ...otherProps } = props;
7477

75-
const isMounted = useIsMounted();
76-
React.useEffect(() => {}, [isMounted]); // This updates the useScrollToActiveTOCItem hook once we're mounted, so we can actually scroll to the this item
77-
78-
const anchorRef = React.createRef<HTMLAnchorElement>();
79-
useScrollToActiveTOCItem({ anchorRef, isActive });
80-
8178
return (
8279
<Link
83-
ref={anchorRef}
8480
href={section.url}
8581
aria-current={isActive && 'page'}
82+
id={section.id}
8683
className={tcls(
8784
'group/section-link',
8885
'flex',

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Button, DropdownChevron, Link } from '@/components/primitives';
88
import { tcls } from '@/lib/tailwind';
99
import { useIsMobile } from '../hooks/useIsMobile';
1010
import { CONTAINER_STYLE } from '../layout';
11+
import { ScrollContainer } from '../primitives/ScrollContainer';
1112
import { SectionIcon } from './SectionIcon';
1213
import type { ClientSiteSection, ClientSiteSections } from './encodeClientSiteSections';
1314

@@ -57,14 +58,21 @@ export function SiteSectionTabs(props: {
5758
onValueChange={setValue}
5859
skipDelayDuration={500}
5960
>
60-
<div
61+
<ScrollContainer
62+
orientation="horizontal"
6163
className={tcls(
62-
'md:-ml-8 -ml-4 sm:-ml-6 no-scrollbar relative flex grow list-none items-end overflow-x-auto pl-4 sm:pl-6 md:pl-8',
63-
!children ? 'md:-mr-8 -mr-4 sm:-mr-6 pr-4 sm:pr-6 md:pr-8' : ''
64+
'grow',
65+
'md:-ml-8 -ml-4 sm:-ml-6',
66+
!children ? 'md:-mr-8 -mr-4 sm:-mr-6' : ''
6467
)}
68+
activeId={currentSection.id}
6569
>
6670
<NavigationMenu.List
67-
className="-mx-3 flex grow gap-2 bg-transparent"
71+
className={tcls(
72+
'-mx-3 flex grow gap-2 bg-transparent',
73+
'pl-4 sm:pl-6 md:pl-8',
74+
!children ? 'pr-4 sm:pr-6 md:pr-8' : ''
75+
)}
6876
aria-label="Sections"
6977
id="sections"
7078
>
@@ -123,7 +131,7 @@ export function SiteSectionTabs(props: {
123131
);
124132
})}
125133
</NavigationMenu.List>
126-
</div>
134+
</ScrollContainer>
127135

128136
{children}
129137

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
'use client';
2+
3+
import { tString, useLanguage } from '@/intl/client';
4+
import { tcls } from '@/lib/tailwind';
5+
import * as React from 'react';
6+
import { Button } from './Button';
7+
8+
/**
9+
* A container that encapsulates a scrollable area with usability features.
10+
* - Faded edges when there is more content than the container can display.
11+
* - Buttons to advance the scroll position.
12+
* - Auto-scroll to the active item when it's initially active.
13+
*/
14+
export type ScrollContainerProps = {
15+
children: React.ReactNode;
16+
className?: string;
17+
18+
/** The direction of the scroll container. */
19+
orientation: 'horizontal' | 'vertical';
20+
21+
/** The ID of the active item to scroll to. */
22+
activeId?: string;
23+
} & React.HTMLAttributes<HTMLDivElement>;
24+
25+
export function ScrollContainer(props: ScrollContainerProps) {
26+
const { children, className, orientation, activeId, ...rest } = props;
27+
28+
const containerRef = React.useRef<HTMLDivElement>(null);
29+
30+
const [scrollPosition, setScrollPosition] = React.useState(0);
31+
const [scrollSize, setScrollSize] = React.useState(0);
32+
33+
const language = useLanguage();
34+
35+
React.useEffect(() => {
36+
const container = containerRef.current;
37+
if (!container) {
38+
return;
39+
}
40+
41+
// Update scroll position on scroll using requestAnimationFrame
42+
const scrollListener: EventListener = () => {
43+
requestAnimationFrame(() => {
44+
setScrollPosition(
45+
orientation === 'horizontal' ? container.scrollLeft : container.scrollTop
46+
);
47+
});
48+
};
49+
container.addEventListener('scroll', scrollListener);
50+
51+
// Update max scroll position using resize observer
52+
const resizeObserver = new ResizeObserver((entries) => {
53+
const containerEntry = entries.find((i) => i.target === containerRef.current);
54+
if (containerEntry) {
55+
setScrollSize(
56+
orientation === 'horizontal'
57+
? containerEntry.target.scrollWidth - containerEntry.target.clientWidth - 1
58+
: containerEntry.target.scrollHeight -
59+
containerEntry.target.clientHeight -
60+
1
61+
);
62+
}
63+
});
64+
resizeObserver.observe(container);
65+
66+
return () => {
67+
container.removeEventListener('scroll', scrollListener);
68+
resizeObserver.disconnect();
69+
};
70+
}, [orientation]);
71+
72+
// Scroll to the active item
73+
React.useEffect(() => {
74+
const container = containerRef.current;
75+
if (!container || !activeId) {
76+
return;
77+
}
78+
const activeItem = container.querySelector(`#${CSS.escape(activeId)}`);
79+
if (activeItem) {
80+
activeItem.scrollIntoView({
81+
inline: 'center',
82+
block: 'center',
83+
});
84+
}
85+
}, [activeId]);
86+
87+
const scrollFurther = () => {
88+
const container = containerRef.current;
89+
if (!container) {
90+
return;
91+
}
92+
container.scrollTo({
93+
top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined,
94+
left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined,
95+
behavior: 'smooth',
96+
});
97+
};
98+
99+
const scrollBack = () => {
100+
const container = containerRef.current;
101+
if (!container) {
102+
return;
103+
}
104+
container.scrollTo({
105+
top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined,
106+
left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined,
107+
behavior: 'smooth',
108+
});
109+
};
110+
111+
return (
112+
<div
113+
className={tcls('group/scroll-container relative flex overflow-hidden', className)}
114+
{...rest}
115+
>
116+
{/* Scrollable content */}
117+
<div
118+
className={tcls(
119+
'flex shrink grow',
120+
orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar',
121+
orientation === 'horizontal' ? 'overflow-x-scroll' : 'overflow-y-auto',
122+
scrollPosition > 0
123+
? orientation === 'horizontal'
124+
? 'mask-l-from-[calc(100%-2rem)]'
125+
: 'mask-t-from-[calc(100%-2rem)]'
126+
: '',
127+
scrollPosition < scrollSize
128+
? orientation === 'horizontal'
129+
? 'mask-r-from-[calc(100%-2rem)]'
130+
: 'mask-b-from-[calc(100%-2rem)]'
131+
: ''
132+
)}
133+
ref={containerRef}
134+
>
135+
{children}
136+
</div>
137+
138+
{/* Scroll buttons back & forward */}
139+
<Button
140+
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
141+
iconOnly
142+
size="xsmall"
143+
variant="secondary"
144+
tabIndex={-1}
145+
className={tcls(
146+
orientation === 'horizontal'
147+
? '-translate-y-1/2! top-1/2 left-0 ml-2'
148+
: '-translate-x-1/2! top-0 left-1/2 mt-2',
149+
'absolute not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
150+
scrollPosition > 0
151+
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
152+
: 'pointer-events-none'
153+
)}
154+
onClick={scrollBack}
155+
label={tString(language, 'scroll_back')}
156+
/>
157+
<Button
158+
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
159+
iconOnly
160+
size="xsmall"
161+
variant="secondary"
162+
tabIndex={-1}
163+
className={tcls(
164+
orientation === 'horizontal'
165+
? '-translate-y-1/2! top-1/2 right-0 mr-2'
166+
: '-translate-x-1/2! bottom-0 left-1/2 mb-2',
167+
'absolute not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
168+
scrollPosition < scrollSize
169+
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
170+
: 'pointer-events-none'
171+
)}
172+
onClick={scrollFurther}
173+
label={tString(language, 'scroll_further')}
174+
/>
175+
</div>
176+
);
177+
}

packages/gitbook/src/intl/translations/de.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,6 @@ export const de = {
120120
copy_mcp_url: 'MCP-Server-URL kopieren',
121121
press_to_confirm: 'Drücke ${1} zum Bestätigen',
122122
tool_call_skipped: 'Übersprungen "${1}"',
123+
scroll_back: 'Zurück scrollen',
124+
scroll_further: 'Weiter scrollen',
123125
};

packages/gitbook/src/intl/translations/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,6 @@ export const en = {
117117
copy_mcp_url: 'Copy the MCP Server URL',
118118
press_to_confirm: 'Press ${1} to confirm',
119119
tool_call_skipped: 'Skipped "${1}"',
120+
scroll_back: 'Scroll back',
121+
scroll_further: 'Scroll further',
120122
};

packages/gitbook/src/intl/translations/es.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,6 @@ export const es: TranslationLanguage = {
121121
copy_mcp_url: 'Copiar URL del servidor MCP',
122122
press_to_confirm: 'Presiona ${1} para confirmar',
123123
tool_call_skipped: 'Omitido "${1}"',
124+
scroll_back: 'Desplazar hacia atrás',
125+
scroll_further: 'Desplazar más',
124126
};

packages/gitbook/src/intl/translations/fr.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,6 @@ export const fr = {
116116
copy_mcp_url: "Copier l'URL du serveur MCP",
117117
press_to_confirm: 'Appuyez sur ${1} pour confirmer',
118118
tool_call_skipped: 'Ignoré "${1}"',
119+
scroll_back: "Défiler vers l'arrière",
120+
scroll_further: 'Défiler plus loin',
119121
};

packages/gitbook/src/intl/translations/ja.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,6 @@ export const ja: TranslationLanguage = {
119119
copy_mcp_url: 'MCPサーバーのURLをコピー',
120120
press_to_confirm: '確認するには${1}を押してください',
121121
tool_call_skipped: '"${1}" をスキップしました',
122+
scroll_back: '戻る',
123+
scroll_further: '進む',
122124
};

packages/gitbook/src/intl/translations/nl.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,6 @@ export const nl: TranslationLanguage = {
119119
copy_mcp_url: 'Kopieer MCP-server URL',
120120
press_to_confirm: 'Druk op ${1} om te bevestigen',
121121
tool_call_skipped: '"${1}" overgeslagen',
122+
scroll_back: 'Terug scrollen',
123+
scroll_further: 'Verder scrollen',
122124
};

0 commit comments

Comments
 (0)