[grade=B] feat: expand i18n language selector to all 31 languages with searchable keyboard-accessible dropdown
- 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:
+180
-15
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user