feat(site): add responsive table of contents rail (#1858)

This commit is contained in:
Darius Cepulis
2026-07-22 16:26:30 -07:00
committed by GitHub
parent 0c9fe3877e
commit 53e543bbf8
6 changed files with 177 additions and 31 deletions
@@ -21,7 +21,7 @@ export function TableOfContentsDesktop({ headings, activeId, onNavigate, classNa
};
return (
<nav ref={navRef} className={clsx('', className)}>
<nav ref={navRef} aria-label="On this page" className={clsx('', className)}>
<div className="py-8 pr-6">
<h2 className="text-p3 mb-3 font-bold">On this page</h2>
<ul className="space-y-3">
@@ -32,6 +32,7 @@ export function TableOfContentsDesktop({ headings, activeId, onNavigate, classNa
onClick={(e) => handleClick(e, heading.slug)}
className={clsx('text-p3 block', activeId === heading.slug && 'font-bold')}
style={{ paddingLeft: `calc(${heading.depth - 2} * var(--spacing) * 4)` }}
aria-current={activeId === heading.slug ? 'location' : undefined}
>
{heading.text}
</a>
@@ -1,6 +1,9 @@
import { Popover } from '@base-ui/react/popover';
import type { MarkdownHeading } from 'astro';
import clsx from 'clsx';
import { Select } from '@/components/Select';
import { useEffect, useRef, useState } from 'react';
import { TableOfContentsDesktop } from './TableOfContents.desktop';
import { calculateRailGeometry } from './utils';
interface TableOfContentsMobileProps {
headings: MarkdownHeading[];
@@ -10,35 +13,125 @@ interface TableOfContentsMobileProps {
}
export function TableOfContentsMobile({ headings, activeId, onNavigate, className }: TableOfContentsMobileProps) {
const handleChange = (slug: string | null) => {
if (slug) onNavigate(slug);
const triggerRef = useRef<HTMLButtonElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [viewportLayout, setViewportLayout] = useState({
availableHeight: 400,
railTop: null as number | null,
});
const railGeometry = calculateRailGeometry(headings.length, viewportLayout.availableHeight);
useEffect(() => {
const updateViewportLayout = () => {
const navHeightValue = triggerRef.current ? getComputedStyle(triggerRef.current).getPropertyValue('--nav-h') : '';
const navHeight = Number.parseFloat(navHeightValue) || 52;
setViewportLayout({
availableHeight: Math.max(0, window.innerHeight - navHeight - 32),
railTop: navHeight + (window.innerHeight - navHeight) / 2,
});
};
updateViewportLayout();
window.addEventListener('resize', updateViewportLayout);
window.visualViewport?.addEventListener('resize', updateViewportLayout);
return () => {
window.removeEventListener('resize', updateViewportLayout);
window.visualViewport?.removeEventListener('resize', updateViewportLayout);
};
}, []);
useEffect(() => {
const desktopMedia = window.matchMedia('(min-width: 80rem)');
const closeAtDesktopBreakpoint = (event: MediaQueryListEvent) => {
if (event.matches) setOpen(false);
};
desktopMedia.addEventListener('change', closeAtDesktopBreakpoint);
return () => desktopMedia.removeEventListener('change', closeAtDesktopBreakpoint);
}, []);
useEffect(() => {
if (!open) return;
const closeOnDocumentScroll = () => setOpen(false);
window.addEventListener('scroll', closeOnDocumentScroll, { passive: true });
return () => window.removeEventListener('scroll', closeOnDocumentScroll);
}, [open]);
const handleNavigate = (slug: string) => {
setOpen(false);
onNavigate(slug);
};
const options = [
{ value: null, label: 'On this page…' },
...headings.map((heading) => ({
value: heading.slug,
label: `${'\u00A0'.repeat((heading.depth - 2) * 2)}${heading.text}`,
})),
];
return (
<div
className={clsx(
'border-b border-manila-75 dark:border-warm-gray bg-manila-light dark:bg-faded-black px-6 lg:px-12 flex items-center',
className
)}
style={{ height: 'var(--mobile-toc-h)' }}
>
<div className="w-full max-w-3xl mx-auto">
<Select
value={activeId || null}
onChange={handleChange}
options={options}
aria-label="Table of contents"
className="w-full"
/>
</div>
</div>
<Popover.Root open={open} onOpenChange={setOpen} modal={false}>
<Popover.Trigger
ref={triggerRef}
aria-label="On this page"
className={clsx(
'fixed left-0 z-20 flex min-h-6 w-6 items-center justify-start intent:text-manila-dark md:left-70 dark:intent:text-manila-dark lg:left-75',
open ? 'text-manila-dark dark:text-manila-dark' : 'text-manila-75 dark:text-warm-gray',
className
)}
style={{
top: viewportLayout.railTop ?? 'calc(50dvh + 1.625rem)',
transform: 'translateY(-50%)',
cursor: 'pointer',
}}
>
<span aria-hidden="true" className="flex flex-col items-start pl-1" style={{ gap: railGeometry.gap }}>
{headings.map((heading) => {
const isActive = activeId === heading.slug;
const width = heading.depth === 2 ? 12 : heading.depth === 3 ? 8 : 4;
return (
<span
key={heading.slug}
className={isActive ? 'block bg-faded-black dark:bg-manila-light' : 'block bg-current'}
style={{ width, height: railGeometry.stripeHeight }}
/>
);
})}
</span>
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner
side="right"
align="center"
sideOffset={-4}
collisionPadding={16}
positionMethod="fixed"
className="z-20"
>
<Popover.Popup
ref={popupRef}
initialFocus={() =>
popupRef.current?.querySelector<HTMLAnchorElement>('a[aria-current="location"]') ??
popupRef.current?.querySelector<HTMLAnchorElement>('a[href]') ??
popupRef.current
}
className={clsx(
'origin-left overflow-y-auto rounded-xs border border-manila-dark bg-manila-light pl-6 text-p3 shadow-xl transition duration-150 ease-out',
'starting-style:-translate-x-1 starting-style:scale-98 starting-style:opacity-0',
'ending-style:-translate-x-1 ending-style:scale-98 ending-style:opacity-0 ending-style:duration-100 ending-style:ease-in',
'motion-reduce:transition-none motion-reduce:starting-style:translate-x-0 motion-reduce:starting-style:scale-100 motion-reduce:ending-style:translate-x-0 motion-reduce:ending-style:scale-100',
'dark:border-soot dark:bg-soot'
)}
style={{
width: 'min(20rem, calc(100vw - 3rem))',
maxHeight: viewportLayout.availableHeight,
}}
>
<TableOfContentsDesktop headings={headings} activeId={activeId} onNavigate={handleNavigate} />
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { calculateRailGeometry } from './utils';
describe('calculateRailGeometry', () => {
it('uses the default stripe height and gap when the rail fits', () => {
expect(calculateRailGeometry(10, 100)).toEqual({ stripeHeight: 2, gap: 4 });
});
it('compresses gaps before changing stripe height', () => {
expect(calculateRailGeometry(10, 38)).toEqual({ stripeHeight: 2, gap: 2 });
});
it('compresses stripe height when removing gaps is not enough', () => {
expect(calculateRailGeometry(10, 15)).toEqual({ stripeHeight: 1.5, gap: 0 });
});
it('keeps the default geometry for a single stripe', () => {
expect(calculateRailGeometry(1, 1)).toEqual({ stripeHeight: 2, gap: 4 });
});
});
@@ -5,6 +5,38 @@ import type { RefObject } from 'react';
import { useEffect, useState } from 'react';
import { API_REFERENCE_SUBSECTION_TITLES } from '@/utils/componentReferenceModel';
export interface RailGeometry {
stripeHeight: number;
gap: number;
}
/**
* Keep the full heading map visible by reducing gaps first, then stripe height.
*/
export function calculateRailGeometry(headingCount: number, availableHeight: number): RailGeometry {
const stripeHeight = 2;
const gap = 4;
if (headingCount <= 1) {
return { stripeHeight, gap };
}
const desiredHeight = headingCount * stripeHeight + (headingCount - 1) * gap;
if (desiredHeight <= availableHeight) {
return { stripeHeight, gap };
}
const compressedGap = (availableHeight - headingCount * stripeHeight) / (headingCount - 1);
if (compressedGap >= 0) {
return { stripeHeight, gap: compressedGap };
}
return {
stripeHeight: Math.max(0, availableHeight / headingCount),
gap: 0,
};
}
/**
* Find the first scrollable ancestor of an element
*/
@@ -101,14 +101,12 @@ const jsonLdSchema = createTechArticleSchema({
"grid grid-cols-1 xl:grid-cols-(--xl-grid-cols)",
"[grid-template-areas:var(--grid-template-areas)] xl:[grid-template-areas:var(--xl-grid-template-areas)]",
"grid-rows-(--grid-rows) xl:grid-rows-1",
"[--scroll-mt:var(--mobile-toc-h)] xl:[--scroll-mt:0px]",
]}
style="
--xl-grid-cols: minmax(0, 1fr) calc(var(--spacing) * 59);
--grid-template-areas: 'selectors' 'toc' 'article';
--grid-rows: min-content min-content minmax(0, 1fr);
--xl-grid-template-areas: 'article toc';
--mobile-toc-h: calc(var(--spacing) * 15);
"
>
<div class="md:hidden" style="grid-area: selectors;">
+2
View File
@@ -194,6 +194,8 @@
}
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant banner-dismissed (&:where(.banner-dismissed, .banner-dismissed *));
@custom-variant starting-style (&[data-starting-style]);
@custom-variant ending-style (&[data-ending-style]);
@layer base {
.list-decimal .list-decimal {