- 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)
451 lines
15 KiB
JavaScript
451 lines
15 KiB
JavaScript
/**
|
|
* DC-077: i18n Language Selector
|
|
*
|
|
* Compact dropdown in the navbar (next to the theme toggle) that lets users switch
|
|
* 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).
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const STORAGE_KEY = 'dashcaddy-language';
|
|
const CONFIG_ENDPOINT = '/api/v1/config';
|
|
|
|
const LANGUAGES = [
|
|
{ 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);
|
|
|
|
function getCurrentLanguage() {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
if (stored && SUPPORTED.includes(stored)) return stored;
|
|
return 'en';
|
|
}
|
|
|
|
function langMeta(code) {
|
|
return LANGUAGES.find(l => l.code === code) || LANGUAGES[0];
|
|
}
|
|
|
|
// ===== Inject styles once =====
|
|
function injectStyles() {
|
|
if (document.getElementById('dc-lang-selector-styles')) return;
|
|
const style = document.createElement('style');
|
|
style.id = 'dc-lang-selector-styles';
|
|
style.textContent = `
|
|
.dc-lang-wrap {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 2px;
|
|
}
|
|
.dc-lang-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 7px 14px;
|
|
border-radius: 6px;
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
white-space: nowrap;
|
|
background: var(--card-base);
|
|
color: var(--accent);
|
|
border: 1px solid var(--border);
|
|
font-family: inherit;
|
|
}
|
|
.dc-lang-btn:hover {
|
|
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
|
}
|
|
.dc-lang-btn .dc-lang-flag {
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
}
|
|
.dc-lang-btn .dc-lang-caret {
|
|
font-size: 0.6rem;
|
|
opacity: 0.7;
|
|
margin-left: 2px;
|
|
}
|
|
.dc-lang-menu {
|
|
position: absolute;
|
|
top: calc(100% + 6px);
|
|
right: 0;
|
|
min-width: 200px;
|
|
max-height: 360px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--card-base, #1e1e2e);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
padding: 4px;
|
|
z-index: 10000;
|
|
display: none;
|
|
}
|
|
.dc-lang-menu.open {
|
|
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;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
color: var(--fg);
|
|
transition: background 0.15s ease;
|
|
white-space: nowrap;
|
|
}
|
|
.dc-lang-option:hover {
|
|
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
|
}
|
|
.dc-lang-option.active {
|
|
background: color-mix(in srgb, var(--accent) 25%, transparent);
|
|
color: var(--accent);
|
|
font-weight: 600;
|
|
}
|
|
.dc-lang-option .dc-lang-flag {
|
|
font-size: 1.1rem;
|
|
line-height: 1;
|
|
}
|
|
.dc-lang-option .dc-lang-check {
|
|
margin-left: auto;
|
|
opacity: 0;
|
|
font-size: 0.8rem;
|
|
}
|
|
.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;
|
|
color: var(--muted);
|
|
font-size: 0.7rem;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
font-family: inherit;
|
|
opacity: 0.8;
|
|
text-align: center;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function buildMenu(current) {
|
|
const menu = document.createElement('div');
|
|
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 =
|
|
'<span class="dc-lang-flag">' + lang.flag + '</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>';
|
|
list.appendChild(opt);
|
|
}
|
|
|
|
// 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) {
|
|
if (!SUPPORTED.includes(code) || code === getCurrentLanguage()) return;
|
|
// Persist locally immediately for instant reload
|
|
localStorage.setItem(STORAGE_KEY, code);
|
|
|
|
// Best-effort backend sync — don't block the reload on failure
|
|
try {
|
|
if (typeof postJSON === 'function') {
|
|
await postJSON(CONFIG_ENDPOINT, { language: code });
|
|
} else if (typeof secureFetch === 'function') {
|
|
await secureFetch(CONFIG_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ language: code }),
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// Non-fatal: localStorage already holds the preference
|
|
console.warn('[LanguageSelector] backend sync failed:', err);
|
|
}
|
|
|
|
// Reload so the new language takes effect across the dashboard
|
|
window.location.reload();
|
|
}
|
|
|
|
function init() {
|
|
injectStyles();
|
|
|
|
const current = getCurrentLanguage();
|
|
const meta = langMeta(current);
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'dc-lang-wrap';
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'dc-lang-btn';
|
|
btn.type = 'button';
|
|
btn.setAttribute('aria-label', 'Select language');
|
|
btn.setAttribute('aria-haspopup', 'true');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
btn.title = 'Switch language';
|
|
btn.innerHTML =
|
|
'<span class="dc-lang-flag">' + meta.flag + '</span>' +
|
|
'<span class="dc-lang-code">' + current.toUpperCase() + '</span>' +
|
|
'<span class="dc-lang-caret">▼</span>';
|
|
|
|
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');
|
|
label.className = 'dc-lang-label-sm';
|
|
label.textContent = 'Language';
|
|
|
|
wrap.appendChild(btn);
|
|
wrap.appendChild(label);
|
|
wrap.appendChild(menu);
|
|
|
|
// Toggle menu open/closed
|
|
btn.addEventListener('click', (e) => {
|
|
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 (delegate to the list container)
|
|
menu.addEventListener('click', (e) => {
|
|
const opt = e.target.closest('.dc-lang-option');
|
|
if (!opt) return;
|
|
const code = opt.dataset.lang;
|
|
menu.classList.remove('open');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
selectLanguage(code);
|
|
});
|
|
|
|
// Close when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!wrap.contains(e.target)) {
|
|
menu.classList.remove('open');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
});
|
|
|
|
// Close on Escape — return focus to the trigger button for accessibility
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && menu.classList.contains('open')) {
|
|
menu.classList.remove('open');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
btn.focus();
|
|
}
|
|
});
|
|
|
|
return wrap;
|
|
}
|
|
|
|
// Expose for programmatic use / testing
|
|
window.DCLanguageSelector = {
|
|
getCurrentLanguage,
|
|
selectLanguage,
|
|
LANGUAGES,
|
|
STORAGE_KEY,
|
|
};
|
|
|
|
// Auto-mount into the navbar next to the theme toggle.
|
|
// The dashboard loads core.js (globals) before this deferred script, but the
|
|
// navbar container is always present in the initial HTML.
|
|
function mount() {
|
|
const group = document.querySelector('.theme-toggle-group');
|
|
if (group && group.parentNode) {
|
|
// Insert immediately after the theme-toggle-group so it sits beside it
|
|
group.parentNode.insertBefore(init(), group.nextSibling);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', mount);
|
|
} else {
|
|
mount();
|
|
}
|
|
|
|
console.log('[LanguageSelector] Module loaded — current language:', getCurrentLanguage());
|
|
})();
|