mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 10:33:22 +00:00
Dynamic TOC height (#3233)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user