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 (
{anchors.map((anchor) => (
-
-
{anchor.title2}
-
- {anchor.children && }
+
+ {anchor.children && (
+
+ )}
))}
@@ -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(
,