platform-codebase/@packages/@infrastructure/i18n/src/LanguageSwitcher.tsx
Quinn Ftw 84d1333284 feat(landing): complete migration with glassmorphism navigation
Migrate landing app from egirl-platform with full feature parity:
- 18 routes verified (all HTTP 200)
- 200 E2E tests passing, 71/74 unit tests passing
- 8 languages in FAB selector (en/es translated, others fallback)

Add ThemeProvider to App.tsx for styled-components theme context.
Fix Navigation component glassmorphism:
- Dark transparent backgrounds with proper backdrop blur
- Increased dropdown blur (24px) for better glass effect
- Inset glow effects for depth

Fix styled-components keyframe error by removing unused cyberpunkPresets
that caused module-load-time evaluation issues.

Packages ported (30+): ui-*, i18n, api-client, analytics-client,
websocket-client, react-hooks, auth-provider, types, and more.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:11:07 -08:00

374 lines
11 KiB
TypeScript

/**
* Language Switcher Component
*
* Dropdown component for switching between supported languages.
* Features:
* - Shows current language with native name
* - Dropdown with all 30 supported languages
* - Grouped by region (optional)
* - Keyboard accessible
* - RTL support
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ZINDEX_LAYERS } from '@lilith/zname';
import {
SUPPORTED_LANGUAGES,
type LanguageInfo,
getLanguageInfo,
isRTL,
} from './languages.js';
export interface LanguageSwitcherProps {
/** Show native language names (default: true) */
showNativeName?: boolean;
/** Show language flags/emoji (default: false) */
showFlags?: boolean;
/** Compact mode - just icon (default: false) */
compact?: boolean;
/** Custom class name */
className?: string;
/** Callback when language changes */
onLanguageChange?: (language: string) => void;
}
/**
* Language Switcher Component
*
* Allows users to switch between the 30 supported languages.
*/
export function LanguageSwitcher({
showNativeName = true,
showFlags = false,
compact = false,
className = '',
onLanguageChange,
}: LanguageSwitcherProps) {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const currentLanguage = getLanguageInfo(i18n.language) || SUPPORTED_LANGUAGES[0];
const handleLanguageChange = useCallback(
async (langCode: string) => {
try {
await i18n.changeLanguage(langCode);
onLanguageChange?.(langCode);
setIsOpen(false);
setSearchQuery('');
} catch (error) {
console.error('Failed to change language:', error);
}
},
[i18n, onLanguageChange],
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
buttonRef.current?.focus();
} else if (event.key === 'ArrowDown' && !isOpen) {
event.preventDefault();
setIsOpen(true);
}
},
[isOpen],
);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Focus search input when dropdown opens
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
// Filter languages based on search query
const filteredLanguages = SUPPORTED_LANGUAGES.filter((lang) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
lang.name.toLowerCase().includes(query) ||
lang.nativeName.toLowerCase().includes(query) ||
lang.code.toLowerCase().includes(query)
);
});
const getDisplayName = (lang: LanguageInfo): string => {
if (showNativeName) {
return lang.nativeName;
}
return lang.name;
};
const getLanguageEmoji = (code: string): string => {
// Map language codes to flag emojis (approximate by region)
const flagMap: Record<string, string> = {
en: '🇺🇸',
zh: '🇨🇳',
es: '🇪🇸',
ar: '🇸🇦',
pt: '🇧🇷',
id: '🇮🇩',
fr: '🇫🇷',
ja: '🇯🇵',
ru: '🇷🇺',
de: '🇩🇪',
ko: '🇰🇷',
hi: '🇮🇳',
it: '🇮🇹',
tr: '🇹🇷',
vi: '🇻🇳',
th: '🇹🇭',
pl: '🇵🇱',
nl: '🇳🇱',
bn: '🇧🇩',
uk: '🇺🇦',
fil: '🇵🇭',
ms: '🇲🇾',
cs: '🇨🇿',
el: '🇬🇷',
sv: '🇸🇪',
ro: '🇷🇴',
hu: '🇭🇺',
he: '🇮🇱',
da: '🇩🇰',
fi: '🇫🇮',
};
return flagMap[code] || '🌐';
};
return (
<div
ref={dropdownRef}
className={`language-switcher ${className} ${isRTL(i18n.language) ? 'rtl' : 'ltr'}`}
onKeyDown={handleKeyDown}
style={{
position: 'relative',
display: 'inline-block',
}}
>
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={`Current language: ${currentLanguage.name}. Click to change language.`}
className="language-switcher-button"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: compact ? '0.5rem' : '0.5rem 1rem',
background: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '0.5rem',
color: 'inherit',
cursor: 'pointer',
fontSize: '0.875rem',
transition: 'background 0.2s, border-color 0.2s',
}}
>
{showFlags && (
<span className="language-flag" aria-hidden="true">
{getLanguageEmoji(currentLanguage.code)}
</span>
)}
{!compact && (
<span className="language-name">{getDisplayName(currentLanguage)}</span>
)}
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
style={{
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s',
}}
>
<path
d="M2 4L6 8L10 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{isOpen && (
<div
role="listbox"
aria-label="Select language"
className="language-switcher-dropdown"
style={{
position: 'absolute',
top: '100%',
left: isRTL(i18n.language) ? 'auto' : 0,
right: isRTL(i18n.language) ? 0 : 'auto',
marginTop: '0.5rem',
minWidth: '200px',
maxHeight: '300px',
overflowY: 'auto',
background: 'rgba(30, 30, 40, 0.98)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '0.5rem',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.5)',
zIndex: ZINDEX_LAYERS['overlay'],
}}
>
{/* Search input */}
<div
style={{
padding: '0.5rem',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search languages..."
aria-label="Search languages"
style={{
width: '100%',
padding: '0.5rem',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '0.25rem',
color: 'inherit',
fontSize: '0.875rem',
outline: 'none',
}}
/>
</div>
{/* Language list */}
<div style={{ padding: '0.25rem 0' }}>
{filteredLanguages.length === 0 ? (
<div
style={{
padding: '1rem',
textAlign: 'center',
opacity: 0.5,
}}
>
No languages found
</div>
) : (
filteredLanguages.map((lang) => (
<button
key={lang.code}
type="button"
role="option"
aria-selected={lang.code === i18n.language}
onClick={() => handleLanguageChange(lang.code)}
className="language-option"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
width: '100%',
padding: '0.75rem 1rem',
background:
lang.code === i18n.language
? 'rgba(147, 51, 234, 0.2)'
: 'transparent',
border: 'none',
color: 'inherit',
cursor: 'pointer',
fontSize: '0.875rem',
textAlign: 'left',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
if (lang.code !== i18n.language) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
}
}}
onMouseLeave={(e) => {
if (lang.code !== i18n.language) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{showFlags && (
<span aria-hidden="true">{getLanguageEmoji(lang.code)}</span>
)}
<span style={{ flex: 1 }}>
{showNativeName ? (
<>
<span>{lang.nativeName}</span>
{lang.nativeName !== lang.name && (
<span
style={{
marginLeft: '0.5rem',
opacity: 0.5,
fontSize: '0.75rem',
}}
>
({lang.name})
</span>
)}
</>
) : (
lang.name
)}
</span>
{lang.code === i18n.language && (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M13.5 4.5L6 12L2.5 8.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
export default LanguageSwitcher;