Update TOC to use normal scrollcontainer

This commit is contained in:
Zeno Kapitein
2025-12-11 14:28:26 +01:00
parent 9281b5e3b7
commit dcd03554bd
9 changed files with 28 additions and 144 deletions
@@ -83,7 +83,7 @@ export function AIChat() {
data-testid="ai-chat"
withScrim={true}
className={tcls(
'ai-chat mx-auto not-hydrated:hidden w-96 max-w-full pl-8 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
)}
>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
@@ -226,7 +226,7 @@ export function AIChatBody(props: {
contentClassName="p-4 gutter-stable flex flex-col gap-4"
orientation="vertical"
fadeEdges={['leading']}
active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
>
{isEmpty ? (
<div className="flex grow flex-col">
@@ -40,7 +40,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
orientation="vertical"
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
className="pb-4"
active={currentSection.id}
active={`#${currentSection.id}`}
>
<div className="flex w-full flex-col px-2">
{sectionsAndGroups.map((item) => {
@@ -76,7 +76,7 @@ export function SiteSectionTabs(props: {
? 'md:-mr-8 -mr-4 sm:-mr-6'
: 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors'
)}
active={currentSection.id}
active={`#${currentSection.id}`}
trailingEdgeScrollClassName={children ? 'after:border-tint' : ''}
>
<NavigationMenu.List
@@ -1,91 +0,0 @@
'use client';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
type ComponentPropsWithoutRef,
} from 'react';
import { assert } from 'ts-essentials';
interface TOCScrollContainerContextType {
getContainer: (listener: (element: HTMLDivElement) => void) => () => void;
}
const TOCScrollContainerContext = React.createContext<TOCScrollContainerContextType | null>(null);
function useTOCScrollContainerContext() {
const ctx = React.useContext(TOCScrollContainerContext);
assert(ctx);
return ctx;
}
/**
* Table of contents scroll container.
*/
export function TOCScrollContainer(props: ComponentPropsWithoutRef<'div'>) {
const ref = useRef<HTMLDivElement>(null);
const listeners = useRef<((element: HTMLDivElement) => void)[]>([]);
const getContainer: TOCScrollContainerContextType['getContainer'] = useCallback((listener) => {
if (ref.current) {
listener(ref.current);
return () => {};
}
listeners.current.push(listener);
return () => {
listeners.current = listeners.current.filter((l) => l !== listener);
};
}, []);
const value: TOCScrollContainerContextType = useMemo(() => ({ getContainer }), [getContainer]);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
listeners.current.forEach((listener) => listener(element));
return () => {
listeners.current = [];
};
}, []);
return (
<TOCScrollContainerContext.Provider value={value}>
<div ref={ref} data-testid="toc-scroll-container" {...props} />
</TOCScrollContainerContext.Provider>
);
}
// Offset to scroll the table of contents item by.
const TOC_ITEM_OFFSET = 100;
/**
* Scrolls the table of contents container to the page item when it's initially active.
*/
export function useScrollToActiveTOCItem(props: {
anchorRef: React.RefObject<HTMLAnchorElement | null>;
isActive: boolean;
}) {
const { isActive, anchorRef } = props;
const { getContainer } = useTOCScrollContainerContext();
useEffect(() => {
const anchor = anchorRef.current;
if (isActive && anchor) {
return getContainer((container) => {
if (isOutOfView(anchor, container)) {
container.scrollTo({ top: anchor.offsetTop - TOC_ITEM_OFFSET });
}
});
}
}, [isActive, getContainer, anchorRef]);
}
function isOutOfView(element: HTMLElement, container: HTMLElement) {
const tocItemTop = element.offsetTop;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.clientHeight;
return (
tocItemTop < containerTop + TOC_ITEM_OFFSET ||
tocItemTop > containerBottom - TOC_ITEM_OFFSET
);
}
@@ -3,9 +3,9 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import type React from 'react';
import { tcls } from '@/lib/tailwind';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
import { PagesList } from './PagesList';
import { TOCScrollContainer } from './TOCScroller';
import { TableOfContentsScript } from './TableOfContentsScript';
import { Trademark } from './Trademark';
import { encodeClientTableOfContents } from './encodeClientTableOfContents';
@@ -74,7 +74,7 @@ export async function TableOfContents(props: {
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:page-no-toc:[html[style*="--outline-height"]_&]:top-(--outline-height)!',
'py-6',
'pt-6 pb-4',
'lg:sidebar-filled:pr-6',
'lg:page-no-toc:pr-0',
'max-lg:pl-8',
@@ -106,21 +106,24 @@ export async function TableOfContents(props: {
)}
>
{innerHeader ? innerHeader : null}
<TOCScrollContainer // The scrollview inside the sidebar
className="hide-scrollbar gutter-stable flex grow flex-col overflow-y-auto p-2"
<ScrollContainer
fadeEdges={['trailing']}
orientation="vertical"
contentClassName="flex grow flex-col p-2 gutter-stable"
active="[data-active=true]"
>
<PagesList
pages={pages}
isRoot={true}
style="mb-5 page-no-toc:hidden grow border-tint-subtle sidebar-list-line:border-l"
style="page-no-toc:hidden grow border-tint-subtle sidebar-list-line:border-l"
/>
{customization.trademark.enabled ? (
<Trademark
context={context}
placement={SiteInsightsTrademarkPlacement.Sidebar}
/>
) : null}
</TOCScrollContainer>
</ScrollContainer>
{customization.trademark.enabled ? (
<Trademark
context={context}
placement={SiteInsightsTrademarkPlacement.Sidebar}
/>
) : null}
</div>
</SideSheet>
<TableOfContentsScript />
@@ -8,7 +8,6 @@ import { tcls } from '@/lib/tailwind';
import { useCurrentPagePath } from '../hooks';
import { Link, type LinkInsightsProps, type LinkProps } from '../primitives';
import { useScrollToActiveTOCItem } from './TOCScroller';
/**
* Client component for a page document to toggle its children and be marked as active.
@@ -81,8 +80,6 @@ function LinkItem(
}
) {
const { isActive, href, insights, children, onActiveClick } = props;
const anchorRef = useRef<HTMLAnchorElement>(null);
useScrollToActiveTOCItem({ anchorRef, isActive });
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (isActive && onActiveClick) {
@@ -93,7 +90,7 @@ function LinkItem(
return (
<Link
ref={anchorRef}
data-active={isActive}
href={href}
insights={insights}
aria-current={isActive ? 'page' : undefined}
@@ -17,33 +17,7 @@ export function Trademark(props: {
}) {
const { className, ...rest } = props;
return (
<div
className={tcls(
'sticky',
'z-2',
'mt-auto',
'inset-x-0',
'-bottom-2',
'-mb-2',
'sidebar-filled:pb-2',
'page-no-toc:pb-0',
'pointer-events-none',
'bg-tint-base',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'lg:page-no-toc:bg-transparent',
'rounded-lg',
'straight-corners:rounded-none',
'circular-corners:rounded-2xl',
className
)}
>
<div className={tcls('mt-auto pr-4 pb-2 pl-2', className)}>
<TrademarkLink {...rest} />
</div>
);
@@ -1,4 +1,3 @@
export { TableOfContents } from './TableOfContents';
export { PagesList } from './PagesList';
export { TOCScrollContainer } from './TOCScroller';
export { Trademark } from './Trademark';
@@ -102,7 +102,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
return;
}
const activeItem =
typeof active === 'string' ? document.getElementById(active) : active.current;
typeof active === 'string'
? containerRef.current?.querySelector(active)
: active.current;
if (!activeItem || !container.contains(activeItem)) {
return;
}
@@ -180,7 +182,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
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]',
'absolute z-10 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'
@@ -199,7 +201,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
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]',
'absolute z-10 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'
@@ -214,7 +216,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
/**
* Scroll to an element in a container.
*/
function scrollToElementInContainer(element: HTMLElement, container: HTMLElement) {
function scrollToElementInContainer(element: Element, container: HTMLElement) {
const containerRect = container.getBoundingClientRect();
const rect = element.getBoundingClientRect();
@@ -229,6 +231,6 @@ function scrollToElementInContainer(element: HTMLElement, container: HTMLElement
(rect.left - containerRect.left) -
container.clientWidth / 2 +
rect.width / 2,
behavior: 'smooth',
behavior: 'auto',
});
}