From db184b879bec6dc6b63ee9cde8cda9785d47b47f Mon Sep 17 00:00:00 2001 From: Yugveer Singh Date: Mon, 30 Dec 2024 18:52:11 +0530 Subject: [PATCH 1/2] feat: close language selector when clicking outside --- src/components/LanguageSelector.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/LanguageSelector.tsx b/src/components/LanguageSelector.tsx index 28e0004c..668b4206 100644 --- a/src/components/LanguageSelector.tsx +++ b/src/components/LanguageSelector.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, useEffect } from "react"; import { useAppContext } from "../contexts/AppContext"; import { useLanguages } from "../hooks/useLanguages"; import { LanguageType } from "../types"; @@ -37,6 +37,22 @@ const LanguageSelector = () => { } }; + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + if (loading) { return

Loading languages...

; } From 4052919c3fea0ca704baac9984855d4b0b0f961c Mon Sep 17 00:00:00 2001 From: Yugveer Singh Date: Mon, 30 Dec 2024 19:33:04 +0530 Subject: [PATCH 2/2] feat(LanguageSelector): add keyboard navigation & improve accessibility --- src/components/LanguageSelector.tsx | 122 ++++++++++++++-------------- src/hooks/useKeyboardNavigation.ts | 53 ++++++++++++ 2 files changed, 112 insertions(+), 63 deletions(-) create mode 100644 src/hooks/useKeyboardNavigation.ts diff --git a/src/components/LanguageSelector.tsx b/src/components/LanguageSelector.tsx index 668b4206..3080b12a 100644 --- a/src/components/LanguageSelector.tsx +++ b/src/components/LanguageSelector.tsx @@ -1,6 +1,7 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useRef, useEffect } from "react"; import { useAppContext } from "../contexts/AppContext"; import { useLanguages } from "../hooks/useLanguages"; +import { useKeyboardNavigation } from "../hooks/useKeyboardNavigation"; import { LanguageType } from "../types"; // Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/ @@ -8,99 +9,94 @@ import { LanguageType } from "../types"; const LanguageSelector = () => { const { language, setLanguage } = useAppContext(); const { fetchedLanguages, loading, error } = useLanguages(); - - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [selectedLanguage, setSelectedLanguage] = - useState(language); const dropdownRef = useRef(null); + const [isOpen, setIsOpen] = React.useState(false); - const handleLanguageChange = (langObj: LanguageType) => { - const selected = fetchedLanguages.find( - (item) => item.lang === langObj.lang - ); - if (selected) { - setSelectedLanguage(selected); - setLanguage(selected); - setIsDropdownOpen(false); - } - }; - - const toggleDropdown = () => { - setIsDropdownOpen((prev) => !prev); + const handleSelect = (selected: LanguageType) => { + setLanguage(selected); + setIsOpen(false); }; - const handleKeyDown = (event: React.KeyboardEvent, lang: LanguageType) => { - if (event.key === "Enter") { - handleLanguageChange(lang); - } else if (event.key === "Escape") { - setIsDropdownOpen(false); - } - }; + const { focusedIndex, handleKeyDown, resetFocus, focusFirst } = + useKeyboardNavigation({ + items: fetchedLanguages, + isOpen, + onSelect: handleSelect, + onClose: () => setIsOpen(false), + }); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { + const handleBlur = () => { + setTimeout(() => { if ( dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) + !dropdownRef.current.contains(document.activeElement) ) { - setIsDropdownOpen(false); + setIsOpen(false); } - }; + }, 0); + }; - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); + const toggleDropdown = () => { + setIsOpen((prev) => { + if (!prev) setTimeout(focusFirst, 0); + return !prev; + }); + }; - if (loading) { - return

Loading languages...

; - } + useEffect(() => { + if (!isOpen) resetFocus(); + }, [isOpen]); + + useEffect(() => { + if (isOpen && focusedIndex >= 0) { + const element = document.querySelector( + `.selector__item:nth-child(${focusedIndex + 1})` + ) as HTMLElement; + element?.focus(); + } + }, [isOpen, focusedIndex]); - if (error) { - return

Error fetching languages: {error}

; - } + if (loading) return

Loading languages...

; + if (error) return

Error fetching languages: {error}

; return (
- {isDropdownOpen && ( -
    - {fetchedLanguages.map((lang) => ( + {isOpen && ( +
      + {fetchedLanguages.map((lang, index) => (
    • handleLanguageChange(lang)} - onKeyDown={(e) => handleKeyDown(e, lang)} + tabIndex={-1} + onClick={() => handleSelect(lang)} className={`selector__item ${ - selectedLanguage.lang === lang.lang ? "selected" : "" - }`} + language.lang === lang.lang ? "selected" : "" + } ${focusedIndex === index ? "focused" : ""}`} + aria-selected={language.lang === lang.lang} > - -