diff --git a/src/components/SidebarItem/SidebarItem.jsx b/src/components/SidebarItem/SidebarItem.jsx index 328c3dc5359c..7a5bb7db2a85 100644 --- a/src/components/SidebarItem/SidebarItem.jsx +++ b/src/components/SidebarItem/SidebarItem.jsx @@ -1,6 +1,6 @@ import PropTypes from "prop-types"; -import { useCallback, useEffect, useState } from "react"; -import { NavLink } from "react-router-dom"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, NavLink } from "react-router-dom"; import ChevronRightIcon from "../../styles/icons/chevron-right.svg"; import BarIcon from "../../styles/icons/vertical-bar.svg"; import list2Tree from "../../utilities/list2Tree/index.js"; @@ -42,22 +42,41 @@ function scrollTop(event) { } } -function Anchors({ anchors, url }) { +function Anchors({ anchors, url, activeAnchorId }) { + const activeAnchorRef = useRef(null); + + useEffect(() => { + if (activeAnchorRef.current) { + activeAnchorRef.current.scrollIntoView({ + block: "nearest", + inline: "nearest", + behavior: "smooth", + }); + } + }, [activeAnchorId]); + return ( @@ -67,15 +86,80 @@ function Anchors({ anchors, url }) { Anchors.propTypes = { anchors: PropTypes.array.isRequired, url: PropTypes.string.isRequired, + activeAnchorId: PropTypes.string, }; export default function SidebarItem({ title, anchors = [], url, currentPage }) { const [open, setOpen] = useState(() => isOpen(currentPage, url)); + const [activeAnchorId, setActiveAnchorId] = useState(null); useEffect(() => { setOpen(isOpen(currentPage, url)); }, [currentPage, url]); + useEffect(() => { + if (currentPage !== url || anchors.length === 0) { + setActiveAnchorId(null); + return undefined; + } + + const content = document.getElementById("md-content"); + + if (!content) { + setActiveAnchorId(null); + return undefined; + } + + const activeAnchors = anchors.filter((anchor) => anchor.level > 1); + + const updateActiveAnchor = () => { + const anchorElements = activeAnchors + .map((anchor) => document.getElementById(anchor.id)) + .filter(Boolean); + + let nextActiveAnchorId = null; + + for (const anchorElement of anchorElements) { + if (anchorElement.getBoundingClientRect().top <= 120) { + nextActiveAnchorId = anchorElement.id; + } else { + break; + } + } + + if (!nextActiveAnchorId && anchorElements.length > 0) { + nextActiveAnchorId = anchorElements[0].id; + } + + setActiveAnchorId(nextActiveAnchorId); + }; + + updateActiveAnchor(); + + let animationFrameId = window.requestAnimationFrame(updateActiveAnchor); + + const handleScroll = () => { + window.cancelAnimationFrame(animationFrameId); + animationFrameId = window.requestAnimationFrame(updateActiveAnchor); + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("resize", handleScroll); + + const mutationObserver = new MutationObserver(handleScroll); + mutationObserver.observe(content, { + childList: true, + subtree: true, + }); + + return () => { + window.cancelAnimationFrame(animationFrameId); + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", handleScroll); + mutationObserver.disconnect(); + }; + }, [anchors, currentPage, url]); + const toggle = useCallback(() => { setOpen((prev) => !prev); }, []); @@ -124,7 +208,9 @@ export default function SidebarItem({ title, anchors = [], url, currentPage }) { {title} - {anchors.length > 0 ? : null} + {anchors.length > 0 ? ( + + ) : null} ); } diff --git a/src/components/SidebarItem/SidebarItem.test.jsx b/src/components/SidebarItem/SidebarItem.test.jsx index c1eb4a46bcfb..435c3413ed60 100644 --- a/src/components/SidebarItem/SidebarItem.test.jsx +++ b/src/components/SidebarItem/SidebarItem.test.jsx @@ -2,16 +2,42 @@ * @jest-environment jsdom */ // eslint-disable-next-line import/no-extraneous-dependencies -import { describe, expect, it } from "@jest/globals"; +import { afterAll, beforeAll, describe, expect, it, jest } from "@jest/globals"; import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import SidebarItem from "./SidebarItem.jsx"; +const scrollIntoViewMock = jest.fn(); +const requestAnimationFrameMock = (callback) => { + callback(); + return 1; +}; +const cancelAnimationFrameMock = jest.fn(); + function renderWithRouter(ui, { route = "/" } = {}) { return render({ui}); } describe("SidebarItem", () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoViewMock, + }); + Object.defineProperty(window, "requestAnimationFrame", { + configurable: true, + value: requestAnimationFrameMock, + }); + Object.defineProperty(window, "cancelAnimationFrame", { + configurable: true, + value: cancelAnimationFrameMock, + }); + }); + + afterAll(() => { + delete HTMLElement.prototype.scrollIntoView; + }); + const defaultProps = { title: "Getting Started", url: "/guides/getting-started/", @@ -81,6 +107,34 @@ describe("SidebarItem", () => { expect(screen.getByText("Basic Setup")).toBeTruthy(); }); + it("highlights and scrolls the active anchor into view", () => { + const anchors = [ + { id: "intro", title: "Introduction", title2: "Introduction", level: 2 }, + { id: "setup", title: "Setup", title2: "Setup", level: 2 }, + ]; + + render( + +
+
+

Introduction

+

Setup

+
+ +
+
, + ); + + expect(screen.getByRole("link", { name: "Setup" }).className).toContain( + "text-[#175d96]", + ); + expect(scrollIntoViewMock).toHaveBeenCalled(); + }); + it("renders a bar icon when no anchors are provided", () => { const { container } = renderWithRouter( ,