Skip to content

Commit 4052919

Browse files
committed
feat(LanguageSelector): add keyboard navigation & improve accessibility
1 parent db184b8 commit 4052919

File tree

2 files changed

+112
-63
lines changed

2 files changed

+112
-63
lines changed

src/components/LanguageSelector.tsx

Lines changed: 59 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,102 @@
1-
import React, { useState, useRef, useEffect } from "react";
1+
import React, { useRef, useEffect } from "react";
22
import { useAppContext } from "../contexts/AppContext";
33
import { useLanguages } from "../hooks/useLanguages";
4+
import { useKeyboardNavigation } from "../hooks/useKeyboardNavigation";
45
import { LanguageType } from "../types";
56

67
// Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/
78

89
const LanguageSelector = () => {
910
const { language, setLanguage } = useAppContext();
1011
const { fetchedLanguages, loading, error } = useLanguages();
11-
12-
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
13-
const [selectedLanguage, setSelectedLanguage] =
14-
useState<LanguageType>(language);
1512
const dropdownRef = useRef<HTMLDivElement>(null);
13+
const [isOpen, setIsOpen] = React.useState(false);
1614

17-
const handleLanguageChange = (langObj: LanguageType) => {
18-
const selected = fetchedLanguages.find(
19-
(item) => item.lang === langObj.lang
20-
);
21-
if (selected) {
22-
setSelectedLanguage(selected);
23-
setLanguage(selected);
24-
setIsDropdownOpen(false);
25-
}
26-
};
27-
28-
const toggleDropdown = () => {
29-
setIsDropdownOpen((prev) => !prev);
15+
const handleSelect = (selected: LanguageType) => {
16+
setLanguage(selected);
17+
setIsOpen(false);
3018
};
3119

32-
const handleKeyDown = (event: React.KeyboardEvent, lang: LanguageType) => {
33-
if (event.key === "Enter") {
34-
handleLanguageChange(lang);
35-
} else if (event.key === "Escape") {
36-
setIsDropdownOpen(false);
37-
}
38-
};
20+
const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
21+
useKeyboardNavigation({
22+
items: fetchedLanguages,
23+
isOpen,
24+
onSelect: handleSelect,
25+
onClose: () => setIsOpen(false),
26+
});
3927

40-
useEffect(() => {
41-
const handleClickOutside = (event: MouseEvent) => {
28+
const handleBlur = () => {
29+
setTimeout(() => {
4230
if (
4331
dropdownRef.current &&
44-
!dropdownRef.current.contains(event.target as Node)
32+
!dropdownRef.current.contains(document.activeElement)
4533
) {
46-
setIsDropdownOpen(false);
34+
setIsOpen(false);
4735
}
48-
};
36+
}, 0);
37+
};
4938

50-
document.addEventListener("mousedown", handleClickOutside);
51-
return () => {
52-
document.removeEventListener("mousedown", handleClickOutside);
53-
};
54-
}, []);
39+
const toggleDropdown = () => {
40+
setIsOpen((prev) => {
41+
if (!prev) setTimeout(focusFirst, 0);
42+
return !prev;
43+
});
44+
};
5545

56-
if (loading) {
57-
return <p>Loading languages...</p>;
58-
}
46+
useEffect(() => {
47+
if (!isOpen) resetFocus();
48+
}, [isOpen]);
49+
50+
useEffect(() => {
51+
if (isOpen && focusedIndex >= 0) {
52+
const element = document.querySelector(
53+
`.selector__item:nth-child(${focusedIndex + 1})`
54+
) as HTMLElement;
55+
element?.focus();
56+
}
57+
}, [isOpen, focusedIndex]);
5958

60-
if (error) {
61-
return <p>Error fetching languages: {error}</p>;
62-
}
59+
if (loading) return <p>Loading languages...</p>;
60+
if (error) return <p>Error fetching languages: {error}</p>;
6361

6462
return (
6563
<div
66-
className={`selector ${isDropdownOpen ? "selector--open" : ""}`}
64+
className={`selector ${isOpen ? "selector--open" : ""}`}
6765
ref={dropdownRef}
66+
onBlur={handleBlur}
6867
>
6968
<button
7069
className="selector__button"
7170
aria-label="select button"
7271
aria-haspopup="listbox"
73-
aria-expanded={isDropdownOpen}
72+
aria-expanded={isOpen}
7473
onClick={toggleDropdown}
7574
>
7675
<div className="selector__value">
77-
<img src={selectedLanguage.icon} alt="" />
78-
<span>{selectedLanguage.lang || "Select a language"}</span>
76+
<img src={language.icon} alt="" />
77+
<span>{language.lang || "Select a language"}</span>
7978
</div>
80-
<span className="selector__arrow"></span>
79+
<span className="selector__arrow" />
8180
</button>
82-
{isDropdownOpen && (
83-
<ul className="selector__dropdown" role="listbox">
84-
{fetchedLanguages.map((lang) => (
81+
{isOpen && (
82+
<ul
83+
className="selector__dropdown"
84+
role="listbox"
85+
onKeyDown={handleKeyDown}
86+
tabIndex={-1}
87+
>
88+
{fetchedLanguages.map((lang, index) => (
8589
<li
8690
key={lang.lang}
8791
role="option"
88-
tabIndex={0}
89-
onClick={() => handleLanguageChange(lang)}
90-
onKeyDown={(e) => handleKeyDown(e, lang)}
92+
tabIndex={-1}
93+
onClick={() => handleSelect(lang)}
9194
className={`selector__item ${
92-
selectedLanguage.lang === lang.lang ? "selected" : ""
93-
}`}
95+
language.lang === lang.lang ? "selected" : ""
96+
} ${focusedIndex === index ? "focused" : ""}`}
97+
aria-selected={language.lang === lang.lang}
9498
>
95-
<input
96-
type="radio"
97-
id={`selector-for-${lang.lang}`}
98-
name="language"
99-
value={lang.lang}
100-
checked={selectedLanguage === lang}
101-
readOnly
102-
/>
103-
<label htmlFor={`selector-for-${lang.lang}`}>
99+
<label>
104100
<img src={lang.icon} alt="" />
105101
<span>{lang.lang}</span>
106102
</label>

src/hooks/useKeyboardNavigation.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useState } from "react";
2+
import { LanguageType } from "../types";
3+
4+
interface UseKeyboardNavigationProps {
5+
items: LanguageType[];
6+
isOpen: boolean;
7+
onSelect: (item: LanguageType) => void;
8+
onClose: () => void;
9+
}
10+
11+
export const useKeyboardNavigation = ({
12+
items,
13+
isOpen,
14+
onSelect,
15+
onClose,
16+
}: UseKeyboardNavigationProps) => {
17+
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
18+
19+
const handleKeyDown = (event: React.KeyboardEvent) => {
20+
if (!isOpen) return;
21+
22+
switch (event.key) {
23+
case "ArrowDown":
24+
event.preventDefault();
25+
setFocusedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));
26+
break;
27+
case "ArrowUp":
28+
event.preventDefault();
29+
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));
30+
break;
31+
case "Enter":
32+
event.preventDefault();
33+
if (focusedIndex >= 0) {
34+
onSelect(items[focusedIndex]);
35+
}
36+
break;
37+
case "Escape":
38+
event.preventDefault();
39+
onClose();
40+
break;
41+
}
42+
};
43+
44+
const resetFocus = () => setFocusedIndex(-1);
45+
const focusFirst = () => setFocusedIndex(0);
46+
47+
return {
48+
focusedIndex,
49+
handleKeyDown,
50+
resetFocus,
51+
focusFirst,
52+
};
53+
};

0 commit comments

Comments
 (0)