diff --git a/status/js/language-selector.js b/status/js/language-selector.js index 9cb91c6..4e73852 100644 --- a/status/js/language-selector.js +++ b/status/js/language-selector.js @@ -2,12 +2,13 @@ * DC-077: i18n Language Selector * * Compact dropdown in the navbar (next to the theme toggle) that lets users switch - * the dashboard language between en / es / zh / ar / de. + * the dashboard language. Supports all 31 backend languages with a searchable list. * * - Shows current language with flag emoji * - Persists selection to localStorage('dashcaddy-language') * - Sends selection to backend via POST /api/v1/config with { language: 'xx' } * - Reloads the page on change so the new language takes effect + * - Search filter for quickly finding a language in the 31-option list * * Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js). */ @@ -18,11 +19,37 @@ const CONFIG_ENDPOINT = '/api/v1/config'; const LANGUAGES = [ - { code: 'en', flag: '🇺🇸', label: 'English' }, - { code: 'es', flag: '🇪🇸', label: 'Español' }, - { code: 'zh', flag: '🇨🇳', label: '中文' }, - { code: 'ar', flag: '🇸🇦', label: 'العربية' }, - { code: 'de', flag: '🇩🇪', label: 'Deutsch' }, + { code: 'en', flag: '🇬🇧', label: 'English', nativeLabel: 'English' }, + { code: 'ar', flag: '🇸🇦', label: 'Arabic', nativeLabel: 'العربية' }, + { code: 'bn', flag: '🇧🇩', label: 'Bengali', nativeLabel: 'বাংলা' }, + { code: 'cs', flag: '🇨🇿', label: 'Czech', nativeLabel: 'Čeština' }, + { code: 'da', flag: '🇩🇰', label: 'Danish', nativeLabel: 'Dansk' }, + { code: 'de', flag: '🇩🇪', label: 'German', nativeLabel: 'Deutsch' }, + { code: 'el', flag: '🇬🇷', label: 'Greek', nativeLabel: 'Ελληνικά' }, + { code: 'es', flag: '🇪🇸', label: 'Spanish', nativeLabel: 'Español' }, + { code: 'fa', flag: '🇮🇷', label: 'Persian', nativeLabel: 'فارسی' }, + { code: 'fi', flag: '🇫🇮', label: 'Finnish', nativeLabel: 'Suomi' }, + { code: 'fr', flag: '🇫🇷', label: 'French', nativeLabel: 'Français' }, + { code: 'hi', flag: '🇮🇳', label: 'Hindi', nativeLabel: 'हिन्दी' }, + { code: 'hu', flag: '🇭🇺', label: 'Hungarian', nativeLabel: 'Magyar' }, + { code: 'id', flag: '🇮🇩', label: 'Indonesian', nativeLabel: 'Bahasa Indonesia' }, + { code: 'it', flag: '🇮🇹', label: 'Italian', nativeLabel: 'Italiano' }, + { code: 'ja', flag: '🇯🇵', label: 'Japanese', nativeLabel: '日本語' }, + { code: 'ko', flag: '🇰🇷', label: 'Korean', nativeLabel: '한국어' }, + { code: 'ms', flag: '🇲🇾', label: 'Malay', nativeLabel: 'Bahasa Melayu' }, + { code: 'nl', flag: '🇳🇱', label: 'Dutch', nativeLabel: 'Nederlands' }, + { code: 'no', flag: '🇳🇴', label: 'Norwegian', nativeLabel: 'Norsk' }, + { code: 'pl', flag: '🇵🇱', label: 'Polish', nativeLabel: 'Polski' }, + { code: 'pt', flag: '🇵🇹', label: 'Portuguese', nativeLabel: 'Português' }, + { code: 'ro', flag: '🇷🇴', label: 'Romanian', nativeLabel: 'Română' }, + { code: 'ru', flag: '🇷🇺', label: 'Russian', nativeLabel: 'Русский' }, + { code: 'sv', flag: '🇸🇪', label: 'Swedish', nativeLabel: 'Svenska' }, + { code: 'th', flag: '🇹🇭', label: 'Thai', nativeLabel: 'ไทย' }, + { code: 'tr', flag: '🇹🇷', label: 'Turkish', nativeLabel: 'Türkçe' }, + { code: 'uk', flag: '🇺🇦', label: 'Ukrainian', nativeLabel: 'Українська' }, + { code: 'ur', flag: '🇵🇰', label: 'Urdu', nativeLabel: 'اردو' }, + { code: 'vi', flag: '🇻🇳', label: 'Vietnamese', nativeLabel: 'Tiếng Việt' }, + { code: 'zh', flag: '🇨🇳', label: 'Chinese', nativeLabel: '中文' }, ]; const SUPPORTED = LANGUAGES.map(l => l.code); @@ -82,7 +109,10 @@ position: absolute; top: calc(100% + 6px); right: 0; - min-width: 160px; + min-width: 200px; + max-height: 360px; + display: flex; + flex-direction: column; background: var(--card-base, #1e1e2e); border: 1px solid var(--border); border-radius: 8px; @@ -92,7 +122,35 @@ display: none; } .dc-lang-menu.open { - display: block; + display: flex; + } + .dc-lang-search { + margin: 2px 0 6px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-base, #111); + color: var(--fg); + font-size: 0.82rem; + font-family: inherit; + outline: none; + width: 100%; + box-sizing: border-box; + } + .dc-lang-search:focus { + border-color: var(--accent); + } + .dc-lang-list { + overflow-y: auto; + max-height: 280px; + scrollbar-width: thin; + } + .dc-lang-list::-webkit-scrollbar { + width: 5px; + } + .dc-lang-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; } .dc-lang-option { display: flex; @@ -126,6 +184,11 @@ .dc-lang-option.active .dc-lang-check { opacity: 1; } + .dc-lang-option.dc-lang-focus, + .dc-lang-option:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + } .dc-lang-label-sm { background: none !important; border: none !important; @@ -146,19 +209,111 @@ menu.className = 'dc-lang-menu'; menu.setAttribute('role', 'menu'); + // Search input + const search = document.createElement('input'); + search.type = 'text'; + search.className = 'dc-lang-search'; + search.placeholder = 'Search language…'; + search.setAttribute('aria-label', 'Search languages'); + search.autocomplete = 'off'; + + // Scrollable option list + const list = document.createElement('div'); + list.className = 'dc-lang-list'; + for (const lang of LANGUAGES) { const opt = document.createElement('div'); opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : ''); opt.setAttribute('role', 'menuitemradio'); opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false'); + opt.setAttribute('tabindex', '-1'); opt.dataset.lang = lang.code; + opt.dataset.search = (lang.label + ' ' + lang.nativeLabel + ' ' + lang.code).toLowerCase(); opt.innerHTML = '' + lang.flag + '' + - '' + lang.label + '' + + '' + lang.nativeLabel + + '' + lang.label + '' + + '' + ''; - menu.appendChild(opt); + list.appendChild(opt); } - return menu; + + // Filter logic — extracted so we can reset from the open handler + function applyFilter(query) { + var q = (query || '').toLowerCase().trim(); + list.querySelectorAll('.dc-lang-option').forEach(function (opt) { + var match = !q || opt.dataset.search.indexOf(q) !== -1; + opt.style.display = match ? '' : 'none'; + }); + } + + search.addEventListener('input', function () { + applyFilter(this.value); + }); + + // Prevent clicks on search from closing the menu + search.addEventListener('click', function (e) { e.stopPropagation(); }); + + // Expose reset so init() can restore visibility when reopening + menu._resetFilter = function () { + search.value = ''; + applyFilter(''); + }; + + // ===== Keyboard navigation (Arrow Up/Down, Enter, Space) ===== + function getVisibleOptions() { + return Array.from(list.querySelectorAll('.dc-lang-option')).filter( + function (o) { return o.style.display !== 'none'; } + ); + } + + function focusOption(opt) { + if (!opt) return; + var visible = getVisibleOptions(); + visible.forEach(function (o) { o.classList.remove('dc-lang-focus'); }); + opt.classList.add('dc-lang-focus'); + opt.focus(); + } + + search.addEventListener('keydown', function (e) { + var visible = getVisibleOptions(); + if (visible.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + focusOption(visible[0]); + } else if (e.key === 'Enter') { + e.preventDefault(); + var active = list.querySelector('.dc-lang-option.active'); + if (active && active.style.display !== 'none') selectLanguage(active.dataset.lang); + } + }); + + list.addEventListener('keydown', function (e) { + var visible = getVisibleOptions(); + var currentIdx = visible.indexOf(document.activeElement); + if (e.key === 'ArrowDown') { + e.preventDefault(); + var next = visible[Math.min(currentIdx + 1, visible.length - 1)]; + focusOption(next); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (currentIdx === 0) { + search.focus(); + } else { + focusOption(visible[currentIdx - 1]); + } + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + var opt = document.activeElement; + if (opt && opt.classList.contains('dc-lang-option')) { + selectLanguage(opt.dataset.lang); + } + } + }); + + menu.appendChild(search); + menu.appendChild(list); + return { menu: menu, search: search }; } async function selectLanguage(code) { @@ -207,7 +362,9 @@ '' + current.toUpperCase() + '' + ''; - const menu = buildMenu(current); + const built = buildMenu(current); + const menu = built.menu; + const searchInput = built.search; // Small label beneath, matching the "Customize Theme" link style const label = document.createElement('span'); @@ -223,9 +380,16 @@ e.stopPropagation(); const isOpen = menu.classList.toggle('open'); btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + if (isOpen) { + // Reset filter: clear search text AND restore all hidden options + if (typeof menu._resetFilter === 'function') { + menu._resetFilter(); + } + searchInput.focus(); + } }); - // Option clicks + // Option clicks (delegate to the list container) menu.addEventListener('click', (e) => { const opt = e.target.closest('.dc-lang-option'); if (!opt) return; @@ -243,11 +407,12 @@ } }); - // Close on Escape + // Close on Escape — return focus to the trigger button for accessibility document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { + if (e.key === 'Escape' && menu.classList.contains('open')) { menu.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); + btn.focus(); } });