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:
copilot-swe-agent[bot]
2026-03-13 21:31:16 +00:00
parent d27140f809
commit 3557590679
83 changed files with 358 additions and 49 deletions
+61 -20
View File
@@ -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>