mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(site): add responsive table of contents rail (#1858)
This commit is contained in:
@@ -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;">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user