Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slimy-points-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gitbook": patch
---

Add scrollcontainer component
53 changes: 25 additions & 28 deletions packages/gitbook/src/components/SiteSections/SiteSectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import { motion } from 'framer-motion';
import React from 'react';

import { type ClassValue, tcls } from '@/lib/tailwind';

import { TOCScrollContainer, useScrollToActiveTOCItem } from '../TableOfContents/TOCScroller';
import { useIsMounted, useToggleAnimation } from '../hooks';
import { useToggleAnimation } from '../hooks';
import { Link } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SectionIcon } from './SectionIcon';
import type {
ClientSiteSection,
Expand Down Expand Up @@ -36,30 +35,34 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
className
)}
>
<TOCScrollContainer
<ScrollContainer
orientation="vertical"
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
className="overflow-y-auto px-2 pb-4"
className="pb-4"
activeId={currentSection.id}
>
{sectionsAndGroups.map((item) => {
if (item.object === 'site-section-group') {
<div className="flex w-full flex-col px-2">
{sectionsAndGroups.map((item) => {
if (item.object === 'site-section-group') {
return (
<SiteSectionGroupItem
key={item.id}
group={item}
currentSection={currentSection}
/>
);
}

return (
<SiteSectionGroupItem
<SiteSectionListItem
section={item}
isActive={item.id === currentSection.id}
key={item.id}
group={item}
currentSection={currentSection}
/>
);
}

return (
<SiteSectionListItem
section={item}
isActive={item.id === currentSection.id}
key={item.id}
/>
);
})}
</TOCScrollContainer>
})}
</div>
</ScrollContainer>
</nav>
)
);
Expand All @@ -72,17 +75,11 @@ export function SiteSectionListItem(props: {
}) {
const { section, isActive, className, ...otherProps } = props;

const isMounted = useIsMounted();
React.useEffect(() => {}, [isMounted]); // This updates the useScrollToActiveTOCItem hook once we're mounted, so we can actually scroll to the this item

const anchorRef = React.createRef<HTMLAnchorElement>();
useScrollToActiveTOCItem({ anchorRef, isActive });

return (
<Link
ref={anchorRef}
href={section.url}
aria-current={isActive && 'page'}
id={section.id}
className={tcls(
'group/section-link',
'flex',
Expand Down
18 changes: 13 additions & 5 deletions packages/gitbook/src/components/SiteSections/SiteSectionTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Button, DropdownChevron, Link } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import { useIsMobile } from '../hooks/useIsMobile';
import { CONTAINER_STYLE } from '../layout';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SectionIcon } from './SectionIcon';
import type { ClientSiteSection, ClientSiteSections } from './encodeClientSiteSections';

Expand Down Expand Up @@ -57,14 +58,21 @@ export function SiteSectionTabs(props: {
onValueChange={setValue}
skipDelayDuration={500}
>
<div
<ScrollContainer
orientation="horizontal"
className={tcls(
'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',
!children ? 'md:-mr-8 -mr-4 sm:-mr-6 pr-4 sm:pr-6 md:pr-8' : ''
'grow',
'md:-ml-8 -ml-4 sm:-ml-6',
!children ? 'md:-mr-8 -mr-4 sm:-mr-6' : ''
)}
activeId={currentSection.id}
>
<NavigationMenu.List
className="-mx-3 flex grow gap-2 bg-transparent"
className={tcls(
'-mx-3 flex grow gap-2 bg-transparent',
'pl-4 sm:pl-6 md:pl-8',
!children ? 'pr-4 sm:pr-6 md:pr-8' : ''
)}
aria-label="Sections"
id="sections"
>
Expand Down Expand Up @@ -123,7 +131,7 @@ export function SiteSectionTabs(props: {
);
})}
</NavigationMenu.List>
</div>
</ScrollContainer>

{children}

Expand Down
177 changes: 177 additions & 0 deletions packages/gitbook/src/components/primitives/ScrollContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
'use client';

import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { Button } from './Button';

/**
* A container that encapsulates a scrollable area with usability features.
* - Faded edges when there is more content than the container can display.
* - Buttons to advance the scroll position.
* - Auto-scroll to the active item when it's initially active.
*/
export type ScrollContainerProps = {
children: React.ReactNode;
className?: string;

/** The direction of the scroll container. */
orientation: 'horizontal' | 'vertical';

/** The ID of the active item to scroll to. */
activeId?: string;
} & React.HTMLAttributes<HTMLDivElement>;

export function ScrollContainer(props: ScrollContainerProps) {
const { children, className, orientation, activeId, ...rest } = props;

const containerRef = React.useRef<HTMLDivElement>(null);

const [scrollPosition, setScrollPosition] = React.useState(0);
const [scrollSize, setScrollSize] = React.useState(0);

const language = useLanguage();

React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}

// Update scroll position on scroll using requestAnimationFrame
const scrollListener: EventListener = () => {
requestAnimationFrame(() => {
setScrollPosition(
orientation === 'horizontal' ? container.scrollLeft : container.scrollTop
);
});
};
container.addEventListener('scroll', scrollListener);

// Update max scroll position using resize observer
const resizeObserver = new ResizeObserver((entries) => {
const containerEntry = entries.find((i) => i.target === containerRef.current);
if (containerEntry) {
setScrollSize(
orientation === 'horizontal'
? containerEntry.target.scrollWidth - containerEntry.target.clientWidth - 1
: containerEntry.target.scrollHeight -
containerEntry.target.clientHeight -
1
);
}
});
resizeObserver.observe(container);

return () => {
container.removeEventListener('scroll', scrollListener);
resizeObserver.disconnect();
};
}, [orientation]);

