Skip to content

Commit ea8c0c1

Browse files
authored
fix: modernize SidebarItem to functional component with Tailwind… (#8100)
1 parent 9022f38 commit ea8c0c1

File tree

7 files changed

+270
-256
lines changed

7 files changed

+270
-256
lines changed

cypress/e2e/click-menu-scroll-top.cy.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ describe("Click menu", () => {
77
// note that there's no hash in url
88
cy.get('[data-testid="contributors"]').scrollIntoView();
99

10-
const selector = '.sidebar-item__title[href="/concepts/modules/"]';
10+
const selector =
11+
'[data-testid="sidebar-item-title"][href="/concepts/modules/"]';
1112

1213
cy.get(selector).click();
1314
cy.window().then((win) => {

cypress/e2e/pr_4435.cy.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ describe("Open page in new tab", { scrollBehavior: false }, () => {
1010
},
1111
});
1212
// wait for page content to load before asserting scroll
13-
cy.get('.sidebar-item__title[href="/concepts/plugins/"]').should("exist");
13+
cy.get(
14+
'[data-testid="sidebar-item-title"][href="/concepts/plugins/"]',
15+
).should("exist");
1416
// there's one call in Page.jsx when componentDidMount
1517
cy.window().should((win) => {
1618
expect(win.scrollTo).to.be.calledOnce;
1719
});
1820

19-
const selector = '.sidebar-item__title[href="/concepts/plugins/"]';
21+
const selector =
22+
'[data-testid="sidebar-item-title"][href="/concepts/plugins/"]';
2023

2124
// we click the menu
2225
cy.get(selector).click();
Lines changed: 116 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,143 +1,137 @@
11
import PropTypes from "prop-types";
2-
import { Component } from "react";
3-
import "./SidebarItem.scss";
2+
import { useCallback, useEffect, useState } from "react";
43
import { NavLink } from "react-router-dom";
54
import ChevronRightIcon from "../../styles/icons/chevron-right.svg";
65
import BarIcon from "../../styles/icons/vertical-bar.svg";
76
import list2Tree from "../../utilities/list2Tree/index.js";
87

9-
const block = "sidebar-item";
10-
11-
export default class SidebarItem extends Component {
12-
static propTypes = {
13-
title: PropTypes.string,
14-
anchors: PropTypes.array,
15-
url: PropTypes.string,
16-
currentPage: PropTypes.string,
17-
};
8+
/**
9+
* Checks whether the sidebar item should be expanded
10+
* based on whether the current page URL matches this item's URL.
11+
*
12+
* @param {string} currentPage - The current page pathname
13+
* @param {string} url - The sidebar item URL
14+
* @returns {boolean}
15+
*/
16+
function isOpen(currentPage, url) {
17+
return new RegExp(`${currentPage}/?$`).test(url);
18+
}
1819

19-
state = {
20-
open: this._isOpen(this.props),
21-
};
20+
/**
21+
* Generate the url for the given anchor depending on the current page
22+
*
23+
* @param {string} url - The base URL
24+
* @param {object} anchor - The anchor object containing its id
25+
* @returns {string}
26+
*/
27+
function generateAnchorURL(url, anchor) {
28+
return anchor.id ? `${url}#${anchor.id}` : url;
29+
}
2230

23-
scrollTop(event) {
24-
// there're two cases
25-
// 1. location.pathname or location.hash changes which will be handled by useEffect in Page.jsx
26-
// 2. location.pathname and location.hash doesn't change at all
27-
if (window.location.hash !== "") {
28-
// case 1
29-
return;
30-
}
31-
if (!event.metaKey && !event.ctrlKey) {
32-
// case 2
33-
window.scrollTo(0, 0);
34-
}
31+
function scrollTop(event) {
32+
// there're two cases
33+
// 1. location.pathname or location.hash changes which will be handled by useEffect in Page.jsx
34+
// 2. location.pathname and location.hash doesn't change at all
35+
if (window.location.hash !== "") {
36+
// case 1
37+
return;
3538
}
39+
if (!event.metaKey && !event.ctrlKey) {
40+
// case 2
41+
window.scrollTo(0, 0);
42+
}
43+
}
3644

37-
renderAnchors(anchors) {
38-
return (
39-
<ul className={`${block}__anchors`}>
40-
{anchors.map((anchor) => (
41-
<li
42-
key={this._generateAnchorURL(anchor)}
43-
className={`${block}__anchor`}
44-
title={anchor.title}
45+
function Anchors({ anchors, url }) {
46+
return (
47+
<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">
48+
{anchors.map((anchor) => (
49+
<li
50+
key={generateAnchorURL(url, anchor)}
51+
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]"
52+
title={anchor.title}
53+
>
54+
<NavLink
55+
to={generateAnchorURL(url, anchor)}
56+
className="text-[#2b3a42] hover:text-[#175d96] dark:text-[#b8b8b8] dark:hover:text-[#82b7f6]"
4557
>
46-
<NavLink to={this._generateAnchorURL(anchor)}>
47-
{anchor.title2}
48-
</NavLink>
49-
{anchor.children && this.renderAnchors(anchor.children)}
50-
</li>
51-
))}
52-
</ul>
53-
);
54-
}
58+
{anchor.title2}
59+
</NavLink>
60+
{anchor.children && <Anchors anchors={anchor.children} url={url} />}
61+
</li>
62+
))}
63+
</ul>
64+
);
65+
}
5566

56-
render() {
57-
const { title, anchors = [] } = this.props;
58-
const openMod = this.state.open ? `${block}--open` : "";
59-
const disabledMod = anchors.length === 0 ? `${block}--disabled` : "";
67+
Anchors.propTypes = {
68+
anchors: PropTypes.array.isRequired,
69+
url: PropTypes.string.isRequired,
70+
};
6071

61-
const filteredAnchors = anchors.filter((anchor) => anchor.level > 1);
62-
const tree = list2Tree(title, filteredAnchors);
72+
export default function SidebarItem({ title, anchors = [], url, currentPage }) {
73+
const [open, setOpen] = useState(() => isOpen(currentPage, url));
6374

64-
return (
65-
<div className={`${block} ${openMod} ${disabledMod}`}>
66-
{anchors.length > 0 ? (
67-
<button
68-
className={`${block}__toggle-button`}
69-
onClick={this._toggle.bind(this)}
70-
aria-label={`Toggle ${title} section`}
71-
aria-expanded={this.state.open}
72-
>
73-
<ChevronRightIcon
74-
width={15}
75-
height={17}
76-
fill="#175d96"
77-
className={`${block}__toggle`}
78-
/>
79-
</button>
80-
) : (
81-
<BarIcon
82-
className={`${block}__toggle`}
75+
useEffect(() => {
76+
setOpen(isOpen(currentPage, url));
77+
}, [currentPage, url]);
78+
79+
const toggle = useCallback(() => {
80+
setOpen((prev) => !prev);
81+
}, []);
82+
83+
const filteredAnchors = anchors.filter((anchor) => anchor.level > 1);
84+
const tree = list2Tree(title, filteredAnchors);
85+
86+
return (
87+
<div
88+
className="group/item relative flex flex-wrap text-[15px] my-[0.6em]"
89+
data-open={open || undefined}
90+
>
91+
{anchors.length > 0 ? (
92+
<button
93+
className="bg-transparent border-none p-0 flex items-center"
94+
onClick={toggle}
95+
aria-label={`Toggle ${title} section`}
96+
aria-expanded={open}
97+
>
98+
<ChevronRightIcon
8399
width={15}
84100
height={17}
85101
fill="#175d96"
102+
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" : ""}`}
86103
/>
87-
)}
88-
89-
<NavLink
90-
end
91-
key={this.props.url}
92-
className={`${block}__title`}
93-
to={this.props.url}
94-
onClick={this.scrollTop}
95-
>
96-
{title}
97-
</NavLink>
98-
99-
{anchors.length > 0 ? this.renderAnchors(tree) : null}
100-
</div>
101-
);
102-
}
103-
104-
// eslint-disable-next-line camelcase
105-
UNSAFE_componentWillReceiveProps(nextProps) {
106-
if (nextProps.currentPage !== this.props.currentPage) {
107-
this.setState({
108-
open: this._isOpen(nextProps),
109-
});
110-
}
111-
}
112-
113-
/**
114-
* Checks whether the item should be expanded
115-
*
116-
* @param {object} props - The current props
117-
*/
118-
_isOpen(props) {
119-
return new RegExp(`${props.currentPage}/?$`).test(props.url);
120-
}
104+
</button>
105+
) : (
106+
<BarIcon
107+
className="flex-none mt-[0.125em] mr-2 text-[#aaa] dark:text-[#69a8ee]"
108+
width={15}
109+
height={17}
110+
fill="#175d96"
111+
/>
112+
)}
121113

122-
/**
123-
* Toggles the open state (expanded/collapsed)
124-
*
125-
* @param {object} e - Click event
126-
*/
127-
_toggle() {
128-
this.setState({
129-
open: !this.state.open,
130-
});
131-
}
114+
<NavLink
115+
end
116+
key={url}
117+
data-testid="sidebar-item-title"
118+
className={({ isActive }) =>
119+
`flex-1 max-w-[85%] overflow-hidden whitespace-nowrap text-ellipsis ${isActive ? "font-semibold text-[#333] dark:text-white" : "text-[#2b3a42] dark:text-[#b8b8b8]"}`
120+
}
121+
to={url}
122+
onClick={scrollTop}
123+
>
124+
{title}
125+
</NavLink>
132126

133-
/**
134-
* Generate the url for the given [anchor] depending on the current page
135-
*
136-
* @param {object} anchor - The anchor object containing its id
137-
* @returns {string}
138-
*/
139-
_generateAnchorURL(anchor) {
140-
const { url } = this.props;
141-
return anchor.id ? `${url}#${anchor.id}` : url;
142-
}
127+
{anchors.length > 0 ? <Anchors anchors={tree} url={url} /> : null}
128+
</div>
129+
);
143130
}
131+
132+
SidebarItem.propTypes = {
133+
title: PropTypes.string,
134+
anchors: PropTypes.array,
135+
url: PropTypes.string,
136+
currentPage: PropTypes.string,
137+
};

0 commit comments

Comments
 (0)