- Added focusedIndex state to track keyboard focus - Enhanced handleKeyDown to support ArrowDown, ArrowUp, Enter, and Escape keys - Added visual focus indicator with orange ring (ring-2 ring-orange-500) - Implemented aria-activedescendant for screen reader support - Added unique IDs to language options for accessibility - Reset focusedIndex when dropdown opens/closes Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
135 lines
4.3 KiB
TypeScript
135 lines
4.3 KiB
TypeScript
'use client';
|
|
|
|
import { useRef, useState, useEffect } from 'react';
|
|
import { useRouter, usePathname } from 'next/navigation';
|
|
import { useLocale } from 'next-intl';
|
|
import { Globe } from 'lucide-react';
|
|
|
|
const LanguageSwitcher = () => {
|
|
const locale = useLocale();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
const languages = [
|
|
{ code: 'en', name: 'English' },
|
|
{ code: 'de', name: 'Deutsch' },
|
|
{ code: 'sr', name: 'Srpski' },
|
|
];
|
|
|
|
const currentLanguage = languages.find(lang => lang.code === locale)?.name || 'Language';
|
|
|
|
const handleLanguageChange = (newLocale: string) => {
|
|
// Replace the locale in the pathname
|
|
const pathWithoutLocale = pathname.replace(/^\/(de|en|sr)/, '');
|
|
const newPath = `/${newLocale}${pathWithoutLocale || ''}`;
|
|
router.push(newPath);
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (!isOpen) return;
|
|
|
|
switch (e.key) {
|
|
case 'Escape':
|
|
setIsOpen(false);
|
|
setFocusedIndex(-1);
|
|
break;
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setFocusedIndex(prev => (prev + 1) % languages.length);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setFocusedIndex(prev => (prev - 1 + languages.length) % languages.length);
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
if (focusedIndex >= 0) {
|
|
handleLanguageChange(languages[focusedIndex].code);
|
|
setFocusedIndex(-1);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const checkMobile = () => {
|
|
setIsMobile(window.innerWidth < 768);
|
|
};
|
|
|
|
checkMobile();
|
|
window.addEventListener('resize', checkMobile);
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', checkMobile);
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="relative inline-block" ref={dropdownRef}>
|
|
<button
|
|
className="flex items-center space-x-2 text-zinc-400 hover:text-white
|
|
px-4 py-2 rounded-full transition-all duration-200
|
|
hover:bg-zinc-800/50 border border-transparent hover:border-zinc-800
|
|
hover:scale-105 active:scale-95"
|
|
onClick={() => {
|
|
setIsOpen(!isOpen);
|
|
setFocusedIndex(-1);
|
|
}}
|
|
aria-expanded={isOpen}
|
|
aria-haspopup="listbox"
|
|
aria-label="Select language"
|
|
>
|
|
<Globe className="h-5 w-5" aria-hidden="true" />
|
|
<span className="text-sm">{currentLanguage}</span>
|
|
</button>
|
|
|
|
<div
|
|
className={`absolute ${isMobile ? 'bottom-full mb-2' : 'top-full mt-2'} right-0 w-48 rounded-lg overflow-hidden
|
|
border border-zinc-800 bg-zinc-900/95 backdrop-blur-sm shadow-lg
|
|
transition-all duration-200 origin-top
|
|
${isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'}`}
|
|
role="listbox"
|
|
aria-label="Languages"
|
|
aria-activedescendant={focusedIndex >= 0 ? `lang-option-${languages[focusedIndex].code}` : undefined}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<div className="py-1">
|
|
{languages.map((lang, index) => (
|
|
<button
|
|
key={lang.code}
|
|
id={`lang-option-${lang.code}`}
|
|
className={`w-full text-left px-4 py-2 text-sm transition-colors duration-200
|
|
hover:bg-zinc-800/50
|
|
${locale === lang.code
|
|
? 'bg-zinc-800 text-white'
|
|
: 'text-zinc-400 hover:text-white'
|
|
}
|
|
${focusedIndex === index ? 'ring-2 ring-orange-500 ring-inset' : ''}`}
|
|
onClick={() => handleLanguageChange(lang.code)}
|
|
role="option"
|
|
aria-selected={locale === lang.code}
|
|
>
|
|
{lang.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LanguageSwitcher;
|