Skip to content

Commit 7ad5e5d

Browse files
committed
consolidate Selector component logic to remove duplicate code
1 parent d395148 commit 7ad5e5d

File tree

6 files changed

+180
-201
lines changed

6 files changed

+180
-201
lines changed

src/components/HighlighterStyleSelector.tsx

Lines changed: 18 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,32 @@
1-
import { useRef, useEffect, useState } from "react";
2-
31
import { highlighterStyles } from "@consts/highlighter-styles";
42
import { useAppContext } from "@contexts/AppContext";
5-
import { useKeyboardNavigation } from "@hooks/useKeyboardNavigation";
6-
import { HighlighterStyleType } from "@types";
3+
import { SelectorOption } from "@types";
4+
5+
import Selector from "./Selector";
76

87
const HighlighterStyleSelector = () => {
98
const { highlighterStyle, setHighlighterStyle } = useAppContext();
109

11-
const dropdownRef = useRef<HTMLDivElement>(null);
12-
const [isOpen, setIsOpen] = useState(false);
10+
const options = highlighterStyles.map((style) => ({
11+
name: style.name,
12+
}));
1313

14-
const handleSelect = (selected: HighlighterStyleType) => {
14+
const handleSelect = (option: SelectorOption) => {
15+
const selected = highlighterStyles.find(
16+
(style) => style.name === option.name
17+
);
18+
if (!selected) {
19+
return;
20+
}
1521
setHighlighterStyle(selected);
16-
setIsOpen(false);
17-
};
18-
19-
const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
20-
useKeyboardNavigation({
21-
items: highlighterStyles,
22-
isOpen,
23-
onSelect: handleSelect,
24-
onClose: () => setIsOpen(false),
25-
});
26-
27-
const handleBlur = () => {
28-
setTimeout(() => {
29-
if (
30-
dropdownRef.current &&
31-
!dropdownRef.current.contains(document.activeElement)
32-
) {
33-
setIsOpen(false);
34-
}
35-
}, 0);
3622
};
3723

38-
const toggleDropdown = () => {
39-
setIsOpen((prev) => {
40-
if (!prev) setTimeout(focusFirst, 0);
41-
return !prev;
42-
});
43-
};
44-
45-
useEffect(() => {
46-
if (!isOpen) {
47-
resetFocus();
48-
}
49-
// eslint-disable-next-line react-hooks/exhaustive-deps
50-
}, [isOpen]);
51-
52-
useEffect(() => {
53-
if (isOpen && focusedIndex >= 0) {
54-
const element = document.querySelector(
55-
`.selector__item:nth-child(${focusedIndex + 1})`
56-
) as HTMLElement;
57-
element?.focus();
58-
}
59-
}, [isOpen, focusedIndex]);
60-
6124
return (
62-
<div
63-
className={`selector ${isOpen ? "selector--open" : ""}`}
64-
ref={dropdownRef}
65-
onBlur={handleBlur}
66-
>
67-
<button
68-
className="selector__button"
69-
aria-label="select button"
70-
aria-haspopup="listbox"
71-
aria-expanded={isOpen}
72-
onClick={toggleDropdown}
73-
>
74-
<div className="selector__value">
75-
<span>{highlighterStyle.name}</span>
76-
</div>
77-
<span className="selector__arrow" />
78-
</button>
79-
{isOpen && (
80-
<ul
81-
className="selector__dropdown"
82-
role="listbox"
83-
onKeyDown={handleKeyDown}
84-
tabIndex={-1}
85-
>
86-
{highlighterStyles.map((hs, index) => (
87-
<li
88-
key={hs.name}
89-
role="option"
90-
tabIndex={-1}
91-
onClick={() => handleSelect(hs)}
92-
className={`selector__item ${
93-
highlighterStyle.name === hs.name ? "selected" : ""
94-
} ${focusedIndex === index ? "focused" : ""}`}
95-
aria-selected={highlighterStyle.name === hs.name}
96-
>
97-
<label>
98-
<span>{hs.name}</span>
99-
</label>
100-
</li>
101-
))}
102-
</ul>
103-
)}
104-
</div>
25+
<Selector
26+
options={options}
27+
selectedOption={{ name: highlighterStyle.name }}
28+
handleSelect={handleSelect}
29+
/>
10530
);
10631
};
10732

src/components/LanguageSelector.tsx

Lines changed: 27 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,115 +1,46 @@
1-
import { useRef, useEffect, useState } from "react";
1+
import { useMemo } from "react";
22

33
import { useAppContext } from "@contexts/AppContext";
4-
import { useKeyboardNavigation } from "@hooks/useKeyboardNavigation";
54
import { useLanguages } from "@hooks/useLanguages";
6-
import { LanguageType } from "@types";
5+
import { SelectorOption } from "@types";
76

8-
// Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/
7+
import Selector from "./Selector";
98

109
const LanguageSelector = () => {
1110
const { language, setLanguage } = useAppContext();
1211
const { fetchedLanguages, loading, error } = useLanguages();
1312

14-
const dropdownRef = useRef<HTMLDivElement>(null);
15-
const [isOpen, setIsOpen] = useState(false);
13+
const options = useMemo(
14+
() =>
15+
fetchedLanguages.map((item) => ({
16+
name: item.lang,
17+
icon: item.icon,
18+
})),
19+
[fetchedLanguages]
20+
);
1621

17-
const handleSelect = (selected: LanguageType) => {
22+
const handleSelect = (option: SelectorOption) => {
23+
const selected = fetchedLanguages.find((lang) => lang.lang === option.name);
24+
if (!selected) {
25+
return;
26+
}
1827
setLanguage(selected);
19-
setIsOpen(false);
20-
};
21-
22-
const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
23-
useKeyboardNavigation({
24-
items: fetchedLanguages,
25-
isOpen,
26-
onSelect: handleSelect,
27-
onClose: () => setIsOpen(false),
28-
});
29-
30-
const handleBlur = () => {
31-
setTimeout(() => {
32-
if (
33-
dropdownRef.current &&
34-
!dropdownRef.current.contains(document.activeElement)
35-
) {
36-
setIsOpen(false);
37-
}
38-
}, 0);
3928
};
4029

41-
const toggleDropdown = () => {
42-
setIsOpen((prev) => {
43-
if (!prev) setTimeout(focusFirst, 0);
44-
return !prev;
45-
});
46-
};
47-
48-
useEffect(() => {
49-
if (!isOpen) {
50-
resetFocus();
51-
}
52-
// eslint-disable-next-line react-hooks/exhaustive-deps
53-
}, [isOpen]);
54-
55-
useEffect(() => {
56-
if (isOpen && focusedIndex >= 0) {
57-
const element = document.querySelector(
58-
`.selector__item:nth-child(${focusedIndex + 1})`
59-
) as HTMLElement;
60-
element?.focus();
61-
}
62-
}, [isOpen, focusedIndex]);
30+
if (loading) {
31+
return <p>Loading languages...</p>;
32+
}
6333

64-
if (loading) return <p>Loading languages...</p>;
65-
if (error) return <p>Error fetching languages: {error}</p>;
34+
if (error) {
35+
return <p>Error fetching languages: {error}</p>;
36+
}
6637

6738
return (
68-
<div
69-
className={`selector ${isOpen ? "selector--open" : ""}`}
70-
ref={dropdownRef}
71-
onBlur={handleBlur}
72-
>
73-
<button
74-
className="selector__button"
75-
aria-label="select button"
76-
aria-haspopup="listbox"
77-
aria-expanded={isOpen}
78-
onClick={toggleDropdown}
79-
>
80-
<div className="selector__value">
81-
<img src={language.icon} alt="" />
82-
<span>{language.lang || "Select a language"}</span>
83-
</div>
84-
<span className="selector__arrow" />
85-
</button>
86-
{isOpen && (
87-
<ul
88-
className="selector__dropdown"
89-
role="listbox"
90-
onKeyDown={handleKeyDown}
91-
tabIndex={-1}
92-
>
93-
{fetchedLanguages.map((lang, index) => (
94-
<li
95-
key={lang.lang}
96-
role="option"
97-
tabIndex={-1}
98-
onClick={() => handleSelect(lang)}
99-
className={`selector__item ${
100-
language.lang === lang.lang ? "selected" : ""
101-
} ${focusedIndex === index ? "focused" : ""}`}
102-
aria-selected={language.lang === lang.lang}
103-
>
104-
<label>
105-
<img src={lang.icon} alt="" />
106-
<span>{lang.lang}</span>
107-
</label>
108-
</li>
109-
))}
110-
</ul>
111-
)}
112-
</div>
39+
<Selector
40+
options={options}
41+
selectedOption={{ name: language.lang, icon: language.icon }}
42+
handleSelect={handleSelect}
43+
/>
11344
);
11445
};
11546

src/components/Selector.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/
3+
*/
4+
5+
import { FC, useEffect, useRef, useState } from "react";
6+
7+
import { useKeyboardNavigation } from "@hooks/useKeyboardNavigation";
8+
import { SelectorOption } from "@types";
9+
10+
interface SelectorProps {
11+
options: Array<SelectorOption>;
12+
selectedOption: SelectorOption;
13+
handleSelect: (option: SelectorOption) => void;
14+
}
15+
16+
const Selector: FC<SelectorProps> = (props) => {
17+
const { options, selectedOption, handleSelect } = props;
18+
19+
const dropdownRef = useRef<HTMLDivElement>(null);
20+
const [isOpen, setIsOpen] = useState(false);
21+
22+
const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
23+
useKeyboardNavigation({
24+
options,
25+
isOpen,
26+
onSelect: handleSelect,
27+
onClose: () => setIsOpen(false),
28+
});
29+
30+
const handleBlur = () => {
31+
setTimeout(() => {
32+
if (
33+
dropdownRef.current &&
34+
!dropdownRef.current.contains(document.activeElement)
35+
) {
36+
setIsOpen(false);
37+
}
38+
}, 0);
39+
};
40+
41+
const toggleDropdown = () => {
42+
setIsOpen((prev) => {
43+
if (!prev) setTimeout(focusFirst, 0);
44+
return !prev;
45+
});
46+
};
47+
48+
useEffect(() => {
49+
if (!isOpen) {
50+
resetFocus();
51+
}
52+
// eslint-disable-next-line react-hooks/exhaustive-deps
53+
}, [isOpen]);
54+
55+
useEffect(() => {
56+
if (isOpen && focusedIndex >= 0) {
57+
const element = document.querySelector(
58+
`.selector__item:nth-child(${focusedIndex + 1})`
59+
) as HTMLElement;
60+
element?.focus();
61+
}
62+
}, [isOpen, focusedIndex]);
63+
64+
return (
65+
<div
66+
className={`selector ${isOpen ? "selector--open" : ""}`}
67+
ref={dropdownRef}
68+
onBlur={handleBlur}
69+
>
70+
<button
71+
className="selector__button"
72+
aria-label="select button"
73+
aria-haspopup="listbox"
74+
aria-expanded={isOpen}
75+
onClick={toggleDropdown}
76+
>
77+
<div className="selector__value">
78+
{selectedOption.icon && <img src={selectedOption.icon} alt="" />}
79+
<span>{selectedOption.name}</span>
80+
</div>
81+
<span className="selector__arrow" />
82+
</button>
83+
{isOpen && (
84+
<ul
85+
className="selector__dropdown"
86+
role="listbox"
87+
onKeyDown={handleKeyDown}
88+
tabIndex={-1}
89+
>
90+
{options.map((item, index) => (
91+
<li
92+
key={item.name}
93+
role="option"
94+
tabIndex={-1}
95+
onClick={() => {
96+
handleSelect(item);
97+
setIsOpen(false);
98+
}}
99+
className={`selector__item ${
100+
selectedOption.name === item.name ? "selected" : ""
101+
} ${focusedIndex === index ? "focused" : ""}`}
102+
aria-selected={selectedOption.name === item.name}
103+
>
104+
<label>
105+
{item.icon && <img src={item.icon} alt="" />}
106+
<span>{item.name as string}</span>
107+
</label>
108+
</li>
109+
))}
110+
</ul>
111+
)}
112+
</div>
113+
);
114+
};
115+
116+
export default Selector;

0 commit comments

Comments
 (0)