feat(i18n): add PuroLocaleSwitcher for portal pages
This commit is contained in:
150
frontend/src/components/puro/PuroLocaleSwitcher.vue
Normal file
150
frontend/src/components/puro/PuroLocaleSwitcher.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="puro-locale" ref="dropdownRef">
|
||||
<button
|
||||
class="puro-locale-btn"
|
||||
type="button"
|
||||
:disabled="switching"
|
||||
:aria-expanded="isOpen"
|
||||
:title="currentLocale?.name"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span class="puro-locale-code">{{ currentLocale?.code.toUpperCase() }}</span>
|
||||
<svg class="puro-locale-chev" :class="{ open: isOpen }" viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
|
||||
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<transition name="puro-locale-pop">
|
||||
<div v-if="isOpen" class="puro-locale-menu" role="listbox">
|
||||
<button
|
||||
v-for="option in availableLocales"
|
||||
:key="option.code"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="option.code === currentLocaleCode"
|
||||
class="puro-locale-option"
|
||||
:class="{ active: option.code === currentLocaleCode }"
|
||||
:disabled="switching"
|
||||
@click="selectLocale(option.code)"
|
||||
>
|
||||
<span class="puro-locale-option-code">{{ option.code.toUpperCase() }}</span>
|
||||
<span class="puro-locale-option-name">{{ option.name }}</span>
|
||||
<span v-if="option.code === currentLocaleCode" class="puro-locale-option-dot" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { setLocale, availableLocales } from '@/i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const switching = ref(false)
|
||||
|
||||
const currentLocaleCode = computed(() => locale.value)
|
||||
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
async function selectLocale(code: string) {
|
||||
if (switching.value || code === currentLocaleCode.value) {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
switching.value = true
|
||||
try {
|
||||
await setLocale(code)
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
switching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', handleClickOutside))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.puro-locale { position: relative; display: inline-flex; }
|
||||
|
||||
.puro-locale-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 12px; font-weight: 500;
|
||||
color: var(--text-1, #cbd5e1);
|
||||
background: rgba(2, 6, 23, 0.4);
|
||||
border: 1px solid var(--border, rgba(148,163,184,0.18));
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.puro-locale-btn:hover:not(:disabled) {
|
||||
border-color: var(--border-2, rgba(148,163,184,0.32));
|
||||
color: var(--cyan, #22d3ee);
|
||||
}
|
||||
.puro-locale-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.puro-locale-code { letter-spacing: 0.08em; }
|
||||
.puro-locale-chev { color: var(--text-3, #64748b); transition: transform .15s; }
|
||||
.puro-locale-chev.open { transform: rotate(180deg); color: var(--cyan, #22d3ee); }
|
||||
|
||||
.puro-locale-menu {
|
||||
position: absolute; top: calc(100% + 6px); right: 0;
|
||||
min-width: 140px;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border: 1px solid var(--border, rgba(148,163,184,0.18));
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.puro-locale-option {
|
||||
width: 100%;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: transparent; border: none; border-radius: 6px;
|
||||
font-size: 13px; color: var(--text-1, #cbd5e1);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.puro-locale-option:hover:not(:disabled) {
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
color: var(--text-0, #f8fafc);
|
||||
}
|
||||
.puro-locale-option.active {
|
||||
color: var(--cyan, #22d3ee);
|
||||
}
|
||||
.puro-locale-option-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px; letter-spacing: 0.08em;
|
||||
color: var(--text-3, #64748b);
|
||||
}
|
||||
.puro-locale-option.active .puro-locale-option-code { color: var(--cyan, #22d3ee); }
|
||||
.puro-locale-option-name { flex: 1; }
|
||||
.puro-locale-option-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--cyan, #22d3ee);
|
||||
box-shadow: 0 0 0 3px rgba(34,211,238,0.2);
|
||||
}
|
||||
|
||||
.puro-locale-pop-enter-active,
|
||||
.puro-locale-pop-leave-active { transition: opacity .12s, transform .12s; }
|
||||
.puro-locale-pop-enter-from,
|
||||
.puro-locale-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.97); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user