// Scroll to the active item
React.useEffect(() => {
const container = containerRef.current;
if (!container || !activeId) {
return;
}
const activeItem = container.querySelector(`#${CSS.escape(activeId)}`);
if (activeItem) {
activeItem.scrollIntoView({
inline: 'center',
block: 'center',
});
}
}, [activeId]);

const scrollFurther = () => {
const container = containerRef.current;
if (!container) {
return;
}
container.scrollTo({
top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined,
left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined,
behavior: 'smooth',
});
};

const scrollBack = () => {
const container = containerRef.current;
if (!container) {
return;
}
container.scrollTo({
top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined,
left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined,
behavior: 'smooth',
});
};

return (
<div
className={tcls('group/scroll-container relative flex overflow-hidden', className)}
{...rest}
>
{/* Scrollable content */}
<div
className={tcls(
'flex shrink grow',
orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar',
orientation === 'horizontal' ? 'overflow-x-scroll' : 'overflow-y-auto',
scrollPosition > 0
? orientation === 'horizontal'
? 'mask-l-from-[calc(100%-2rem)]'
: 'mask-t-from-[calc(100%-2rem)]'
: '',
scrollPosition < scrollSize
? orientation === 'horizontal'
? 'mask-r-from-[calc(100%-2rem)]'
: 'mask-b-from-[calc(100%-2rem)]'
: ''
)}
ref={containerRef}
>
{children}
</div>

{/* Scroll buttons back & forward */}
<Button
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
className={tcls(
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 left-0 ml-2'
: '-translate-x-1/2! top-0 left-1/2 mt-2',
'absolute not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
scrollPosition > 0
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none'
)}
onClick={scrollBack}
label={tString(language, 'scroll_back')}
/>
<Button
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
iconOnly
size="xsmall"
variant="secondary"
tabIndex={-1}
className={tcls(
orientation === 'horizontal'
? '-translate-y-1/2! top-1/2 right-0 mr-2'
: '-translate-x-1/2! bottom-0 left-1/2 mb-2',
'absolute not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
scrollPosition < scrollSize
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
: 'pointer-events-none'
)}
onClick={scrollFurther}
label={tString(language, 'scroll_further')}
/>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,6 @@ export const de = {
copy_mcp_url: 'MCP-Server-URL kopieren',
press_to_confirm: 'Drücke ${1} zum Bestätigen',
tool_call_skipped: 'Übersprungen "${1}"',
scroll_back: 'Zurück scrollen',
scroll_further: 'Weiter scrollen',
};
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,6 @@ export const en = {
copy_mcp_url: 'Copy the MCP Server URL',
press_to_confirm: 'Press ${1} to confirm',
tool_call_skipped: 'Skipped "${1}"',
scroll_back: 'Scroll back',
scroll_further: 'Scroll further',
};
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,6 @@ export const es: TranslationLanguage = {
copy_mcp_url: 'Copiar URL del servidor MCP',
press_to_confirm: 'Presiona ${1} para confirmar',
tool_call_skipped: 'Omitido "${1}"',
scroll_back: 'Desplazar hacia atrás',
scroll_further: 'Desplazar más',
};
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,6 @@ export const fr = {
copy_mcp_url: "Copier l'URL du serveur MCP",
press_to_confirm: 'Appuyez sur ${1} pour confirmer',
tool_call_skipped: 'Ignoré "${1}"',
scroll_back: "Défiler vers l'arrière",
scroll_further: 'Défiler plus loin',
};
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,6 @@ export const ja: TranslationLanguage = {
copy_mcp_url: 'MCPサーバーのURLをコピー',
press_to_confirm: '確認するには${1}を押してください',
tool_call_skipped: '"${1}" をスキップしました',
scroll_back: '戻る',
scroll_further: '進む',
};
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,6 @@ export const nl: TranslationLanguage = {
copy_mcp_url: 'Kopieer MCP-server URL',
press_to_confirm: 'Druk op ${1} om te bevestigen',
tool_call_skipped: '"${1}" overgeslagen',
scroll_back: 'Terug scrollen',
scroll_further: 'Verder scrollen',
};
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/no.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,6 @@ export const no: TranslationLanguage = {
copy_mcp_url: 'Kopier MCP-server URL',
press_to_confirm: 'Trykk ${1} for å bekrefte',
tool_call_skipped: 'Hoppet over "${1}"',
scroll_back: 'Rull tilbake',
scroll_further: 'Rull videre',
};
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/pt-br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,6 @@ export const pt_br = {
copy_mcp_url: 'Copiar URL do servidor MCP',
press_to_confirm: 'Pressione ${1} para confirmar',
tool_call_skipped: 'Pulado "${1}"',
scroll_back: 'Rolar para trás',
scroll_further: 'Rolar para frente',
};
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,6 @@ export const ru = {
copy_mcp_url: 'Скопировать URL MCP-сервера',
press_to_confirm: 'Нажмите ${1} для подтверждения',
tool_call_skipped: 'Пропущен "${1}"',
scroll_back: 'Прокрутить назад',
scroll_further: 'Прокрутить дальше',
};
2 changes: 2 additions & 0 deletions packages/gitbook/src/intl/translations/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,6 @@ export const zh: TranslationLanguage = {
copy_mcp_url: '复制 MCP 服务器 URL',
press_to_confirm: '按 ${1} 确认',
tool_call_skipped: '已跳过 "${1}"',
scroll_back: '向后滚动',
scroll_further: '向前滚动',
};