Skip to content

Commit 00a40ed

Browse files
authored
refactor(Dropdown): rewrite class component to functional (#8171)
1 parent 0c7bd7d commit 00a40ed

File tree

3 files changed

+363
-129
lines changed

3 files changed

+363
-129
lines changed
Lines changed: 129 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,53 @@
11
import PropTypes from "prop-types";
2-
import { Component } from "react";
2+
import { useEffect, useRef, useState } from "react";
33

4-
export default class Dropdown extends Component {
5-
static propTypes = {
6-
className: PropTypes.string,
7-
items: PropTypes.array,
8-
};
9-
10-
state = {
11-
active: false,
12-
};
4+
export default function Dropdown({ className = "", items = [] }) {
5+
const [active, setActive] = useState(false);
6+
const activeRef = useRef(false);
7+
const dropdownRef = useRef(null);
8+
const dropdownButtonRef = useRef(null);
9+
const linksRef = useRef([]);
10+
const openedByClickRef = useRef(false);
1311

14-
componentDidMount() {
15-
document.addEventListener("keyup", this._closeDropdownOnEsc, true);
16-
document.addEventListener("focus", this._closeDropdownIfFocusLost, true);
17-
document.addEventListener("click", this._closeDropdownIfFocusLost, true);
18-
}
12+
// Keep activeRef in sync for use inside stable event listeners
13+
useEffect(() => {
14+
activeRef.current = active;
15+
}, [active]);
1916

20-
componentWillUnmount() {
21-
document.removeEventListener("keyup", this._closeDropdownOnEsc, true);
22-
document.removeEventListener("focus", this._closeDropdownIfFocusLost, true);
23-
document.removeEventListener("click", this._closeDropdownIfFocusLost, true);
24-
}
17+
useEffect(() => {
18+
const handleEsc = (event) => {
19+
if (event.key === "Escape" && activeRef.current) {
20+
setActive(false);
21+
dropdownButtonRef.current.focus();
22+
}
23+
};
2524

26-
_closeDropdownOnEsc = (event) => {
27-
if (event.key === "Escape" && this.state.active) {
28-
this.setState({ active: false }, () => {
29-
this.dropdownButton.focus();
30-
});
31-
}
32-
};
25+
const handleFocusLost = (event) => {
26+
if (activeRef.current && !dropdownRef.current.contains(event.target)) {
27+
setActive(false);
28+
}
29+
};
3330

34-
_closeDropdownIfFocusLost = (event) => {
35-
if (this.state.active && !this.dropdown.contains(event.target)) {
36-
this.setState({ active: false });
37-
}
38-
};
31+
document.addEventListener("keyup", handleEsc, true);
32+
document.addEventListener("focus", handleFocusLost, true);
33+
document.addEventListener("click", handleFocusLost, true);
3934

40-
render() {
41-
const { className = "", items = [] } = this.props;
35+
return () => {
36+
document.removeEventListener("keyup", handleEsc, true);
37+
document.removeEventListener("focus", handleFocusLost, true);
38+
document.removeEventListener("click", handleFocusLost, true);
39+
};
40+
}, []);
4241

43-
return (
44-
<nav
45-
className={`relative ${className}`}
46-
ref={(el) => (this.dropdown = el)}
47-
onMouseOver={this._handleMouseOver}
48-
onMouseLeave={this._handleMouseLeave}
49-
>
50-
<button
51-
ref={(el) => (this.dropdownButton = el)}
52-
aria-haspopup="true"
53-
aria-expanded={String(this.state.active)}
54-
aria-label="Select language"
55-
onClick={this._handleClick}
56-
className="cursor-pointer text-white border-none bg-transparent m-0 p-0 text-[length:inherit] flex items-center"
57-
>
58-
<svg
59-
viewBox="0 0 610 560"
60-
xmlns="http://www.w3.org/2000/svg"
61-
className="w-20 h-20 align-middle text-gray-100 hover:text-blue-200 transition-colors duration-200"
62-
>
63-
<path d="m304.8 99.2-162.5-57.3v353.6l162.5-52.6z" />
64-
<path
65-
d="m300.9 99 168.7-57.3v353.6l-168.7-52.5zm-200.7 358.4 200.7-66.9v-291.5l-200.7 66.9z"
66-
fill="currentColor"
67-
/>
68-
<path d="m392.4 461.8 28.4 46.8 15-43.5zm-223.9-262.3c-1.1-1 1.4 8.6 4.8 12 6.1 6.1 10.8 6.9 13.3 7 5.6.2 12.5-1.4 16.5-3.1s10.9-5.2 13.5-10.4c.6-1.1 2.1-3 1.1-7.5-.7-3.5-3-4.8-5.7-4.6s-11 2.4-15 3.6-12.2 3.7-15.8 4.5-11.5-.3-12.7-1.5zm101.2 114.8c-1.6-.6-34.3-14.1-38.9-16.4a368 368 0 0 0 -17.5-7.5c12.3-19 20.1-33.4 21.2-35.6 1.9-4 15-29.5 15.3-31.1s.7-7.5.4-8.9-5.1 1.3-11.6 3.5-18.9 10.2-23.7 11.2-20.1 6.8-28 9.4c-7.8 2.6-22.7 7.1-28.8 8.8-6.1 1.6-11.4 1.8-14.9 2.8 0 0 .5 4.8 1.4 6.2s4.1 5 7.9 5.9c3.8 1 10 .6 12.8-.1s7.7-3.1 8.4-4.1c.7-1.1-.3-4.3.8-5.3s16.1-4.5 21.7-6.2 27.2-9.2 30.1-8.8a916 916 0 0 1 -23.9 47.7 821 821 0 0 1 -45 63.3c-5.3 6-18 21.5-22.4 25 1.1.3 9-.4 10.4-1.3 8.9-5.5 23.8-24.1 28.6-29.7a489.1 489.1 0 0 0 36.7-49.4c1.9.8 17.6 13.6 21.7 16.4a293 293 0 0 0 23.7 13.3c3.5 1.5 16.9 7.7 17.5 5.6.7-1.8-2.3-14.1-3.9-14.7z" />
69-
<path
70-
clipRule="evenodd"
71-
d="m194.1 484.7a204.9 204.9 0 0 0 30.7 14.5 233.6 233.6 0 0 0 46.4 12c.5 0 16 1.9 19.2 1.9h15.7c6.1-.5 11.8-.9 17.9-1.7 4.9-.7 10.3-1.6 15.5-2.8 3.8-.9 7.8-1.7 11.7-3 3.7-1 7.8-2.4 11.8-3.8l8.2-3.1c2.3-1 5.1-2.3 7.7-3.3a243 243 0 0 0 19.2-10c2.3-1.2 7.5-5.2 10.3-5.2 3.1 0 5.2 2.8 5.2 5.2 0 5.1-6.8 6.6-9.9 8.9-3.3 2.3-7.3 4-10.8 5.9-7 3.7-14.1 6.8-20.9 9.4a251 251 0 0 1 -27.3 8.5c-3.3.7-6.6 1.6-9.9 2.1-1.7.3-19.9 3.1-24.9 3.1h-23a293.9 293.9 0 0 1 -35.1-5.2 196 196 0 0 1 -33.1-10.3c-12-4.5-24.6-10.5-36.4-18.3-2.1-1.4-2.3-2.8-2.3-4.4a5 5 0 0 1 5.1-5.1c2.4.1 8 4.2 9 4.7zm101.4-98.1-189.9 63.2v-280.1l189.9-63.2zm10.6-288.3v292.7a6 6 0 0 1 -1.2 2.6c-.3.5-1 1.2-1.6 1.4a14621 14621 0 0 1 -203.1 67.6c-2.1 0-4-1.4-5.1-3.7 0-.2-.2-.3-.2-.7v-292.8c.3-.9.5-2.1 1.2-2.8 1.4-1.9 3.8-2.3 5.4-2.8 3-1 196.1-65.8 198.9-65.8 1.9 0 5.7 1.2 5.7 4.3z"
72-
fillRule="evenodd"
73-
/>
74-
<path
75-
clipRule="evenodd"
76-
d="m464.3 388-158-49.1v-236l158-53.7zm10.6-345.8v352.4c-.2 4-3 5.7-5.6 5.7-2.3 0-18.6-5.6-21.4-6.4l-65.8-20.4-14.6-4.7-12.9-4c-18.6-5.7-37.6-11.5-56.3-17.8-.7-.2-2.4-2.6-2.4-3.1v-246.1c.3-.9.7-1.9 1.6-2.6 1.4-1.6 61.1-21.4 84.7-29.3 6.3-2.3 84.8-29.3 87.3-29.3 3 .1 5.4 2.3 5.4 5.6z"
77-
fillRule="evenodd"
78-
/>
79-
<path d="m515 461.8-211.1-67.3.9-292.8 210.2 66.9z" />
80-
<path
81-
clipRule="evenodd"
82-
d="m408.6 232.5-20.7 50.1 38.1 11.5zm-12.4-47.2 27.2 8.2 49.5 178.6-27.9-8.5-10-36.7-57.7-17.5-12.4 29.9-27.9-8.5z"
83-
fill="currentColor"
84-
fillRule="evenodd"
85-
/>
86-
</svg>
87-
{/* Commented out until media breakpoints are in place
88-
<span>{ items[0].title }</span> */}
89-
<i aria-hidden="true" className="leading-none before:content-['▾']" />
90-
</button>
91-
<div
92-
className={`${
93-
this.state.active ? "block" : "hidden"
94-
} absolute top-full right-0 text-[13px] bg-[#526b78] rounded-md shadow-[0_4px_12px_rgba(0,0,0,0.4)] z-[9999]`}
95-
>
96-
<ul className="pt-1">
97-
{items.map((item, i) => (
98-
<li
99-
key={item.title}
100-
className="py-1 px-2 list-none text-white transition-all duration-[250ms] hover:bg-[#175d96]"
101-
>
102-
<a
103-
onKeyDown={(event) =>
104-
this._handleArrowKeys(i, items.length - 1, event)
105-
}
106-
ref={(node) =>
107-
this.links ? this.links.push(node) : (this.links = [node])
108-
}
109-
href={item.url}
110-
className="px-5 block text-white visited:text-white hover:text-white"
111-
>
112-
<span lang={item.lang} className="align-top text-left">
113-
{item.title}
114-
</span>
115-
</a>
116-
</li>
117-
))}
118-
</ul>
119-
</div>
120-
</nav>
121-
);
122-
}
42+
// Focus first link when opened via click
43+
useEffect(() => {
44+
if (active && openedByClickRef.current && linksRef.current.length > 0) {
45+
linksRef.current[0].focus();
46+
openedByClickRef.current = false;
47+
}
48+
}, [active]);
12349

124-
_handleArrowKeys = (currentIndex, lastIndex, event) => {
50+
const handleArrowKeys = (currentIndex, lastIndex, event) => {
12551
if (["ArrowDown", "ArrowUp"].includes(event.key)) {
12652
event.preventDefault();
12753
}
@@ -141,22 +67,96 @@ export default class Dropdown extends Component {
14167
}
14268
}
14369

144-
this.links[newIndex].focus();
145-
};
146-
147-
_handleClick = () => {
148-
this.setState({ active: !this.state.active }, () => {
149-
if (this.state.active) {
150-
this.links[0].focus();
151-
}
152-
});
70+
linksRef.current[newIndex].focus();
15371
};
15472

155-
_handleMouseOver = () => {
156-
this.setState({ active: true });
73+
const handleClick = () => {
74+
openedByClickRef.current = true;
75+
setActive((prev) => !prev);
15776
};
15877

159-
_handleMouseLeave = () => {
160-
this.setState({ active: false });
161-
};
78+
return (
79+
<nav
80+
className={`relative ${className}`}
81+
ref={dropdownRef}
82+
onMouseOver={() => setActive(true)}
83+
onMouseLeave={() => setActive(false)}
84+
>
85+
<button
86+
ref={dropdownButtonRef}
87+
aria-haspopup="true"
88+
aria-expanded={String(active)}
89+
aria-label="Select language"
90+
onClick={handleClick}
91+
className="cursor-pointer text-white border-none bg-transparent m-0 p-0 text-[length:inherit] flex items-center"
92+
>
93+
<svg
94+
viewBox="0 0 610 560"
95+
xmlns="http://www.w3.org/2000/svg"
96+
className="w-20 h-20 align-middle text-gray-100 hover:text-blue-200 transition-colors duration-200"
97+
>
98+
<path d="m304.8 99.2-162.5-57.3v353.6l162.5-52.6z" />
99+
<path
100+
d="m300.9 99 168.7-57.3v353.6l-168.7-52.5zm-200.7 358.4 200.7-66.9v-291.5l-200.7 66.9z"
101+
fill="currentColor"
102+
/>
103+
<path d="m392.4 461.8 28.4 46.8 15-43.5zm-223.9-262.3c-1.1-1 1.4 8.6 4.8 12 6.1 6.1 10.8 6.9 13.3 7 5.6.2 12.5-1.4 16.5-3.1s10.9-5.2 13.5-10.4c.6-1.1 2.1-3 1.1-7.5-.7-3.5-3-4.8-5.7-4.6s-11 2.4-15 3.6-12.2 3.7-15.8 4.5-11.5-.3-12.7-1.5zm101.2 114.8c-1.6-.6-34.3-14.1-38.9-16.4a368 368 0 0 0 -17.5-7.5c12.3-19 20.1-33.4 21.2-35.6 1.9-4 15-29.5 15.3-31.1s.7-7.5.4-8.9-5.1 1.3-11.6 3.5-18.9 10.2-23.7 11.2-20.1 6.8-28 9.4c-7.8 2.6-22.7 7.1-28.8 8.8-6.1 1.6-11.4 1.8-14.9 2.8 0 0 .5 4.8 1.4 6.2s4.1 5 7.9 5.9c3.8 1 10 .6 12.8-.1s7.7-3.1 8.4-4.1c.7-1.1-.3-4.3.8-5.3s16.1-4.5 21.7-6.2 27.2-9.2 30.1-8.8a916 916 0 0 1 -23.9 47.7 821 821 0 0 1 -45 63.3c-5.3 6-18 21.5-22.4 25 1.1.3 9-.4 10.4-1.3 8.9-5.5 23.8-24.1 28.6-29.7a489.1 489.1 0 0 0 36.7-49.4c1.9.8 17.6 13.6 21.7 16.4a293 293 0 0 0 23.7 13.3c3.5 1.5 16.9 7.7 17.5 5.6.7-1.8-2.3-14.1-3.9-14.7z" />
104+
<path
105+
clipRule="evenodd"
106+
d="m194.1 484.7a204.9 204.9 0 0 0 30.7 14.5 233.6 233.6 0 0 0 46.4 12c.5 0 16 1.9 19.2 1.9h15.7c6.1-.5 11.8-.9 17.9-1.7 4.9-.7 10.3-1.6 15.5-2.8 3.8-.9 7.8-1.7 11.7-3 3.7-1 7.8-2.4 11.8-3.8l8.2-3.1c2.3-1 5.1-2.3 7.7-3.3a243 243 0 0 0 19.2-10c2.3-1.2 7.5-5.2 10.3-5.2 3.1 0 5.2 2.8 5.2 5.2 0 5.1-6.8 6.6-9.9 8.9-3.3 2.3-7.3 4-10.8 5.9-7 3.7-14.1 6.8-20.9 9.4a251 251 0 0 1 -27.3 8.5c-3.3.7-6.6 1.6-9.9 2.1-1.7.3-19.9 3.1-24.9 3.1h-23a293.9 293.9 0 0 1 -35.1-5.2 196 196 0 0 1 -33.1-10.3c-12-4.5-24.6-10.5-36.4-18.3-2.1-1.4-2.3-2.8-2.3-4.4a5 5 0 0 1 5.1-5.1c2.4.1 8 4.2 9 4.7zm101.4-98.1-189.9 63.2v-280.1l189.9-63.2zm10.6-288.3v292.7a6 6 0 0 1 -1.2 2.6c-.3.5-1 1.2-1.6 1.4a14621 14621 0 0 1 -203.1 67.6c-2.1 0-4-1.4-5.1-3.7 0-.2-.2-.3-.2-.7v-292.8c.3-.9.5-2.1 1.2-2.8 1.4-1.9 3.8-2.3 5.4-2.8 3-1 196.1-65.8 198.9-65.8 1.9 0 5.7 1.2 5.7 4.3z"
107+
fillRule="evenodd"
108+
/>
109+
<path
110+
clipRule="evenodd"
111+
d="m464.3 388-158-49.1v-236l158-53.7zm10.6-345.8v352.4c-.2 4-3 5.7-5.6 5.7-2.3 0-18.6-5.6-21.4-6.4l-65.8-20.4-14.6-4.7-12.9-4c-18.6-5.7-37.6-11.5-56.3-17.8-.7-.2-2.4-2.6-2.4-3.1v-246.1c.3-.9.7-1.9 1.6-2.6 1.4-1.6 61.1-21.4 84.7-29.3 6.3-2.3 84.8-29.3 87.3-29.3 3 .1 5.4 2.3 5.4 5.6z"
112+
fillRule="evenodd"
113+
/>
114+
<path d="m515 461.8-211.1-67.3.9-292.8 210.2 66.9z" />
115+
<path
116+
clipRule="evenodd"
117+
d="m408.6 232.5-20.7 50.1 38.1 11.5zm-12.4-47.2 27.2 8.2 49.5 178.6-27.9-8.5-10-36.7-57.7-17.5-12.4 29.9-27.9-8.5z"
118+
fill="currentColor"
119+
fillRule="evenodd"
120+
/>
121+
</svg>
122+
{/* Commented out until media breakpoints are in place
123+
<span>{ items[0].title }</span> */}
124+
<i aria-hidden="true" className="leading-none before:content-['▾']" />
125+
</button>
126+
<div
127+
className={`${
128+
active ? "block" : "hidden"
129+
} absolute top-full right-0 text-[13px] bg-[#526b78] rounded-md shadow-[0_4px_12px_rgba(0,0,0,0.4)] z-[9999]`}
130+
>
131+
<ul className="pt-1">
132+
{items.map((item, i) => (
133+
<li
134+
key={item.title}
135+
className="py-1 px-2 list-none text-white transition-all duration-[250ms] hover:bg-[#175d96]"
136+
>
137+
<a
138+
onKeyDown={(event) =>
139+
handleArrowKeys(i, items.length - 1, event)
140+
}
141+
ref={(node) => {
142+
linksRef.current[i] = node;
143+
}}
144+
href={item.url}
145+
className="px-5 block text-white visited:text-white hover:text-white"
146+
>
147+
<span lang={item.lang} className="align-top text-left">
148+
{item.title}
149+
</span>
150+
</a>
151+
</li>
152+
))}
153+
</ul>
154+
</div>
155+
</nav>
156+
);
162157
}
158+
159+
Dropdown.propTypes = {
160+
className: PropTypes.string,
161+
items: PropTypes.array,
162+
};

0 commit comments

Comments
 (0)