Dynamic TOC height (#3233)

This commit is contained in:
Zeno Kapitein
2025-05-20 14:13:57 +02:00
committed by GitHub
parent 4b67fe5317
commit 57bb146075
9 changed files with 185 additions and 88 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Make TOC height dynamic based on visible header and footer elements
@@ -25,7 +25,7 @@ export function AnnouncementBanner(props: {
const style = BANNER_STYLES[announcement.style];
return (
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2">
<div id="announcement-banner" className="theme-bold:bg-header-background pt-4 pb-2">
<div className="scroll-nojump">
<div className={tcls('relative', CONTAINER_STYLE)}>
<Tag
@@ -25,6 +25,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
return (
<footer
id="site-footer"
className={tcls(
'border-tint-subtle border-t',
// If the footer only contains a mode toggle, we only show it on smaller screens
@@ -75,7 +75,7 @@ export async function CustomizationRootLayout(props: {
lang={customization.internationalization.locale}
className={tcls(
customization.header.preset === CustomizationHeaderPreset.None
? 'site-header-none'
? null
: 'scroll-pt-[76px]', // Take the sticky header in consideration for the scrolling
customization.styling.corners === CustomizationCorners.Straight
? ' straight-corners'
@@ -161,6 +161,6 @@ html.dark {
color-scheme: dark light;
}
html.announcement-hidden .announcement-banner {
html.announcement-hidden #announcement-banner {
@apply hidden;
}
@@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';
import { PagesList } from './PagesList';
import { TOCScrollContainer } from './TOCScroller';
import { TableOfContentsScript } from './TableOfContentsScript';
import { Trademark } from './Trademark';
export function TableOfContents(props: {
@@ -17,97 +18,107 @@ export function TableOfContents(props: {
const { space, customization, pages } = context;
return (
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
data-testid="table-of-contents"
className={tcls(
'group',
'text-sm',
'grow-0',
'shrink-0',
'basis-full',
'lg:basis-72',
'page-no-toc:lg:basis-56',
'relative',
'z-[1]',
'lg:sticky',
// Without header
'lg:top-0',
'lg:h-screen',
// With header
'site-header:lg:top-16',
'site-header:lg:h-[calc(100vh_-_4rem)]',
// With header and sections
'site-header-sections:lg:top-[6.75rem]',
'site-header-sections:lg:h-[calc(100vh_-_6.75rem)]',
'pt-6',
'pb-4',
'sidebar-filled:lg:pr-6',
'page-no-toc:lg:pr-0',
'hidden',
'navigation-open:!flex',
'lg:flex',
'page-no-toc:lg:hidden',
'page-no-toc:xl:flex',
'site-header-none:page-no-toc:lg:flex',
'flex-col',
'gap-4',
'navigation-open:border-b',
'border-tint-subtle'
)}
>
{header && header}
<div // The actual sidebar, either shown with a filled bg or transparent.
<>
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
data-testid="table-of-contents"
id="table-of-contents"
className={tcls(
'lg:-ms-5',
'relative flex flex-grow flex-col overflow-hidden border-tint-subtle',
'group',
'text-sm',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-filled.theme-gradient_&]:border',
'page-no-toc:!bg-transparent',
'grow-0',
'shrink-0',
'basis-full',
'lg:basis-72',
'page-no-toc:lg:basis-56',
'sidebar-filled:rounded-xl',
'straight-corners:rounded-none'
'relative',
'z-[1]',
'lg:sticky',
// Server-side static positioning
'lg:top-0',
'lg:h-screen',
'announcement:lg:h-[calc(100vh-4.25rem)]',
'site-header:lg:top-16',
'site-header:lg:h-[calc(100vh-4rem)]',
'announcement:site-header:lg:h-[calc(100vh-4rem-4.25rem)]',
'site-header-sections:lg:top-[6.75rem]',
'site-header-sections:lg:h-[calc(100vh-6.75rem)]',
'announcement:site-header-sections:lg:h-[calc(100vh-6.75rem-4.25rem)]',
// Client-side dynamic positioning (CSS vars applied by script)
'[html[style*="--toc-top-offset"]_&]:lg:!top-[var(--toc-top-offset)]',
'[html[style*="--toc-height"]_&]:lg:!h-[var(--toc-height)]',
'pt-6',
'pb-4',
'sidebar-filled:lg:pr-6',
'page-no-toc:lg:pr-0',
'hidden',
'navigation-open:!flex',
'lg:flex',
'page-no-toc:lg:hidden',
'page-no-toc:xl:flex',
'site-header-none:page-no-toc:lg:flex',
'flex-col',
'gap-4',
'navigation-open:border-b',
'border-tint-subtle'
)}
>
{innerHeader && <div className="px-5 *:my-4">{innerHeader}</div>}
<TOCScrollContainer // The scrollview inside the sidebar
{header && header}
<div // The actual sidebar, either shown with a filled bg or transparent.
className={tcls(
'flex flex-grow flex-col p-2',
customization.trademark.enabled && 'lg:pb-20',
'lg:gutter-stable overflow-y-auto',
'[&::-webkit-scrollbar]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:bg-transparent',
'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle',
'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7',
'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8'
'lg:-ms-5',
'relative flex flex-grow flex-col overflow-hidden border-tint-subtle',
'sidebar-filled:bg-tint-subtle',
'theme-muted:bg-tint-subtle',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'[html.sidebar-filled.theme-gradient_&]:border',
'page-no-toc:!bg-transparent',
'sidebar-filled:rounded-xl',
'straight-corners:rounded-none'
)}
>
<PagesList
rootPages={pages}
pages={pages}
context={context}
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
/>
{customization.trademark.enabled ? (
<Trademark
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Sidebar}
{innerHeader && <div className="px-5 *:my-4">{innerHeader}</div>}
<TOCScrollContainer // The scrollview inside the sidebar
className={tcls(
'flex flex-grow flex-col p-2',
customization.trademark.enabled && 'lg:pb-20',
'lg:gutter-stable overflow-y-auto',
'[&::-webkit-scrollbar]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:bg-transparent',
'group-hover:[&::-webkit-scrollbar]:bg-tint-subtle',
'group-hover:[&::-webkit-scrollbar-thumb]:bg-tint-7',
'group-hover:[&::-webkit-scrollbar-thumb:hover]:bg-tint-8'
)}
>
<PagesList
rootPages={pages}
pages={pages}
context={context}
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
/>
) : null}
</TOCScrollContainer>
</div>
</aside>
{customization.trademark.enabled ? (
<Trademark
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Sidebar}
/>
) : null}
</TOCScrollContainer>
</div>
</aside>
<TableOfContentsScript />
</>
);
}
@@ -0,0 +1,73 @@
'use client';
import { useEffect } from 'react';
/**
* Adjusts TableOfContents height based on visible elements
*/
export function TableOfContentsScript() {
useEffect(() => {
const root = document.documentElement;
// Calculate and set TOC dimensions
const updateTocLayout = () => {
// Get key elements
const header = document.getElementById('site-header');
const banner = document.getElementById('announcement-banner');
const footer = document.getElementById('site-footer');
// Set sticky top position based on header
const headerHeight = header?.offsetHeight ?? 0;
root.style.setProperty('--toc-top-offset', `${headerHeight}px`);
// Start with full viewport height minus header
let height = window.innerHeight - headerHeight;
// Subtract visible banner (if any)
if (banner && window.getComputedStyle(banner).display !== 'none') {
const bannerRect = banner.getBoundingClientRect();
if (bannerRect.height > 0 && bannerRect.bottom > 0) {
height -= Math.min(bannerRect.height, bannerRect.bottom);
}
}
// Subtract visible footer (if any)
if (footer) {
const footerRect = footer.getBoundingClientRect();
if (footerRect.top < window.innerHeight) {
height -= Math.min(footerRect.height, window.innerHeight - footerRect.top);
}
}
// Update height
root.style.setProperty('--toc-height', `${height}px`);
};
// Initial update
updateTocLayout();
// Let the browser handle scroll throttling naturally
window.addEventListener('scroll', updateTocLayout, { passive: true });
window.addEventListener('resize', updateTocLayout, { passive: true });
// Use MutationObserver for DOM changes
const observer = new MutationObserver(() => {
requestAnimationFrame(updateTocLayout);
});
// Only observe what matters
observer.observe(document.documentElement, {
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
});
return () => {
observer.disconnect();
window.removeEventListener('scroll', updateTocLayout);
window.removeEventListener('resize', updateTocLayout);
};
}, []);
return null;
}
@@ -1 +1,4 @@
export * from './TableOfContents';
export { TableOfContents } from './TableOfContents';
export { PagesList } from './PagesList';
export { TOCScrollContainer } from './TOCScroller';
export { Trademark } from './Trademark';
+5 -1
View File
@@ -458,12 +458,16 @@ const config: Config = {
/**
* Variant when a header is displayed.
*/
addVariant('site-header-none', 'html.site-header-none &');
addVariant('site-header-none', 'body:not(:has(#site-header:not(.mobile-only))) &');
addVariant('site-header', 'body:has(#site-header:not(.mobile-only)) &');
addVariant('site-header-sections', [
'body:has(#site-header:not(.mobile-only) #sections) &',
'body:has(.page-no-toc):has(#site-header:not(.mobile-only) #variants) &',
]);
addVariant(
'announcement',
'html:not(.announcement-hidden):has(#announcement-banner) &'
);
const customisationVariants = {
// Sidebar styles