implement language selector component and update internationalization logic #1

Merged
fabio merged 1 commits from dev into main 2026-03-07 17:38:45 +00:00
14 changed files with 254 additions and 27 deletions

View File

@ -166,12 +166,9 @@
});
}
var langSelect = document.getElementById('');
if (langSelect) {
langSelect.addEventListener('change', function () {
var selectedLang = normalizeLang(langSelect.value);
function persistSelectedLang(selectedLang, isAuthenticated) {
localStorage.setItem(STORAGE_KEY, selectedLang);
if (langSelect.dataset.authenticated === '1') {
if (!isAuthenticated) return;
fetch('/preferences/lang', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@ -180,6 +177,24 @@
// Keep UI responsive even if persistence fails.
});
}
var langSelect = document.getElementById('lang-select');
if (langSelect) {
langSelect.addEventListener('change', function () {
var selectedLang = normalizeLang(langSelect.value);
persistSelectedLang(selectedLang, langSelect.dataset.authenticated === '1');
applyTranslations(document);
});
}
var langSelectorElement = document.querySelector('lang-selector');
if (langSelectorElement) {
langSelectorElement.addEventListener('lang-changed', function (event) {
var eventLang = event && event.detail ? event.detail.langCode : '';
var selectedLang = normalizeLang(eventLang);
if (!isSupportedLang(selectedLang)) return;
var authAttr = langSelectorElement.getAttribute('authenticated');
persistSelectedLang(selectedLang, authAttr === '1' || authAttr === 'true');
applyTranslations(document);
});
}

View File

@ -1,14 +1,3 @@
{{define "language_dropdown"}}
<div class="relative flex items-center gap-2 text-sm">
<img id="lang-flag" class="rounded object-cover dark:outline" src="/static/vendor/flags/it.svg" alt="Italiano" style="width:32px;height:22px;">
<select id="lang-select" name="lang" data-authenticated="{{if .CurrentUser}}1{{else}}0{{end}}" class="inline-flex h-8 min-w-[95px] items-center rounded-lg bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-700">
<option value="it">Italiano</option>
<option value="en">English</option>
<option value="en_us">English USA</option>
<option value="de">Deutsch</option>
<option value="de_ch">Deutsch CH</option>
<option value="fr">Français</option>
<option value="fr_ch">Français CH</option>
</select>
</div>
<lang-selector authenticated="{{if .CurrentUser}}1{{else}}0{{end}}"></lang-selector>
{{end}}

View File

@ -22,10 +22,15 @@ Output in `dist/`:
- `user-menu.es.js`
- `user-menu.iife.js`
Componenti registrati dal bundle:
- `<user-menu>`
- `<lang-selector>`
## Uso nel browser
```html
<link rel="stylesheet" href="/path/user-menu.css" />
<script type="module" src="/path/user-menu.es.js"></script>
<trustcontact-greeting name="Fabio"></trustcontact-greeting>
<user-menu target="user-dropdown" pos="bl" sr="Open user menu"></user-menu>
<lang-selector authenticated="1"></lang-selector>
```

View File

@ -9,6 +9,10 @@
<h1>Web Components Playground</h1>
<user-menu target="user-dropdown" pos="bl" sr="Open user menu"></user-menu>
<div class="m-4 border">
<lang-selector authenticated="1" flagspath="public/flags/" id="test"></lang-selector>
</div>
<div id="user-dropdown" style="outline:auto;" class="z-50 my-4 w-56 list-none divide-y divide-gray-100 rounded-lg bg-white text-base shadow-sm dark:divide-gray-700 dark:bg-gray-800 ">
<div class="px-4 py-3">
<span class="block truncate text-sm text-gray-900 dark:text-gray-100">Admin User</span>

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Swiss flag">
<rect width="32" height="32" fill="#d52b1e"/>
<rect x="13" y="6" width="6" height="20" fill="#ffffff"/>
<rect x="6" y="13" width="20" height="6" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="German flag">
<rect width="48" height="10.67" x="0" y="0" fill="#000000"/>
<rect width="48" height="10.67" x="0" y="10.67" fill="#dd0000"/>
<rect width="48" height="10.66" x="0" y="21.34" fill="#ffce00"/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="English flag">
<rect width="48" height="32" fill="#012169"/>
<path d="M0 0 48 32M48 0 0 32" stroke="#ffffff" stroke-width="6"/>
<path d="M0 0 48 32M48 0 0 32" stroke="#c8102e" stroke-width="3"/>
<rect x="20" y="0" width="8" height="32" fill="#ffffff"/>
<rect x="0" y="12" width="48" height="8" fill="#ffffff"/>
<rect x="22" y="0" width="4" height="32" fill="#c8102e"/>
<rect x="0" y="14" width="48" height="4" fill="#c8102e"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

View File

@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="United States flag">
<rect width="48" height="32" fill="#b22234"/>
<g fill="#ffffff">
<rect y="2.46" width="48" height="2.46"/>
<rect y="7.38" width="48" height="2.46"/>
<rect y="12.30" width="48" height="2.46"/>
<rect y="17.22" width="48" height="2.46"/>
<rect y="22.14" width="48" height="2.46"/>
<rect y="27.06" width="48" height="2.46"/>
</g>
<rect width="20" height="17.23" fill="#3c3b6e"/>
<g fill="#ffffff">
<circle cx="2.2" cy="2.2" r="0.7"/>
<circle cx="5.4" cy="2.2" r="0.7"/>
<circle cx="8.6" cy="2.2" r="0.7"/>
<circle cx="11.8" cy="2.2" r="0.7"/>
<circle cx="15.0" cy="2.2" r="0.7"/>
<circle cx="18.2" cy="2.2" r="0.7"/>
<circle cx="3.8" cy="4.4" r="0.7"/>
<circle cx="7.0" cy="4.4" r="0.7"/>
<circle cx="10.2" cy="4.4" r="0.7"/>
<circle cx="13.4" cy="4.4" r="0.7"/>
<circle cx="16.6" cy="4.4" r="0.7"/>
<circle cx="2.2" cy="6.6" r="0.7"/>
<circle cx="5.4" cy="6.6" r="0.7"/>
<circle cx="8.6" cy="6.6" r="0.7"/>
<circle cx="11.8" cy="6.6" r="0.7"/>
<circle cx="15.0" cy="6.6" r="0.7"/>
<circle cx="18.2" cy="6.6" r="0.7"/>
<circle cx="3.8" cy="8.8" r="0.7"/>
<circle cx="7.0" cy="8.8" r="0.7"/>
<circle cx="10.2" cy="8.8" r="0.7"/>
<circle cx="13.4" cy="8.8" r="0.7"/>
<circle cx="16.6" cy="8.8" r="0.7"/>
<circle cx="2.2" cy="11" r="0.7"/>
<circle cx="5.4" cy="11" r="0.7"/>
<circle cx="8.6" cy="11" r="0.7"/>
<circle cx="11.8" cy="11" r="0.7"/>
<circle cx="15.0" cy="11" r="0.7"/>
<circle cx="18.2" cy="11" r="0.7"/>
<circle cx="3.8" cy="13.2" r="0.7"/>
<circle cx="7.0" cy="13.2" r="0.7"/>
<circle cx="10.2" cy="13.2" r="0.7"/>
<circle cx="13.4" cy="13.2" r="0.7"/>
<circle cx="16.6" cy="13.2" r="0.7"/>
<circle cx="2.2" cy="15.4" r="0.7"/>
<circle cx="5.4" cy="15.4" r="0.7"/>
<circle cx="8.6" cy="15.4" r="0.7"/>
<circle cx="11.8" cy="15.4" r="0.7"/>
<circle cx="15.0" cy="15.4" r="0.7"/>
<circle cx="18.2" cy="15.4" r="0.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="French flag">
<rect width="16" height="32" x="0" y="0" fill="#0055a4"/>
<rect width="16" height="32" x="16" y="0" fill="#ffffff"/>
<rect width="16" height="32" x="32" y="0" fill="#ef4135"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" role="img" aria-label="Italian flag">
<rect width="16" height="32" x="0" y="0" fill="#009246"/>
<rect width="16" height="32" x="16" y="0" fill="#ffffff"/>
<rect width="16" height="32" x="32" y="0" fill="#ce2b37"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@ -0,0 +1,100 @@
<template>
<div class="min-w-[160px] relative inline-block">
<button @click="toggleDropdown" @blur="closeDropdown" class="min-w-[60px] inline-flex h-8 w-full items-center rounded-lg bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-gray-200 rounded-lg border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-200">
<img
id="lang-flag"
class="mr-4 rounded object-cover dark:border-gray-700"
:src="selectedFlag"
alt="Italiano"
:style="selectedStyle"
>
{{ langs[select].name }}
</button>
<div v-if="visible" class="min-w-[160px] fixed z-50 mt-2 list-none rounded-lg border border-gray-200 bg-white shadow-sm dark:border-2 dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800 " >
<ul class="list-none">
<li v-for="(lang, index) in langs" :key="index" :value="lang.code">
<button v-on:click="selectLang(index)" class="h-8 w-full inline-flex items-center rounded-lg bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-100 focus:outline-none dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 " >
<img
class="flag-min rounded object-cover mr-4 dark:border-gray-700"
:src="defaultFlagSrc + lang.flag"
:style="lang.style"
>
{{ lang.name }}</button>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { getCurrentInstance, onMounted, ref } from "vue";
const langs = [
{ code: "it", name: "Italiano", flag: "it.svg", style:"width:32px;height:22px;" },
{ code: "en", name: "English", flag: "en.svg", style:"width:32px;height:22px;" },
{ code: "en_us", name: "English USA", flag: "en_us.svg", style:"width:32px;height:22px;" },
{ code: "de", name: "Deutsch", flag: "de.svg", style:"width:32px;height:22px;" },
{ code: "de_ch", name: "Deutsch CH", flag: "ch.svg", style:"width:22px;height:22px;" },
{ code: "fr", name: "Français", flag: "fr.svg", style:"width:32px;height:22px;" },
{ code: "fr_ch", name: "Français CH", flag: "ch.svg", style:"width:22px;height:22px;" },
];
const select = ref(0);
const visible = ref(false);
const instance = getCurrentInstance();
onMounted(() => {
const storedLang = localStorage.getItem("tc_lang");
if (storedLang) {
const index = langs.findIndex((lang) => lang.code === storedLang);
if (index !== -1) {
selectLang(index, false);
}
}
});
const toggleDropdown = (event: MouseEvent) => {
visible.value = !visible.value;
};
const closeDropdown = () => {
if(visible.value) {
setTimeout(() => {
visible.value = false;
}, 200);
}
};
const emitLangChanged = (langCode: string) => {
const host = instance?.vnode.el as HTMLElement | null;
if (!host) return;
host.dispatchEvent(
new CustomEvent("lang-changed", {
detail: { langCode },
bubbles: true,
composed: true,
}),
);
};
const selectLang = (index: number, shouldEmit = true) => {
select.value = index;
selectedFlag.value = defaultFlagSrc + langs[index].flag;
selectedStyle.value = langs[index].style;
localStorage.setItem("tc_lang", langs[index].code);
visible.value = false;
if (shouldEmit) {
emitLangChanged(langs[index].code);
}
};
const props = defineProps<{
authenticated?: string | boolean;
flagspath?: string;
}>();
const defaultFlagSrc = props.flagspath ? props.flagspath : "/static/vendor/flags/";
const selectedFlag = ref(defaultFlagSrc+langs[0].flag);
const selectedStyle = ref(langs[0].style)
</script>

View File

@ -1,3 +1,8 @@
import { registerWebComponents } from './register';
registerWebComponents();
const test = document.getElementById('test');
test?.addEventListener('lang-changed', (e) => {
console.log('Language changed to:', (e as CustomEvent).detail);
});

View File

@ -1,13 +1,15 @@
import { defineCustomElement } from 'vue';
import UserMenuElement from './components/UserMenu.ce.vue';
import LangSelectorElement from './components/LangSelector.ce.vue';
import './style.css';
const TAG_NAME = 'user-menu';
const USER_MENU_TAG = 'user-menu';
const LANG_SELECTOR_TAG = 'lang-selector';
export function registerWebComponents(): void {
if (!customElements.get(TAG_NAME)) {
if (!customElements.get(USER_MENU_TAG)) {
customElements.define(
TAG_NAME,
USER_MENU_TAG,
defineCustomElement(UserMenuElement, {
// Tailwind is generated as global CSS; without Shadow DOM
// utility classes apply correctly inside the custom element.
@ -15,6 +17,15 @@ export function registerWebComponents(): void {
}),
);
}
if (!customElements.get(LANG_SELECTOR_TAG)) {
customElements.define(
LANG_SELECTOR_TAG,
defineCustomElement(LangSelectorElement, {
shadowRoot: false,
}),
);
}
}
registerWebComponents();

View File

@ -1,3 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
img.falg{
width:32px;
height:22px;
}
img.falg-min{
width:22px;
height:14px;
}
.flag-ch{
width:22px;
height:22px;
}
.flag-ch-min{
width:22px;
height:14px;
}