diff --git a/cypress/e2e/click-menu-scroll-top.cy.js b/cypress/e2e/click-menu-scroll-top.cy.js
index 712780441286..08de6118caf1 100644
--- a/cypress/e2e/click-menu-scroll-top.cy.js
+++ b/cypress/e2e/click-menu-scroll-top.cy.js
@@ -7,7 +7,8 @@ describe("Click menu", () => {
// note that there's no hash in url
cy.get('[data-testid="contributors"]').scrollIntoView();
- const selector = '.sidebar-item__title[href="/concepts/modules/"]';
+ const selector =
+ '[data-testid="sidebar-item-title"][href="/concepts/modules/"]';
cy.get(selector).click();
cy.window().then((win) => {
diff --git a/cypress/e2e/pr_4435.cy.js b/cypress/e2e/pr_4435.cy.js
index 70cc6e6931af..e2e1007e9ffa 100644
--- a/cypress/e2e/pr_4435.cy.js
+++ b/cypress/e2e/pr_4435.cy.js
@@ -10,13 +10,16 @@ describe("Open page in new tab", { scrollBehavior: false }, () => {
},
});
// wait for page content to load before asserting scroll
- cy.get('.sidebar-item__title[href="/concepts/plugins/"]').should("exist");
+ cy.get(
+ '[data-testid="sidebar-item-title"][href="/concepts/plugins/"]',
+ ).should("exist");
// there's one call in Page.jsx when componentDidMount
cy.window().should((win) => {
expect(win.scrollTo).to.be.calledOnce;
});
- const selector = '.sidebar-item__title[href="/concepts/plugins/"]';
+ const selector =
+ '[data-testid="sidebar-item-title"][href="/concepts/plugins/"]';
// we click the menu
cy.get(selector).click();
diff --git a/src/components/SidebarItem/SidebarItem.jsx b/src/components/SidebarItem/SidebarItem.jsx
index 12b950111fba..328c3dc5359c 100644
--- a/src/components/SidebarItem/SidebarItem.jsx
+++ b/src/components/SidebarItem/SidebarItem.jsx
@@ -1,143 +1,137 @@
import PropTypes from "prop-types";
-import { Component } from "react";
-import "./SidebarItem.scss";
+import { useCallback, useEffect, useState } from "react";
import { 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";
-const block = "sidebar-item";
-
-export default class SidebarItem extends Component {
- static propTypes = {
- title: PropTypes.string,
- anchors: PropTypes.array,
- url: PropTypes.string,
- currentPage: PropTypes.string,
- };
+/**
+ * Checks whether the sidebar item should be expanded
+ * based on whether the current page URL matches this item's URL.
+ *
+ * @param {string} currentPage - The current page pathname
+ * @param {string} url - The sidebar item URL
+ * @returns {boolean}
+ */
+function isOpen(currentPage, url) {
+ return new RegExp(`${currentPage}/?$`).test(url);
+}
- state = {
- open: this._isOpen(this.props),
- };
+/**
+ * Generate the url for the given anchor depending on the current page
+ *
+ * @param {string} url - The base URL
+ * @param {object} anchor - The anchor object containing its id
+ * @returns {string}
+ */
+function generateAnchorURL(url, anchor) {
+ return anchor.id ? `${url}#${anchor.id}` : url;
+}
- scrollTop(event) {
- // there're two cases
- // 1. location.pathname or location.hash changes which will be handled by useEffect in Page.jsx
- // 2. location.pathname and location.hash doesn't change at all
- if (window.location.hash !== "") {
- // case 1
- return;
- }
- if (!event.metaKey && !event.ctrlKey) {
- // case 2
- window.scrollTo(0, 0);
- }
+function scrollTop(event) {
+ // there're two cases
+ // 1. location.pathname or location.hash changes which will be handled by useEffect in Page.jsx
+ // 2. location.pathname and location.hash doesn't change at all
+ if (window.location.hash !== "") {
+ // case 1
+ return;
}
+ if (!event.metaKey && !event.ctrlKey) {
+ // case 2
+ window.scrollTo(0, 0);
+ }
+}
- renderAnchors(anchors) {
- return (
-
- {anchors.map((anchor) => (
- -
+ {anchors.map((anchor) => (
+
-
+
-
- {anchor.title2}
-
- {anchor.children && this.renderAnchors(anchor.children)}
-
- ))}
-
- );
- }
+ {anchor.title2}
+
+ {anchor.children && }
+
+ ))}
+
+ );
+}
- render() {
- const { title, anchors = [] } = this.props;
- const openMod = this.state.open ? `${block}--open` : "";
- const disabledMod = anchors.length === 0 ? `${block}--disabled` : "";
+Anchors.propTypes = {
+ anchors: PropTypes.array.isRequired,
+ url: PropTypes.string.isRequired,
+};
- const filteredAnchors = anchors.filter((anchor) => anchor.level > 1);
- const tree = list2Tree(title, filteredAnchors);
+export default function SidebarItem({ title, anchors = [], url, currentPage }) {
+ const [open, setOpen] = useState(() => isOpen(currentPage, url));
- return (
-
- {anchors.length > 0 ? (
-
- ) : (
-
{
+ setOpen(isOpen(currentPage, url));
+ }, [currentPage, url]);
+
+ const toggle = useCallback(() => {
+ setOpen((prev) => !prev);
+ }, []);
+
+ const filteredAnchors = anchors.filter((anchor) => anchor.level > 1);
+ const tree = list2Tree(title, filteredAnchors);
+
+ return (
+
+ {anchors.length > 0 ? (
+
- );
- }
-
- // eslint-disable-next-line camelcase
- UNSAFE_componentWillReceiveProps(nextProps) {
- if (nextProps.currentPage !== this.props.currentPage) {
- this.setState({
- open: this._isOpen(nextProps),
- });
- }
- }
-
- /**
- * Checks whether the item should be expanded
- *
- * @param {object} props - The current props
- */
- _isOpen(props) {
- return new RegExp(`${props.currentPage}/?$`).test(props.url);
- }
+
+ ) : (
+
+ )}
- /**
- * Toggles the open state (expanded/collapsed)
- *
- * @param {object} e - Click event
- */
- _toggle() {
- this.setState({
- open: !this.state.open,
- });
- }
+
+ `flex-1 max-w-[85%] overflow-hidden whitespace-nowrap text-ellipsis ${isActive ? "font-semibold text-[#333] dark:text-white" : "text-[#2b3a42] dark:text-[#b8b8b8]"}`
+ }
+ to={url}
+ onClick={scrollTop}
+ >
+ {title}
+
- /**
- * Generate the url for the given [anchor] depending on the current page
- *
- * @param {object} anchor - The anchor object containing its id
- * @returns {string}
- */
- _generateAnchorURL(anchor) {
- const { url } = this.props;
- return anchor.id ? `${url}#${anchor.id}` : url;
- }
+ {anchors.length > 0 ? : null}
+
+ );
}
+
+SidebarItem.propTypes = {
+ title: PropTypes.string,
+ anchors: PropTypes.array,
+ url: PropTypes.string,
+ currentPage: PropTypes.string,
+};
diff --git a/src/components/SidebarItem/SidebarItem.scss b/src/components/SidebarItem/SidebarItem.scss
deleted file mode 100644
index ec34b1420423..000000000000
--- a/src/components/SidebarItem/SidebarItem.scss
+++ /dev/null
@@ -1,119 +0,0 @@
-@use "../../styles/partials/mixins" as *;
-@use "../../styles/partials/functions" as *;
-
-.sidebar-item {
- position: relative;
- display: flex;
- flex-wrap: wrap;
- font-size: 15px;
- margin: 0.6em 0;
- align-items: center;
- min-height: 40px;
-
- &__toggle {
- flex: 0 0 auto;
- margin-top: 0.125em;
- margin-right: 0.5em;
- cursor: pointer;
- color: getColor(denim);
- transition: all 250ms;
-
- &:hover {
- color: getColor(mine-shaft);
- }
- }
-
- &__toggle-button {
- background: none;
- border: none;
- padding: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-width: 40px;
- min-height: 40px;
- }
-
- &__title {
- flex: 1 1 auto;
- color: getColor(elephant);
- max-width: 85%;
- min-height: 40px;
- display: inline-flex;
- align-items: center;
- @include control-overflow;
- &.active {
- font-weight: 600;
- color: getColor(mine-shaft);
- }
- }
-
- &__anchors {
- position: relative;
- display: none;
- flex: 0 0 100%;
- flex-wrap: wrap;
- margin: 0.35em 0;
- padding-left: 1.5em;
- overflow: hidden;
- list-style: none;
- line-height: 19px;
-
- &:before {
- content: "";
- position: absolute;
- height: calc(100% - 0.6em);
- top: 0;
- left: 1.5em;
- border-left: 1px dashed getColor(dusty-grey);
- }
- }
-
- &__anchor {
- position: relative;
- flex: 0 0 100%;
- margin: 0.25em 0;
- padding-left: 1em;
- @include control-overflow;
-
- &:first-child {
- margin-top: 0;
- }
- &:last-child {
- margin-bottom: 0;
- }
- &:before {
- content: "";
- position: absolute;
- width: 0.5em;
- left: 0;
- top: 10px;
- border-bottom: 1px dashed getColor(dusty-grey);
- }
-
- a {
- color: getColor(elephant);
-
- &:hover {
- color: getColor(denim);
- }
- }
- }
-
- &--open {
- .sidebar-item__anchors {
- display: flex;
- }
-
- .sidebar-item__toggle {
- transform-origin: center center;
- transform: rotate(90deg);
- }
- }
-
- &--disabled {
- .sidebar-item__toggle {
- color: #aaa;
- }
- }
-}
diff --git a/src/components/SidebarItem/SidebarItem.test.jsx b/src/components/SidebarItem/SidebarItem.test.jsx
new file mode 100644
index 000000000000..c1eb4a46bcfb
--- /dev/null
+++ b/src/components/SidebarItem/SidebarItem.test.jsx
@@ -0,0 +1,103 @@
+/**
+ * @jest-environment jsdom
+ */
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { describe, expect, it } from "@jest/globals";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import SidebarItem from "./SidebarItem.jsx";
+
+function renderWithRouter(ui, { route = "/" } = {}) {
+ return render({ui});
+}
+
+describe("SidebarItem", () => {
+ const defaultProps = {
+ title: "Getting Started",
+ url: "/guides/getting-started/",
+ currentPage: "/guides/",
+ anchors: [],
+ };
+
+ it("renders the title", () => {
+ renderWithRouter();
+ expect(screen.getByText("Getting Started")).toBeTruthy();
+ });
+
+ it("renders collapsed by default when not matching currentPage", () => {
+ const { container } = renderWithRouter();
+ const wrapper = container.firstChild;
+ expect(wrapper.getAttribute("data-open")).toBeNull();
+ });
+
+ it("renders expanded when url matches currentPage", () => {
+ const { container } = renderWithRouter(
+ ,
+ );
+ const wrapper = container.firstChild;
+ expect(wrapper.getAttribute("data-open")).toBe("true");
+ });
+
+ it("toggles open state when chevron button is clicked", () => {
+ const anchors = [
+ {
+ id: "introduction",
+ title: "Introduction",
+ title2: "Introduction",
+ level: 2,
+ },
+ { id: "setup", title: "Setup", title2: "Setup", level: 2 },
+ ];
+ const { container } = renderWithRouter(
+ ,
+ );
+
+ const wrapper = container.firstChild;
+ expect(wrapper.getAttribute("data-open")).toBeNull();
+
+ const toggleButton = screen.getByRole("button", {
+ name: /toggle getting started section/i,
+ });
+ fireEvent.click(toggleButton);
+
+ expect(wrapper.getAttribute("data-open")).toBe("true");
+
+ fireEvent.click(toggleButton);
+ expect(wrapper.getAttribute("data-open")).toBeNull();
+ });
+
+ it("renders anchor links when anchors are provided", () => {
+ const anchors = [
+ { id: "intro", title: "Introduction", title2: "Introduction", level: 2 },
+ {
+ id: "basic-setup",
+ title: "Basic Setup",
+ title2: "Basic Setup",
+ level: 2,
+ },
+ ];
+ renderWithRouter();
+ expect(screen.getByText("Introduction")).toBeTruthy();
+ expect(screen.getByText("Basic Setup")).toBeTruthy();
+ });
+
+ it("renders a bar icon when no anchors are provided", () => {
+ const { container } = renderWithRouter(
+ ,
+ );
+ // No toggle button should exist when there are no anchors
+ expect(screen.queryByRole("button")).toBeNull();
+ // The wrapper should still render
+ expect(container.firstChild).toBeTruthy();
+ });
+
+ it("matches snapshot", () => {
+ const anchors = [
+ { id: "step-1", title: "Step 1", title2: "Step 1", level: 2 },
+ ];
+ const { container } = renderWithRouter(
+ ,
+ );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap b/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap
new file mode 100644
index 000000000000..edfbd4f8f5fe
--- /dev/null
+++ b/src/components/SidebarItem/__snapshots__/SidebarItem.test.jsx.snap
@@ -0,0 +1,44 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`SidebarItem matches snapshot 1`] = `
+
+`;
diff --git a/src/styles/dark.scss b/src/styles/dark.scss
index 749990c63857..63ec251e2a54 100644
--- a/src/styles/dark.scss
+++ b/src/styles/dark.scss
@@ -98,13 +98,6 @@
}
}
}
- .sidebar-item__title,
- .sidebar-item__anchor a {
- color: #b8b8b8;
- }
- .sidebar-item__title.active {
- color: #fff;
- }
.gitter__button {
background: #1c3b39;
}
@@ -115,10 +108,6 @@
.page-links__gap {
color: #999;
}
- .sidebar-item__toggle,
- .sidebar-item--disabled .sidebar-item__toggle {
- color: #69a8ee;
- }
.site {
background: #121212 !important;
}
@@ -175,7 +164,6 @@
}
@layer base {
- [data-theme="dark"] .sidebar-item__anchor a:hover,
[data-theme="dark"] a:hover {
color: #82b7f6;
}