diff --git a/src/generators/web/__tests__/generate.test.mjs b/src/generators/web/__tests__/generate.test.mjs index cde610ae..efff1f49 100644 --- a/src/generators/web/__tests__/generate.test.mjs +++ b/src/generators/web/__tests__/generate.test.mjs @@ -1,4 +1,7 @@ import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it } from 'node:test'; import { setConfig } from '../../../utils/configuration/index.mjs'; @@ -6,10 +9,10 @@ import buildContent from '../../jsx-ast/utils/buildContent.mjs'; import { buildNotFoundPage } from '../../jsx-ast/utils/synthetic/404.mjs'; import { generate } from '../generate.mjs'; -const createEntry = (api, name) => { +const createEntry = (api, name, { depth = 1 } = {}) => { const heading = { type: 'heading', - depth: 1, + depth, children: [{ type: 'text', value: name }], data: { name, text: name, slug: api }, }; @@ -83,4 +86,26 @@ describe('web generate', () => { assert.match(fsPage.html, /property=og:type content=website/); assert.match(fsPage.html, /href=https:\/\/fonts\.googleapis\.com/); }); + + it('hydrates active status for right-side table of contents links', async () => { + const output = await mkdtemp(join(tmpdir(), 'doc-kit-web-')); + const config = await setConfig({}); + config.web.output = output; + + try { + const fs = createEntry('fs', 'File system'); + const readFileEntry = createEntry('fs-read-file', 'File system readFile', { depth: 2 }); + + await generate([await buildContent([fs, readFileEntry], fs)]); + + const clientBundle = await readFile(join(output, 'fs.js'), 'utf8'); + + for (const token of ['data-active-heading', 'aria-current', 'hashchange', 'scroll']) { + assert.match(clientBundle, new RegExp(token)); + } + } finally { + config.web.output = undefined; + await rm(output, { recursive: true, force: true }); + } + }); }); diff --git a/src/generators/web/ui/components/MetaBar/index.jsx b/src/generators/web/ui/components/MetaBar/index.jsx index 261498ea..4a04228f 100644 --- a/src/generators/web/ui/components/MetaBar/index.jsx +++ b/src/generators/web/ui/components/MetaBar/index.jsx @@ -2,6 +2,7 @@ import { CodeBracketIcon, DocumentIcon } from '@heroicons/react/24/outline'; import Badge from '@node-core/ui-components/Common/Badge'; import MetaBar from '@node-core/ui-components/Containers/MetaBar'; import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub'; +import { useEffect, useState } from 'react'; import styles from './index.module.css'; @@ -15,6 +16,72 @@ const iconMap = { const STABILITY_KINDS = ['error', 'warning', null, 'info']; const STABILITY_LABELS = ['D', 'E', null, 'L']; const STABILITY_TOOLTIPS = ['Deprecated', 'Experimental', null, 'Legacy']; +const SCROLL_OFFSET = 96; + +const toHeadingIds = headings => + headings + .map(heading => heading.data?.id) + .filter(id => typeof id === 'string' && id.length > 0); + +const useActiveHeadingId = headings => { + const [activeHeadingId, setActiveHeadingId] = useState(''); + + useEffect(() => { + const headingIds = toHeadingIds(headings); + + if (headingIds.length === 0) { + setActiveHeadingId(''); + return; + } + + let frame = 0; + + const updateActiveHeading = () => { + let nextActiveHeadingId = ''; + frame = 0; + + for (const id of headingIds) { + const element = document.getElementById(id); + + if (!element) { + continue; + } + + if ( + !nextActiveHeadingId || + element.getBoundingClientRect().top <= SCROLL_OFFSET + ) { + nextActiveHeadingId = id; + } + } + + setActiveHeadingId(nextActiveHeadingId); + }; + + const scheduleActiveHeadingUpdate = () => { + if (frame === 0) { + frame = window.requestAnimationFrame(updateActiveHeading); + } + }; + + updateActiveHeading(); + window.addEventListener('hashchange', scheduleActiveHeadingUpdate); + window.addEventListener('scroll', scheduleActiveHeadingUpdate, { + passive: true, + }); + + return () => { + window.removeEventListener('hashchange', scheduleActiveHeadingUpdate); + window.removeEventListener('scroll', scheduleActiveHeadingUpdate); + + if (frame !== 0) { + window.cancelAnimationFrame(frame); + } + }; + }, [headings]); + + return activeHeadingId; +}; /** * Renders a heading value with an optional stability badge @@ -53,14 +120,32 @@ const HeadingValue = ({ value, stability }) => { */ export default ({ metadata, headings = [], readingTime }) => { const editThisPage = editURL.replace('{path}', metadata.path); + const activeHeadingId = useActiveHeadingId(headings); const viewAs = [ ['JSON', `${metadata.basename}.json`], ['MD', `${metadata.basename}.md`], ]; + const ActiveHeadingLink = ({ href, className, ...props }) => { + const headingId = href?.startsWith('#') ? href.slice(1) : ''; + const isActive = headingId === activeHeadingId; + const activeClassName = isActive ? styles.activeHeading : ''; + + return ( + + ); + }; + return ( ({ diff --git a/src/generators/web/ui/components/MetaBar/index.module.css b/src/generators/web/ui/components/MetaBar/index.module.css index 0f3163ee..c512fd5c 100644 --- a/src/generators/web/ui/components/MetaBar/index.module.css +++ b/src/generators/web/ui/components/MetaBar/index.module.css @@ -9,3 +9,9 @@ display: inline-block; margin-left: 0.25rem; } + +.activeHeading.activeHeading { + border-left: 0.25rem solid currentColor; padding-left: 0.4rem; + color: var(--color-green-700, #2c682c); + font-weight: 700; text-decoration-line: none; +}