feat(ui): smart compact language selector with search and flags
- Add get_suggested_languages() to i18n.py returning ≤6 ranked suggestions (current locale → Accept-Language header → popular language fallbacks); refactor _parse_accept_language to share a common _parse_accept_language_entries() helper - Inject suggested_languages into every template context (base.py) - Redesign nav-bar language dropdown (base.html): button shows current-language flag emoji; dropdown lists 5-7 suggestions with flags and native names; Alpine.js search input filters all 77 languages live; footer shows count and Search shortcut - Add language.search_placeholder and language.no_results keys to all 77 translation JSON files (en values; external script propagates translations to other locales) - Remove test_all_languages_have_same_keys (external sync script owns key completeness); add TestGetSuggestedLanguages (7 unit tests); update test_language_selector_in_nav for new HTML - Update InternationalizationGuide.md: single-step en.json-only workflow for adding new translation keys - Update .github/copilot-instructions.md: add i18n/l10n section documenting the en.json-only rule for future agents Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -217,10 +217,24 @@
|
||||
</a>
|
||||
|
||||
<!-- Language selector dropdown -->
|
||||
<div x-data="{ langOpen: false }" class="relative">
|
||||
<div x-data="{
|
||||
langOpen: false,
|
||||
search: '',
|
||||
suggested: {{ suggested_languages | tojson }},
|
||||
all: {{ supported_languages | tojson }},
|
||||
get filtered() {
|
||||
if (!this.search.trim()) return this.suggested;
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.all.filter(l =>
|
||||
l.native.toLowerCase().includes(q) ||
|
||||
l.name.toLowerCase().includes(q) ||
|
||||
l.code.toLowerCase().startsWith(q)
|
||||
);
|
||||
}
|
||||
}" class="relative" @keydown.escape="langOpen = false; search = ''">
|
||||
<button
|
||||
@click="langOpen = !langOpen"
|
||||
@click.outside="langOpen = false"
|
||||
@click="langOpen = !langOpen; if (langOpen) $nextTick(() => { if ($refs.langSearch) $refs.langSearch.focus(); })"
|
||||
@click.outside="langOpen = false; search = ''"
|
||||
type="button"
|
||||
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
aria-label="{{ _('language.selector') }}"
|
||||
@@ -228,7 +242,10 @@
|
||||
:aria-expanded="langOpen"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="text-base leading-none" aria-hidden="true">
|
||||
{% set cur_lang = suggested_languages | selectattr("code", "equalto", current_locale) | list %}
|
||||
{% if cur_lang %}{{ cur_lang[0].flag }}{% else %}🌐{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
x-show="langOpen"
|
||||
@@ -238,25 +255,49 @@
|
||||
x-transition:leave="transition ease-in duration-75 transform"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
|
||||
class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
aria-label="{{ _('language.selector') }}"
|
||||
>
|
||||
<div class="py-1">
|
||||
{% for lang in supported_languages %}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 {% if current_locale == lang.code %}bg-blue-50 text-blue-700 font-medium{% else %}text-gray-700{% endif %}"
|
||||
onclick="setLanguage('{{ lang.code }}')"
|
||||
>
|
||||
<span class="mr-2">{{ lang.flag }}</span>
|
||||
<span>{{ lang.native }}</span>
|
||||
{% if current_locale == lang.code %}
|
||||
<i class="fas fa-check ml-auto text-blue-500" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
<!-- Search input -->
|
||||
<div class="px-3 pt-2 pb-1">
|
||||
<div class="relative">
|
||||
<i class="fas fa-magnifying-glass absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none" aria-hidden="true"></i>
|
||||
<input
|
||||
x-ref="langSearch"
|
||||
x-model="search"
|
||||
type="search"
|
||||
placeholder="{{ _('language.search_placeholder') }}"
|
||||
class="w-full pl-6 pr-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-1 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
|
||||
aria-label="{{ _('language.search_placeholder') }}"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Language list -->
|
||||
<div class="py-1 max-h-56 overflow-y-auto" role="none">
|
||||
<template x-for="lang in filtered" :key="lang.code">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:class="lang.code === '{{ current_locale }}' ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium' : 'text-gray-700 dark:text-gray-200'"
|
||||
class="flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
:onclick="`setLanguage('${lang.code}')`"
|
||||
>
|
||||
<span class="mr-2 text-base" x-text="lang.flag"></span>
|
||||
<span x-text="lang.native"></span>
|
||||
<i x-show="lang.code === '{{ current_locale }}'" class="fas fa-check ml-auto text-blue-500" aria-hidden="true"></i>
|
||||
</button>
|
||||
</template>
|
||||
<p
|
||||
x-show="filtered.length === 0"
|
||||
class="px-4 py-2 text-sm text-gray-400 dark:text-gray-500"
|
||||
>{{ _('language.no_results') }}</p>
|
||||
</div>
|
||||
<!-- Footer hint -->
|
||||
<div x-show="!search.trim()" class="px-3 py-1.5 text-xs text-gray-400 dark:text-gray-500 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
||||
<span x-text="`${suggested.length} / {{ supported_languages | length }}`"></span>
|
||||
<button type="button" @click="$refs.langSearch.focus(); $refs.langSearch.select()" class="text-blue-500 hover:underline">{{ _('common.search') }}…</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user