From a4a33d6c981d2d109ebd67ea1b890b8c88bb55b8 Mon Sep 17 00:00:00 2001 From: sebassg Date: Sat, 7 Mar 2026 22:56:14 -0500 Subject: [PATCH] feat(web): add scroll-spy active indicator to TOC Use IntersectionObserver to track the currently visible heading and highlight it in the MetaBar table of contents. Passes activeSlug via Context to a custom ActiveLink component rendered through the existing 'as' prop of MetaBar, requiring no changes to @node-core/ui-components. --- .../web/ui/components/MetaBar/index.jsx | 109 +++++++++++------- .../ui/components/MetaBar/index.module.css | 7 ++ .../ui/hooks/__tests__/useScrollSpy.test.mjs | 38 ++++++ src/generators/web/ui/hooks/useScrollSpy.mjs | 49 ++++++++ 4 files changed, 164 insertions(+), 39 deletions(-) create mode 100644 src/generators/web/ui/hooks/__tests__/useScrollSpy.test.mjs create mode 100644 src/generators/web/ui/hooks/useScrollSpy.mjs diff --git a/src/generators/web/ui/components/MetaBar/index.jsx b/src/generators/web/ui/components/MetaBar/index.jsx index 23f48638..7aae8b47 100644 --- a/src/generators/web/ui/components/MetaBar/index.jsx +++ b/src/generators/web/ui/components/MetaBar/index.jsx @@ -1,9 +1,12 @@ +/* eslint-disable react-x/no-use-context, react-x/no-context-provider -- preact/compat does not export `use`, so React 19 patterns are unavailable at runtime */ 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 { createContext, useContext } from 'react'; import styles from './index.module.css'; +import { useScrollSpy } from '../../hooks/useScrollSpy.mjs'; const iconMap = { JSON: CodeBracketIcon, @@ -23,6 +26,8 @@ const STABILITY_KINDS = ['error', 'warning', null, 'info']; const STABILITY_LABELS = ['D', 'E', null, 'L']; const STABILITY_TOOLTIPS = ['Deprecated', 'Experimental', null, 'Legacy']; +const ActiveSlugContext = createContext(null); + /** * Renders a heading value with an optional stability badge * @param {{ value: string, stability: number }} props @@ -54,6 +59,25 @@ const HeadingValue = ({ value, stability }) => { ); }; +/** + * Anchor element that highlights itself when it matches the active TOC slug. + * @param {{ href: string, className?: string }} props + */ +const ActiveLink = ({ href, className, ...props }) => { + const activeSlug = useContext(ActiveSlugContext); + + return ( + + ); +}; + /** * MetaBar component that displays table of contents and page metadata * @param {MetaBarProps} props - Component props @@ -64,42 +88,49 @@ export default ({ readingTime, viewAs = [], editThisPage, -}) => ( - ({ - ...heading, - value: , - })), - }} - items={{ - 'Reading Time': readingTime, - 'Added In': addedIn, - 'View As': ( -
    - {viewAs.map(([title, path]) => { - const Icon = iconMap[title]; - - return ( -
  1. - - {Icon && } - - {title} - -
  2. - ); - })} -
- ), - Contribute: ( - <> - - - Edit this page - - ), - }} - /> -); +}) => { + const activeSlug = useScrollSpy(headings); + + return ( + + ({ + ...heading, + value: , + })), + }} + items={{ + 'Reading Time': readingTime, + 'Added In': addedIn, + 'View As': ( +
    + {viewAs.map(([title, path]) => { + const Icon = iconMap[title]; + + return ( +
  1. + + {Icon && } + + {title} + +
  2. + ); + })} +
+ ), + Contribute: ( + <> + + + Edit this page + + ), + }} + /> +
+ ); +}; diff --git a/src/generators/web/ui/components/MetaBar/index.module.css b/src/generators/web/ui/components/MetaBar/index.module.css index 0f3163ee..d1ff076d 100644 --- a/src/generators/web/ui/components/MetaBar/index.module.css +++ b/src/generators/web/ui/components/MetaBar/index.module.css @@ -9,3 +9,10 @@ display: inline-block; margin-left: 0.25rem; } + +.tocActive { + color: var(--color-primary, #0078d4); + font-weight: 600; + border-left: 2px solid var(--color-primary, #0078d4); + padding-left: 0.25rem; +} diff --git a/src/generators/web/ui/hooks/__tests__/useScrollSpy.test.mjs b/src/generators/web/ui/hooks/__tests__/useScrollSpy.test.mjs new file mode 100644 index 00000000..867b568a --- /dev/null +++ b/src/generators/web/ui/hooks/__tests__/useScrollSpy.test.mjs @@ -0,0 +1,38 @@ +import { strictEqual } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { getActiveSlug } from '../useScrollSpy.mjs'; + +describe('getActiveSlug', () => { + it('should return null for an empty entries array', () => { + strictEqual(getActiveSlug([]), null); + }); + + it('should return null when no entry is intersecting', () => { + const entries = [ + { isIntersecting: false, target: { id: 'intro' } }, + { isIntersecting: false, target: { id: 'usage' } }, + ]; + + strictEqual(getActiveSlug(entries), null); + }); + + it('should return the id of the first intersecting entry', () => { + const entries = [ + { isIntersecting: false, target: { id: 'intro' } }, + { isIntersecting: true, target: { id: 'usage' } }, + { isIntersecting: true, target: { id: 'api' } }, + ]; + + strictEqual(getActiveSlug(entries), 'usage'); + }); + + it('should return the id when only one entry is intersecting', () => { + const entries = [ + { isIntersecting: false, target: { id: 'intro' } }, + { isIntersecting: true, target: { id: 'config' } }, + ]; + + strictEqual(getActiveSlug(entries), 'config'); + }); +}); diff --git a/src/generators/web/ui/hooks/useScrollSpy.mjs b/src/generators/web/ui/hooks/useScrollSpy.mjs new file mode 100644 index 00000000..578b965b --- /dev/null +++ b/src/generators/web/ui/hooks/useScrollSpy.mjs @@ -0,0 +1,49 @@ +import { useState, useEffect } from 'react'; + +/** + * Determines the active heading slug from IntersectionObserver entries. + * Exported as a named function to allow unit testing without a DOM environment. + * + * @param {IntersectionObserverEntry[]} entries + * @returns {string | null} + */ +export const getActiveSlug = entries => { + const visible = entries.find(e => e.isIntersecting); + + return visible ? visible.target.id : null; +}; + +/** + * Tracks which heading is currently visible in the viewport using IntersectionObserver. + * + * @param {Array<{ slug: string }>} headings + * @returns {string | null} The slug of the active heading + */ +export const useScrollSpy = headings => { + const [activeSlug, setActiveSlug] = useState(null); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + const slug = getActiveSlug(entries); + + if (slug !== null) { + setActiveSlug(slug); + } + }, + { rootMargin: '0px 0px -70% 0px', threshold: 0 } + ); + + headings.forEach(({ slug }) => { + const el = document.getElementById(slug); + + if (el) { + observer.observe(el); + } + }); + + return () => observer.disconnect(); + }, [headings]); + + return activeSlug; +};