Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | 5x 5x 90x 90x 90x 90x 32x 32x 32x 90x 2x 2x 2x 90x 44x 260x 31x 30x 30x 251x 31x 1x 1x 30x 30x 249x 249x 30x 30x 1x 1x 1x 1x 212x 30x 30x 90x 1x 1x 542x 542x 2x | 'use client';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import type { TocItem } from '@/lib/markdown';
import { BulletedListIcon, CloseIcon } from './icons';
import { Button } from './ui/Button';
const NAV_STATE_KEY = 'inpage-nav-state';
/**
* Content region for doc pages: a page-heading band, a centered content column,
* and the in-page "On This Page" nav.
*
* The `heading` slot (breadcrumbs, title, tabs) sits in row 1 of the content
* column and the body in row 2; the nav spans both rows in the right column, so
* it opens level with the breadcrumbs when scrolled to the top.
*
* Responsive (CSS breakpoints):
* XLarge (1280+) nav is a sticky column, open by default; the X collapses it
* (.is-closed) and swaps in the reopen button
* Large (1026–1279) nav is collapsed by default; the button opens it (.is-open)
* Medium / Small nav hidden; single content column
*
* `navState` is a tri-state because the default differs by breakpoint: `null`
* follows the CSS default, `'open'` / `'closed'` is a user override. An explicit
* choice is persisted in localStorage so it survives navigation between pages.
*/
export function DocShell({
toc = [],
heading,
children,
}: {
toc?: TocItem[];
heading?: ReactNode;
children: ReactNode;
}) {
const [navState, setNavState] = useState<'open' | 'closed' | null>(null);
const [activeId, setActiveId] = useState('');
const hasNav = toc.length > 0;
// Read after mount (not in the initializer) so hydration matches the static HTML.
useEffect(() => {
try {
const saved = localStorage.getItem(NAV_STATE_KEY);
if (saved === 'open' || saved === 'closed') setNavState(saved);
} catch {
/* localStorage unavailable (private mode, etc.): fall back to defaults. */
}
}, []);
const chooseNavState = (next: 'open' | 'closed') => {
setNavState(next);
try {
localStorage.setItem(NAV_STATE_KEY, next);
} catch {
/* ignore persistence failures */
}
};
// Scroll-spy: highlight the section currently at the top of the viewport.
useEffect(() => {
if (!hasNav) return;
const headings = toc.map((t) => document.getElementById(t.id)).filter((el): el is HTMLElement => !!el);
if (!headings.length) return;
const visible = new Set<string>();
const pickActive = () => {
// First heading (document order) inside the trigger band wins.
const inBand = toc.find((t) => visible.has(t.id));
if (inBand) {
setActiveId(inBand.id);
return;
}
// None in band: keep the last heading that has scrolled above it active.
let above = '';
for (const t of toc) {
const el = document.getElementById(t.id);
if (el && el.getBoundingClientRect().top < 120) above = t.id;
}
setActiveId((prev) => above || prev);
};
const obs = new IntersectionObserver(
(entries) => {
for (const e of entries) {
const id = (e.target as HTMLElement).id;
if (e.isIntersecting) visible.add(id);
else Evisible.delete(id);
}
pickActive();
},
{ rootMargin: '-96px 0px -66% 0px', threshold: 0 },
);
headings.forEach((h) => obs.observe(h));
pickActive();
return () => obs.disconnect();
}, [toc, hasNav]);
return (
<div
className={[
'doc-shell',
hasNav ? 'doc-shell--with-nav' : '',
navState === 'open' ? 'is-open' : '',
navState === 'closed' ? 'is-closed' : '',
]
.filter(Boolean)
.join(' ')}
>
{heading && <div className="doc-shell__heading">{heading}</div>}
<div className="doc-shell__content">{children}</div>
{hasNav && (
<>
<button
type="button"
className="doc-shell__toggle"
onClick={() => chooseNavState('open')}
aria-label="Open on-this-page navigation"
>
<BulletedListIcon className="doc-shell__toggle-icon" aria-hidden="true" />
</button>
<aside className="doc-shell__nav" aria-label="On this page">
<div className="doc-shell__nav-inner">
<div className="doc-shell__nav-head">
<div className="doc-shell__nav-item doc-shell__nav-item--head">
<span className="doc-shell__nav-bar" aria-hidden="true" />
<span className="doc-shell__nav-label">On This Page</span>
</div>
<Button
variant="ghost"
size="md"
iconOnly
className="doc-shell__nav-close"
onClick={() => chooseNavState('closed')}
aria-label="Close on-this-page navigation"
>
<CloseIcon />
</Button>
</div>
<nav className="doc-shell__nav-scroll" aria-label="Page contents">
<ul className="doc-shell__nav-list">
{toc.map((item) => {
const active = item.id === activeId;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
className={[
'doc-shell__nav-item',
item.level === 3 ? 'doc-shell__nav-item--l2' : 'doc-shell__nav-item--l1',
active ? 'is-active' : '',
]
.filter(Boolean)
.join(' ')}
aria-current={active ? 'location' : undefined}
onClick={() => setActiveId(item.id)}
>
<span className="doc-shell__nav-bar" aria-hidden="true" />
<span className="doc-shell__nav-label">{item.text}</span>
</a>
</li>
);
})}
</ul>
</nav>
</div>
</aside>
</>
)}
</div>
);
}
|