Skip to content

Commit 5a11143

Browse files
committed
refactor: modernize SidebarItem to functional component with Tailwind CSS
- Convert SidebarItem from class component to functional component with hooks - Replace deprecated UNSAFE_componentWillReceiveProps with useEffect - Migrate SidebarItem.scss to Tailwind CSS utilities (addresses #8047) - Update Print component to use Tailwind instead of removed BEM classes - Remove orphaned sidebar-item selectors from dark.scss - Add comprehensive unit tests for SidebarItem (7 test cases) - Support both light and dark mode with proper color tokens
1 parent 29288b2 commit 5a11143

6 files changed

Lines changed: 264 additions & 248 deletions

File tree

src/components/Print/Print.jsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@ export default function Print(props) {
3232
}
3333

3434
return (
35-
<div className="sidebar-item sidebar-item--disabled">
35+
<div className="relative flex flex-wrap text-[15px] my-[0.6em]">
3636
<BarIcon
37-
className="sidebar-item__toggle"
37+
className="flex-none mt-[0.125em] mr-2 text-[#aaa] dark:text-[#69a8ee]"
3838
width={15}
3939
height={17}
4040
fill="#175d96"
4141
/>
4242
<a
43-
className="sidebar-item__title sidebar-link__print"
43+
className="flex-1 max-w-[85%] overflow-hidden whitespace-nowrap text-ellipsis flex items-center flex-nowrap text-[#2b3a42] dark:text-[#b8b8b8]"
4444
href={printUrl}
4545
rel="nofollow noopener noreferrer"
4646
title="Print"
Lines changed: 115 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,143 +1,136 @@
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+
className={({ isActive }) =>
118+
`flex-1 max-w-[85%] overflow-hidden whitespace-nowrap text-ellipsis ${isActive ? "font-semibold text-[#333] dark:text-white" : "text-[#2b3a42] dark:text-[#b8b8b8]"}`
119+
}
120+
to={url}
121+
onClick={scrollTop}
122+
>
123+
{title}
124+
</NavLink>
132125

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-
}
126+
{anchors.length > 0 ? <Anchors anchors={tree} url={url} /> : null}
127+
</div>
128+
);
143129
}
130+
131+
SidebarItem.propTypes = {
132+
title: PropTypes.string,
133+
anchors: PropTypes.array,
134+
url: PropTypes.string,
135+
currentPage: PropTypes.string,
136+
};

src/components/SidebarItem/SidebarItem.scss

Lines changed: 0 additions & 111 deletions
This file was deleted.

0 commit comments

Comments
 (0)