Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cypress/e2e/click-menu-scroll-top.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
7 changes: 5 additions & 2 deletions cypress/e2e/pr_4435.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
238 changes: 116 additions & 122 deletions src/components/SidebarItem/SidebarItem.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<ul className={`${block}__anchors`}>
{anchors.map((anchor) => (
<li
key={this._generateAnchorURL(anchor)}
className={`${block}__anchor`}
title={anchor.title}
function Anchors({ anchors, url }) {
return (
<ul className="relative hidden flex-[0_0_100%] flex-wrap my-[0.35em] pl-6 overflow-hidden list-none leading-[19px] before:content-[''] before:absolute before:h-[calc(100%-0.6em)] before:top-0 before:left-6 before:border-l before:border-dashed before:border-[#777676] group-data-[open]/item:flex">
{anchors.map((anchor) => (
<li
key={generateAnchorURL(url, anchor)}
className="relative flex-[0_0_100%] my-1 first:mt-0 last:mb-0 pl-4 overflow-hidden whitespace-nowrap text-ellipsis before:content-[''] before:absolute before:w-2 before:left-0 before:top-[10px] before:border-b before:border-dashed before:border-[#777676]"
title={anchor.title}
>
<NavLink
to={generateAnchorURL(url, anchor)}
className="text-[#2b3a42] hover:text-[#175d96] dark:text-[#b8b8b8] dark:hover:text-[#82b7f6]"
>
<NavLink to={this._generateAnchorURL(anchor)}>
{anchor.title2}
</NavLink>
{anchor.children && this.renderAnchors(anchor.children)}
</li>
))}
</ul>
);
}
{anchor.title2}
</NavLink>
{anchor.children && <Anchors anchors={anchor.children} url={url} />}
</li>
))}
</ul>
);
}

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 (
<div className={`${block} ${openMod} ${disabledMod}`}>
{anchors.length > 0 ? (
<button
className={`${block}__toggle-button`}
onClick={this._toggle.bind(this)}
aria-label={`Toggle ${title} section`}
aria-expanded={this.state.open}
>
<ChevronRightIcon
width={15}
height={17}
fill="#175d96"
className={`${block}__toggle`}
/>
</button>
) : (
<BarIcon
className={`${block}__toggle`}
useEffect(() => {
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 (
<div
className="group/item relative flex flex-wrap text-[15px] my-[0.6em]"
data-open={open || undefined}
>
{anchors.length > 0 ? (
<button
className="bg-transparent border-none p-0 flex items-center"
onClick={toggle}
aria-label={`Toggle ${title} section`}
aria-expanded={open}
>
<ChevronRightIcon
width={15}
height={17}
fill="#175d96"
className={`flex-none mt-[0.125em] mr-2 cursor-pointer text-[#175d96] dark:text-[#69a8ee] transition-all duration-250 hover:text-[#333] ${open ? "origin-center rotate-90" : ""}`}
/>
)}

<NavLink
end
key={this.props.url}
className={`${block}__title`}
to={this.props.url}
onClick={this.scrollTop}
>
{title}
</NavLink>

{anchors.length > 0 ? this.renderAnchors(tree) : null}
</div>
);
}

// 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);
}
</button>
) : (
<BarIcon
className="flex-none mt-[0.125em] mr-2 text-[#aaa] dark:text-[#69a8ee]"
width={15}
height={17}
fill="#175d96"
/>
)}

/**
* Toggles the open state (expanded/collapsed)
*
* @param {object} e - Click event
*/
_toggle() {
this.setState({
open: !this.state.open,
});
}
<NavLink
end
key={url}
data-testid="sidebar-item-title"
className={({ isActive }) =>
`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}
</NavLink>

/**
* 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 ? <Anchors anchors={tree} url={url} /> : null}
</div>
);
}

SidebarItem.propTypes = {
title: PropTypes.string,
anchors: PropTypes.array,
url: PropTypes.string,
currentPage: PropTypes.string,
};
Loading
Loading