[grade=B] feat: expand i18n language selector to all 31 languages with searchable keyboard-accessible dropdown
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

- Expand from 5 hardcoded languages to all 31 backend-supported languages
- Add searchable dropdown with filter input for quick language finding
- Add scrollable list (max-height: 280px) with thin scrollbar styling
- Display native label + English label for each language option
- Keyboard navigation: Arrow Up/Down to move, Enter/Space to select, Escape to close
- Fix stale filter: reopening the menu now resets all option visibility
- Return focus to trigger button on Escape close
- Add tabindex=-1 on options for keyboard focus management
- Add focus-visible outline styling for keyboard users
Codex grade: B (urn:ump:gzbxn46ebbv2w7offhlim7gwjf45lp5jv4koakojmparztu2cw6q)
This commit is contained in:
Hermes
2026-08-13 12:01:35 -07:00
parent 7f97ff4a7f
commit fabda78929
+180 -15
View File
@@ -2,12 +2,13 @@
* DC-077: i18n Language Selector * DC-077: i18n Language Selector
* *
* Compact dropdown in the navbar (next to the theme toggle) that lets users switch * 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 * - Shows current language with flag emoji
* - Persists selection to localStorage('dashcaddy-language') * - Persists selection to localStorage('dashcaddy-language')
* - Sends selection to backend via POST /api/v1/config with { language: 'xx' } * - Sends selection to backend via POST /api/v1/config with { language: 'xx' }
* - Reloads the page on change so the new language takes effect * - 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). * Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js).
*/ */
@@ -18,11 +19,37 @@
const CONFIG_ENDPOINT = '/api/v1/config'; const CONFIG_ENDPOINT = '/api/v1/config';
const LANGUAGES = [ const LANGUAGES = [
{ code: 'en', flag: '🇺🇸', label: 'English' }, { code: 'en', flag: '🇬🇧', label: 'English', nativeLabel: 'English' },
{ code: 'es', flag: '🇪🇸', label: 'Español' }, { code: 'ar', flag: '🇸🇦', label: 'Arabic', nativeLabel: 'العربية' },
{ code: 'zh', flag: '🇨🇳', label: '中文' }, { code: 'bn', flag: '🇧🇩', label: 'Bengali', nativeLabel: 'বাংলা' },
{ code: 'ar', flag: '🇸🇦', label: 'العربية' }, { code: 'cs', flag: '🇨🇿', label: 'Czech', nativeLabel: 'Čeština' },
{ code: 'de', flag: '🇩🇪', label: 'Deutsch' }, { 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); const SUPPORTED = LANGUAGES.map(l => l.code);
@@ -82,7 +109,10 @@
position: absolute; position: absolute;
top: calc(100% + 6px); top: calc(100% + 6px);
right: 0; right: 0;
min-width: 160px; min-width: 200px;
max-height: 360px;
display: flex;
flex-direction: column;
background: var(--card-base, #1e1e2e); background: var(--card-base, #1e1e2e);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
@@ -92,7 +122,35 @@
display: none; display: none;
} }
.dc-lang-menu.open { .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 { .dc-lang-option {
display: flex; display: flex;
@@ -126,6 +184,11 @@
.dc-lang-option.active .dc-lang-check { .dc-lang-option.active .dc-lang-check {
opacity: 1; 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 { .dc-lang-label-sm {
background: none !important; background: none !important;
border: none !important; border: none !important;
@@ -146,19 +209,111 @@
menu.className = 'dc-lang-menu'; menu.className = 'dc-lang-menu';
menu.setAttribute('role', '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) { for (const lang of LANGUAGES) {
const opt = document.createElement('div'); const opt = document.createElement('div');
opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : ''); opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : '');
opt.setAttribute('role', 'menuitemradio'); opt.setAttribute('role', 'menuitemradio');
opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false'); opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false');
opt.setAttribute('tabindex', '-1');
opt.dataset.lang = lang.code; opt.dataset.lang = lang.code;
opt.dataset.search = (lang.label + ' ' + lang.nativeLabel + ' ' + lang.code).toLowerCase();
opt.innerHTML = opt.innerHTML =
'<span class="dc-lang-flag">' + lang.flag + '</span>' + '<span class="dc-lang-flag">' + lang.flag + '</span>' +
'<span class="dc-lang-name">' + lang.label + '</span>' + '<span class="dc-lang-name">' + lang.nativeLabel +
'<span style="opacity:0.5;font-size:0.8em;margin-left:6px;">' + lang.label + '</span>' +
'</span>' +
'<span class="dc-lang-check">✓</span>'; '<span class="dc-lang-check">✓</span>';
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) { async function selectLanguage(code) {
@@ -207,7 +362,9 @@
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' + '<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
'<span class="dc-lang-caret">▼</span>'; '<span class="dc-lang-caret">▼</span>';
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 // Small label beneath, matching the "Customize Theme" link style
const label = document.createElement('span'); const label = document.createElement('span');
@@ -223,9 +380,16 @@
e.stopPropagation(); e.stopPropagation();
const isOpen = menu.classList.toggle('open'); const isOpen = menu.classList.toggle('open');
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); 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) => { menu.addEventListener('click', (e) => {
const opt = e.target.closest('.dc-lang-option'); const opt = e.target.closest('.dc-lang-option');
if (!opt) return; 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) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape' && menu.classList.contains('open')) {
menu.classList.remove('open'); menu.classList.remove('open');
btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-expanded', 'false');
btn.focus();
} }
}); });