Feat(i18n): make language a setting #185

Merged
jakob.scheid merged 8 commits from feature/make-language-a-setting into main 2026-08-07 11:44:33 +02:00
4 changed files with 38 additions and 55 deletions
Showing only changes of commit b571a68e3b - Show all commits
+4
View File
@@ -18,8 +18,10 @@ limitations under the License.
import Navbar from './features/nav/components/Navbar.vue'; import Navbar from './features/nav/components/Navbar.vue';
import Footer from './features/footer/components/Footer.vue'; import Footer from './features/footer/components/Footer.vue';
import { LANGUAGE_PATH } from './config/featureSettings.js';
import { updatePageTitle } from './router'; import { updatePageTitle } from './router';
import { useSettings } from './features/settings/composables/useSettings.js'; import { useSettings } from './features/settings/composables/useSettings.js';
import { setLocale } from './i18n';
import { computed, watch, watchEffect } from 'vue'; import { computed, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -51,6 +53,8 @@ watch(colorScheme, (newColorScheme) => {
updateColorScheme(colorScheme.value); updateColorScheme(colorScheme.value);
watchEffect(() => updatePageTitle(route)); watchEffect(() => updatePageTitle(route));
watch(() => getSetting(LANGUAGE_PATH), setLocale);
</script> </script>
<template> <template>
@@ -17,20 +17,20 @@ limitations under the License.
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue'; import Icon from '@/features/icons/components/Icon.vue';
import { LANGUAGE_PATH } from '@/config/featureSettings';
import { useSettings } from '@/features/settings/composables/useSettings';
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { loadLanguage, LANGUAGES_RTL, SUPPORTED_LANGUAGES } from '@/i18n'; import { SUPPORTED_LANGUAGES } from '@/i18n';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { setSetting } = useSettings();
const isOpen = ref(false); const isOpen = ref(false);
const languageDropdown = ref(null); const languageDropdown = ref(null);
async function selectLanguage(code) { const selectLanguage = function selectLanguage (code) {
await loadLanguage(code); setSetting(LANGUAGE_PATH, code);
localStorage.setItem('locale', code);
document.documentElement.lang = code;
document.documentElement.dir = LANGUAGES_RTL.includes(code) ? 'rtl' : 'ltr';
close(); close();
}; };
@@ -14,41 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { expect, test, describe, vi, beforeEach } from 'vitest'; import { LANGUAGE_PATH } from '@/config/featureSettings.js';
import { mount } from '@vue/test-utils'; import { useSettings } from '@/features/settings/composables/useSettings.js';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { expect, test, describe } from 'vitest';
import LanguageSwitchButton from '../LanguageSwitchButton.vue'; import LanguageSwitchButton from '../LanguageSwitchButton.vue';
import { loadLanguage } from '@/i18n';
vi.mock('@/i18n', () => ({ const getWrapper = function getWrapper () {
loadLanguage: vi.fn(() => Promise.resolve()), return mountComponent(LanguageSwitchButton);
LANGUAGES_RTL: ['ar', 'he'], };
SUPPORTED_LANGUAGES: ['en', 'de', 'ar']
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key,
locale: { value: 'de' }
})
}));
vi.mock('@/features/icons/components/Icon.vue', () => ({
default: {
name: 'Icon',
template: '<span>Icon</span>'
}
}));
describe('LanguageSwitchButton', () => { describe('LanguageSwitchButton', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
document.documentElement.lang = '';
document.documentElement.dir = '';
});
test('renders correctly with initial state closed', () => { test('renders correctly with initial state closed', () => {
const wrapper = mount(LanguageSwitchButton); const wrapper = getWrapper();
expect(wrapper.find('.language-button').exists()).toBe(true); expect(wrapper.find('.language-button').exists()).toBe(true);
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
@@ -56,7 +34,7 @@ describe('LanguageSwitchButton', () => {
}); });
test('opens the dropdown when language button is clicked', async () => { test('opens the dropdown when language button is clicked', async () => {
const wrapper = mount(LanguageSwitchButton); const wrapper = getWrapper();
const button = wrapper.find('.language-button'); const button = wrapper.find('.language-button');
await button.trigger('click'); await button.trigger('click');
@@ -65,33 +43,28 @@ describe('LanguageSwitchButton', () => {
expect(button.attributes('aria-expanded')).toBe('true'); expect(button.attributes('aria-expanded')).toBe('true');
}); });
const languageTestCases = [ test('sets the language when clicking it', async () => {
{ code: 'en', expectedDir: 'ltr' }, const { getSetting } = useSettings();
{ code: 'de', expectedDir: 'ltr' },
{ code: 'ar', expectedDir: 'rtl' }
];
test.for(languageTestCases)('selectLanguage($code) sets localStorage, html attributes and changes layout direction to $expectedDir', async ({ code, expectedDir }) => { const wrapper = getWrapper();
const wrapper = mount(LanguageSwitchButton);
await wrapper.find('.language-button').trigger('click'); await wrapper.find('.language-button').trigger('click');
const options = wrapper.findAll('.language-dropdown li'); const options = wrapper.findAll('.language-dropdown li');
const optionToClick = options.find(opt => opt.text().includes(code));
await optionToClick.trigger('click');
expect(loadLanguage).toHaveBeenCalledWith(code); const enOption = options.find(opt => opt.text().includes('en'));
expect(localStorage.getItem('locale')).toBe(code); await enOption.trigger('click');
expect(document.documentElement.lang).toBe(code); expect(getSetting(LANGUAGE_PATH)).toBe('en');
expect(document.documentElement.dir).toBe(expectedDir); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
const deOption = options.find(opt => opt.text().includes('de'));
await deOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('de');
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
}); });
test('closes the dropdown when clicking outside the component', async () => { test('closes the dropdown when clicking outside the component', async () => {
const wrapper = mount(LanguageSwitchButton, { const wrapper = getWrapper();
attachTo: document.body
});
await wrapper.find('.language-button').trigger('click'); await wrapper.find('.language-button').trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true); expect(wrapper.find('.language-dropdown').exists()).toBe(true);
+7 -1
View File
@@ -57,4 +57,10 @@ export async function loadLanguage (locale) {
i18n.global.locale.value = locale; i18n.global.locale.value = locale;
loadedLanguages.add(locale); loadedLanguages.add(locale);
}; };
export const setLocale = async function setLocale (locale) {
await loadLanguage(locale);
document.documentElement.lang = locale;
document.documentElement.dir = LANGUAGES_RTL.includes(locale) ? 'rtl' : 'ltr';
};