Skip to content

Commit 8060a1f

Browse files
authored
Merge pull request #32 from Yugveer06/dev
Improve Language Selector: Close on Outside Click & Blur, Add Keyboard Navigation
2 parents 80844a0 + 4052919 commit 8060a1f

File tree

2 files changed

+115
-50
lines changed

2 files changed

+115
-50
lines changed

src/components/LanguageSelector.tsx

Lines changed: 62 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,102 @@
1-
import React, { useState, useRef } 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-
}
15+
const handleSelect = (selected: LanguageType) => {
16+
setLanguage(selected);
17+
setIsOpen(false);
2618
};
2719

28-
const toggleDropdown = () => {
29-
setIsDropdownOpen((prev) => !prev);
20+
const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
21+
useKeyboardNavigation({
22+
items: fetchedLanguages,
23+
isOpen,
24+
onSelect: handleSelect,
25+
onClose: () => setIsOpen(false),
26+
});
27+
28+
const handleBlur = () => {
29+
setTimeout(() => {
30+
if (
31+
dropdownRef.current &&
32+
!dropdownRef.current.contains(document.activeElement)
33+
) {
34+
setIsOpen(false);
35+
}
36+
}, 0);
3037
};
3138

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-
}
39+
const toggleDropdown = () => {
40+
setIsOpen((prev) => {
41+
if (!prev) setTimeout(focusFirst, 0);
42+
return !prev;
43+
});
3844
};
3945

40-
if (loading) {
41-
return <p>Loading languages...</p>;
42-
}
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]);
4358

44-
if (error) {
45-
return <p>Error fetching languages: {error}</p>;
46-
}
59+
if (loading) return <p>Loading languages...</p>;
60+
if (error) return <p>Error fetching languages: {error}</p>;
4761

4862
return (
4963
<div
50-
className={`selector ${isDropdownOpen ? "selector--open" : ""}`}
64+
className={`selector ${isOpen ? "selector--open" : ""}`}
5165
ref={dropdownRef}
66+
onBlur={handleBlur}
5267
>
5368
<button
5469
className="selector__button"
5570
aria-label="select button"
5671
aria-haspopup="listbox"
57-
aria-expanded={isDropdownOpen}
72+
aria-expanded={isOpen}
5873
onClick={toggleDropdown}
5974
>
6075
<div className="selector__value">
61-
<img src={selectedLanguage.icon} alt="" />
62-
<span>{selectedLanguage.lang || "Select a language"}</span>
76+
<img src={language.icon} alt="" />
77+
<span>{language.lang || "Select a language"}</span>
6378
</div>
64-
<span className="selector__arrow"></span>
79+
<span className="selector__arrow" />
6580
</button>
66-
{isDropdownOpen && (
67-
<ul className="selector__dropdown" role="listbox">
68-
{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) => (
6989
<li
7090
key={lang.lang}
7191
role="option"
72-
tabIndex={0}
73-
onClick={() => handleLanguageChange(lang)}
74-
onKeyDown={(e) => handleKeyDown(e, lang)}
92+
tabIndex={-1}
93+
onClick={() => handleSelect(lang)}
7594
className={`selector__item ${
76-
selectedLanguage.lang === lang.lang ? "selected" : ""
77-
}`}
95+
language.lang === lang.lang ? "selected" : ""
96+
} ${focusedIndex === index ? "focused" : ""}`}
97+
aria-selected={language.lang === lang.lang}
7898
>
79-
<input
80-
type="radio"
81-
id={`selector-for-${lang.lang}`}
82-
name="language"
83-
value={lang.lang}
84-
checked={selectedLanguage === lang}
85-
readOnly
86-
/>
87-
<label htmlFor={`selector-for-${lang.lang}`}>
99+
<label>
88100
<img src={lang.icon} alt="" />
89101
<span>{lang.lang}</span>
90102
</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)