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 ( - - ); - } + {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; }