2 Commits
Author SHA1 Message Date
jakob.scheid c9f8b513b0 chore: resolve merge conflicts 2026-07-28 19:40:55 +02:00
jakob.scheid 630a8268e3 feat(color-scheme): set color scheme on body
The color scheme is now set on the document body. The reactivity is
ensured by a watcher on the color scheme ref. The color CSS variables
are now set on the body.
2026-07-28 17:45:00 +02:00
53 changed files with 2084 additions and 13174 deletions
-4
View File
@@ -5,7 +5,3 @@ The frontend of the search engine Seekra.
## Setup ## Setup
Please execute the setup script in `scripts/` for your platform when cloning the repository. Please execute the setup script in `scripts/` for your platform when cloning the repository.
## Legal
After each update to the dependencies, please run the script `scripts/generate-license-files.js` to regenerate the third-party license files.
+1072 -3
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1
View File
@@ -13,4 +13,3 @@
- Internationalization and some major languages - Internationalization and some major languages
- Color scheme - Color scheme
- Search results view with error message which indicates that the search is not available - Search results view with error message which indicates that the search is not available
- Settings renderer
+129 -900
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -11,17 +11,14 @@
"test:run": "vitest run" "test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"terser": "^5.47.1", "terser": "^5.47.1",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-i18n": "^11.4.4", "vue-i18n": "^11.4.4",
"vue-router": "^5.0.6" "vue-router": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@pinia/testing": "^1.0.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"@vitejs/plugin-vue": "^6.0.6",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"vite": "^8.0.10", "vite": "^8.0.10",
"vite-plugin-html": "^3.2.2", "vite-plugin-html": "^3.2.2",
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env node
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fs from 'node:fs/promises';
import { generateLicenseFile } from 'generate-license-file';
const packageJson = './package.json';
const packageJsonBackup = `${packageJson}~`;
await generateLicenseFile(
packageJson,
'THIRD_PARTY_LICENSES'
);
const pkg = JSON.parse(
await fs.readFile(packageJson, 'utf-8')
);
await fs.rename(packageJson, packageJsonBackup);
const devPackage = {
...pkg,
dependencies: pkg.devDependencies ?? {},
devDependencies: {}
};
/*
The file including the dependencies for the current license file has
to be always package.json due to a bug of generate-license file. See
https://github.com/TobyAndToby/generate-license-file/issues/752
*/
await fs.writeFile(
packageJson,
JSON.stringify(devPackage)
);
await generateLicenseFile(
packageJson,
'./THIRD_PARTY_LICENSES_DEV'
);
await fs.rename(packageJsonBackup, packageJson);
-14
View File
@@ -1,17 +1,3 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
# Copyright 2026 Seekra
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
git config --local commit.template .gitmessage git config --local commit.template .gitmessage
-14
View File
@@ -1,17 +1,3 @@
#!/usr/bin/bash #!/usr/bin/bash
# Copyright 2026 Seekra
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
git config --local commit.template .gitmessage git config --local commit.template .gitmessage
+5 -13
View File
@@ -18,12 +18,8 @@ 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 { updatePageTitle } from './router';
import { useColorScheme } from './features/colorScheme/composables/useColorScheme'; import { useColorScheme } from './features/colorScheme/composables/useColorScheme';
import { ref, provide, watch, watchEffect } from 'vue'; import { ref, provide, watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const { getColorScheme, updateColorScheme } = useColorScheme(); const { getColorScheme, updateColorScheme } = useColorScheme();
const colorScheme = ref(getColorScheme()); const colorScheme = ref(getColorScheme());
@@ -46,8 +42,6 @@ watch(colorScheme, (newValue) => {
document.body.classList.remove('color-scheme-auto'); document.body.classList.remove('color-scheme-auto');
} }
}); });
watchEffect(() => updatePageTitle(route));
</script> </script>
<template> <template>
@@ -68,12 +62,6 @@ watchEffect(() => updatePageTitle(route));
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--light-bg);
color: var(--dark);
}
.main-content {
flex-grow: 1;
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
@@ -81,4 +69,8 @@ watchEffect(() => updatePageTitle(route));
--main-content-padding-x: 15px; --main-content-padding-x: 15px;
} }
} }
.main-content {
flex-grow: 1;
}
</style> </style>
-26
View File
@@ -1,26 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12 l6 8 l8 -16"
/>
</svg>

Before

Width:  |  Height:  |  Size: 832 B

-26
View File
@@ -1,26 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M8 20 l8 -8 l-8 -8"
/>
</svg>

Before

Width:  |  Height:  |  Size: 833 B

-38
View File
@@ -1,38 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="
M6 4
h12
a4 4 0 0 1 4 4
v8
a4 4 0 0 1 -4 4
h-12
a4 4 0 0 1 -4 -4
v-8
a4 4 0 0 1 4 -4
m2 0
v16
"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

+1 -1
View File
@@ -35,7 +35,7 @@ const copyrightPeriod =
<template> <template>
<footer class="global-footer"> <footer class="global-footer">
<div class="footer-segment"> <div class="footer-segment">
<RouterLink to="/settings" class="link"> <RouterLink to="settings" class="link">
{{ t('preferences.settings') }} {{ t('preferences.settings') }}
</RouterLink> </RouterLink>
<LanguageSwitchButton /> <LanguageSwitchButton />
@@ -39,7 +39,7 @@ vi.mock('@/features/icons/components/Icon.vue', () => ({
} }
})); }));
describe('LanguageSwitchButton', () => { describe('LanguageSwitchButton.vue', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear(); localStorage.clear();
@@ -1,126 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsStore } from '../stores/settingsStore';
import { computed, ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const normalizeSelectedValue = function normalizeSelectedValue (value) {
if (props.setting.allowMultiple) {
if (Array.isArray(value)) {
return value;
}
if (value === undefined || value === null) {
return [];
}
return [value];
}
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
};
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
const store = useSettingsStore();
const optionType = computed(() => props.setting.allowMultiple ? 'checkbox' : 'radio');
const selected = ref(normalizeSelectedValue(store.get(props.path) ?? props.setting.default));
watch(selected, (newValue) => {
store.set(props.path, newValue);
});
const labelId = useId();
</script>
<template>
<div>
<div class="label" :id="labelId">
{{ t(props.setting.i18n) }}
</div>
<ul class="options-list" :aria-labelledby="labelId">
<li v-for="option in props.setting.options" :key="option.name">
<label
class="settings-button"
:title="t(option.i18n)"
:aria-label="t('settings.selection.option.ariaLabel', { option: t(option.i18n) })"
>
<input
:type="optionType"
:name="props.path"
:value="option.name"
class="option-input"
v-model="selected"
>
<div class="option-check-mark-wrapper">
<Icon class="option-check-mark" name="check-mark" size="1em" />
</div>
<div class="input-label">
{{ t(option.i18n) }}
</div>
</label>
</li>
</ul>
</div>
</template>
<style scoped src="./SettingsButton.css"></style>
<style scoped>
.options-list {
list-style: none;
padding: 0;
margin: 0;
margin-top: 0.5em;
}
.option-input {
appearance: none;
display: none;
}
.option-check-mark-wrapper {
width: 1em;
height: 1em;
display: flex;
justify-content: center;
align-items: center;
}
.option-check-mark {
visibility: hidden;
}
.option-input:checked + .option-check-mark-wrapper .option-check-mark {
visibility: visible;
}
</style>
@@ -1,19 +0,0 @@
.settings-button {
--padding-x: 0.7em;
padding: 0.3em var(--padding-x);
margin-top: 0.2em;
width: calc(100% - 2 * var(--padding-x));
border-radius: 0.8em;
display: inline-flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
background-color: var(--light-bg);
user-select: none;
-moz-user-select: none;
}
.settings-button:hover {
background-color: var(--light-hover);
}
@@ -1,215 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsStore } from '../stores/settingsStore';
import { computed, ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
const store = useSettingsStore();
const inputModel = ref(store.get(props.path) ?? props.setting.default);
let oldInputValue = inputModel.value;
const doneButtonInitial = ref(true);
const inputId = useId();
const inputLabelId = useId();
const inputType = computed(() => ({
number: 'number',
string: 'text'
}[props.setting.type]
));
const hasChanged = computed(() =>
inputModel.value !== store.get(props.path)
);
watch(store, (newValue) => {
const newInputValue = newValue.get(props.path);
if (inputModel.value === oldInputValue) {
inputModel.value = newInputValue;
oldInputValue = newInputValue;
};
});
watch(inputModel, () => {
doneButtonInitial.value = false;
});
const apply = function apply () {
store.set(props.path, inputModel.value);
};
</script>
<template>
<div class="input-setting-container">
<label :for="inputId" :id="inputLabelId">
{{ t(props.setting.i18n) }}
</label>
<form
@submit.prevent="apply"
class="input-container"
:class="{ 'done-button-shown': hasChanged }"
>
<input
:type="inputType"
:id="inputId"
:aria-labelledby="inputLabelId"
v-model="inputModel"
class="input"
/>
<button
ref="doneButton"
class="done-button button"
:class="{ initial: doneButtonInitial }"
type="submit"
:title="t('settings.input.done.title')"
:aria-label="t('settings.input.done.ariaLabel')"
>
<Icon name="check-mark" />
</button>
</form>
</div>
</template>
<style scoped>
.input-setting-container {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
}
.input-container {
position: relative;
width: 50%;
white-space: nowrap;
--total-animation-duration: 0.3s;
}
.input {
box-sizing: border-box;
width: 100%;
height: calc(var(--input-height) + 2 * var(--input-padding-y));
display: inline;
vertical-align: middle;
--input-height: 24px;
transition: width calc(var(--total-animation-duration) / 3);
}
.input-container:not(.done-button-shown) .input {
transition-delay: calc((2 / 3) * var(--total-animation-duration));
}
.input-container.done-button-shown .input {
width: calc(100% - 1.5 * var(--input-padding) - 26px - 0.75 * var(--input-height) + 1.5px);
}
.done-button {
position: absolute;
right: 0;
top: 50%;
border: 1px solid var(--border);
min-height: var(--input-height);
padding: var(--input-padding);
transform: translateY(-50%) scale(0);
transform-origin: center;
vertical-align: middle;
align-items: center;
justify-content: center;
border-radius: calc(var(--input-padding) + 13px);
font-size: 1em;
font-family: inherit;
}
.done-button:not(.initial) {
animation: done-button-disappear var(--total-animation-duration);
}
.input-container.done-button-shown .done-button {
animation: done-button-appear var(--total-animation-duration);
display: inline-flex;
transform: translateY(-50%) scale(1);
}
@keyframes done-button-appear {
0% {
transform: translateY(-50%) scale(0);
}
10% {
transform: translateY(-50%) scale(0);
}
95% {
transform: translateY(-50%) scale(1.05);
}
100% {
transform: translateY(-50%) scale(1);
}
}
@keyframes done-button-disappear {
0% {
transform: translateY(-50%) scale(1);
}
100% {
transform: translateY(-50%) scale(0);
}
}
@media (prefers-reduced-motion: reduce) {
@keyframes done-button-appear {
0% {
transform: translateY(-50%) scale(1);
opacity: 0;
}
10% {
transform: translateY(-50%) scale(1);
opacity: 0;
}
100% {
transform: translateY(-50%) scale(1);
opacity: 1;
}
}
@keyframes done-button-disappear {
0% {
opacity: 1;
transform: translateY(-50%) scale(1);
}
100% {
opacity: 0;
transform: translateY(-50%) scale(1);
}
}
}
</style>
@@ -1,93 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Switch from './Switch.vue';
import SettingsInput from './SettingsInput.vue';
import Selection from './Selection.vue';
import SettingsPageLink from './SettingsPageLink.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
</script>
<template>
<div>
<h2 class="section-heading">
{{ t(props.setting.i18n) }}
</h2>
<div
v-for="setting in props.setting.content"
:key="setting"
class="setting"
>
<Switch
v-if="setting.type === 'bool'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<SettingsInput
v-if="setting.type === 'string' || setting.type === 'number'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<Selection
v-if="setting.type === 'selection'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<SettingsPageLink
v-if="setting.type === 'section'"
:display-name="t(setting.i18n)"
:target="`${props.path}.${setting.name}`"
/>
</div>
</div>
</template>
<style scoped>
.setting {
margin-bottom: 1.2em;
}
.section-heading {
--size: 1.2em;
font-size: var(--size);
margin-bottom: calc(var(--size) - 0.2em);
}
.section-heading[aria-level="2"] {
--size: 1.8em;
}
.section-heading[aria-level="3"] {
--size: 1.6em;
}
.section-heading[aria-level="4"] {
--size: 1.4em;
}
</style>
@@ -1,61 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsPage } from '../composables/useSettingsPage';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { goToSettingsPage } = useSettingsPage();
const props = defineProps({
target: {
required: true,
type: String
},
displayName: {
required: true,
type: String
}
});
const followLink = function followLink () {
goToSettingsPage(props.target);
};
</script>
<template>
<button
class="settings-button button settings-page-link"
:title="props.displayName"
:aria-label="t('settings.pages.link.ariaLabel', { page: props.displayName })"
@click="followLink"
>
{{ props.displayName }}
<Icon name="chevron-right" size="1em" />
</button>
</template>
<style scoped src="./SettingsButton.css"></style>
<style scoped>
.settings-page-link {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
-101
View File
@@ -1,101 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import { useSettingsStore } from '../stores/settingsStore';
import { useId } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
const labelId = useId();
const switchId = useId();
const store = useSettingsStore();
const toggle = function toggle () {
store.set(props.path, !store.get(props.path));
};
</script>
<template>
<div class="switch-container" @click="toggle">
<label :for="switchId" :id="labelId" class="switch-label">
{{ t(props.setting.i18n) }}
</label>
<div
class="switch-wrapper"
:class="{ enabled: store.get(props.path) }"
:title="t('settings.switch.title')"
:aria-label="t('settings.switch.ariaLabel')"
:aria-labelledby="labelId"
>
<div class="switch-point"></div>
</div>
</div>
</template>
<style scoped>
.switch-container {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
cursor: pointer;
}
.switch-label {
cursor: inherit;
}
.switch-wrapper {
--point-size: 0.9em;
--padding: 2px;
--transition-duration: 0.2s;
background-color: var(--light-bg);
outline: 1px solid var(--border);
width: calc(2 * var(--point-size));
padding: var(--padding);
border-radius: calc(0.5 * var(--point-size) + var(--padding));
transition: background-color var(--transition-duration) ease;
}
.switch-point {
height: var(--point-size);
width: var(--point-size);
background-color: var(--border);
border-radius: calc(0.5 * var(--point-size));
margin-left: 0;
transition: margin-left var(--transition-duration) ease;
}
.switch-wrapper.enabled {
background-color: var(--primary-color);
}
.switch-wrapper.enabled .switch-point {
margin-left: var(--point-size);
}
</style>
@@ -1,267 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Selection from '../Selection.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper ({
i18n = 'selection',
options = [],
allowMultiple = false,
defaultValue = undefined,
translations = {},
piniaOptions = {}
} = {}) {
return mountComponent(Selection, {
attrs: {
setting: {
name: 'selection',
type: 'selection',
i18n,
options,
allowMultiple,
default: defaultValue
},
path: 'selection'
}
}, translations, [], piniaOptions);
};
const getChecked = function getChecked (inputs) {
return inputs.map((input) => input.element.matches(':checked'));
};
const exampleOptions = [
{ name: 'o0', i18n: 'o0' },
{ name: 'o1', i18n: 'o1' },
{ name: 'o2', i18n: 'o2' }
];
describe('Selection', () => {
test('displays options correctly', () => {
const wrapper = getWrapper({ options: exampleOptions });
const listItems = wrapper.findAll('li');
expect(listItems.length).toBe(3);
for (let i = 0; i <= 2; i++) {
const option = exampleOptions[i];
const listItem = listItems[i];
expect(listItem.find('.input-label').text()).toBe(option.i18n);
const input = listItem.find('input');
expect(input.attributes('type')).toBe('radio');
expect(input.attributes('name')).toBe('selection');
expect(input.element.matches(':checked')).toBe(false);
};
});
test('shows value from store', () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
}
}
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
});
test('shows multiple values from store', () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', ['o1', 'o2']);
}
},
allowMultiple: true
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, true]);
});
test('shows default value', () => {
const wrapper = getWrapper({
options: exampleOptions,
defaultValue: 'o2'
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, false, true]);
});
test('shows multiple default values', () => {
const wrapper = getWrapper({
options: exampleOptions,
defaultValue: ['o0', 'o1'],
allowMultiple: true
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([true, true, false]);
});
test('shows store value even if default value is set', () => {
const wrapper = getWrapper({
options: exampleOptions,
default: 'o2',
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
}
}
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
});
test('stores one value', async () => {
const wrapper = getWrapper({ options: exampleOptions });
const store = useSettingsStore();
expect(store.get('selection')).toBeUndefined();
const inputs = wrapper.findAll('input');
const inputO0 = inputs[0];
const inputO1 = inputs[1];
const inputO2 = inputs[2];
await inputO0.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o0');
await inputO1.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o1');
await inputO2.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o2');
expect(inputO2.element.matches(':checked')).toBe(true);
});
test('stores multiple values', async () => {
const setValue = async function setValue (input, value) {
input.setValue(value);
await nextTick();
};
const check = function check (input) {
return setValue(input, true);
};
const uncheck = function uncheck (input) {
return setValue(input, false);
};
const expectSelectionValue = function expectSelectionValue (value, options = exampleOptions) {
value.sort();
expect(store.get('selection').sort()).toStrictEqual(value);
const checkedInputs = getChecked(inputs);
expect(
options
.map((option) => option.name)
.filter((entry, i) => checkedInputs[i])
).toStrictEqual(value);
};
const wrapper = getWrapper({
options: exampleOptions,
allowMultiple: true
});
const store = useSettingsStore();
expect(store.get('selection')).toBeUndefined();
const inputs = wrapper.findAll('input');
const inputO0 = inputs[0];
const inputO1 = inputs[1];
const inputO2 = inputs[2];
await check(inputO0);
expectSelectionValue(['o0'])
await uncheck(inputO0);
expectSelectionValue([]);
await check(inputO1);
expectSelectionValue(['o1']);
await check(inputO0);
expectSelectionValue(['o0', 'o1']);
await check(inputO2);
expectSelectionValue(['o0', 'o1', 'o2']);
await uncheck(inputO2);
expectSelectionValue(['o0', 'o1']);
await check(inputO2);
expectSelectionValue(['o0', 'o1', 'o2']);
await uncheck(inputO1);
expectSelectionValue(['o0', 'o2']);
await inputO0.setValue(true);
expectSelectionValue(['o0', 'o2']);
await inputO0.setValue(false);
expectSelectionValue(['o2']);
await inputO1.setValue(true);
expectSelectionValue(['o1', 'o2']);
await inputO2.setValue(false);
expectSelectionValue(['o1']);
await inputO1.setValue(true);
expectSelectionValue(['o1']);
await inputO1.setValue(false);
expectSelectionValue([]);
});
test('shows correct translation', () => {
const translation = 'Selection setting';
const wrapper = getWrapper({
translations: {
selection: translation
}
});
const label = wrapper.find('.label');
expect(label.text()).toBe(translation);
});
});
@@ -1,210 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsInput from '../SettingsInput.vue';
import { mountComponent } from '@/test-utils/mountComponent';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper({ type = 'string', i18n = 'input', defaultValue = undefined, piniaOptions = {} } = {}) {
return mountComponent(SettingsInput, {
attrs: {
setting: {
name: 'input',
default: defaultValue,
i18n,
type
},
path: 'input'
}
}, {}, [], piniaOptions);
}
describe('SettingsInput', () => {
test('uses correct input type', () => {
const stringSettingWrapper = getWrapper();
const stringInputElement = stringSettingWrapper.find('input');
expect(stringInputElement.attributes('type')).toBe('text');
const numberSettingWrapper = getWrapper({ type: 'number' });
const numberInputElement = numberSettingWrapper.find('input');
expect(numberInputElement.attributes('type')).toBe('number');
});
test('shows empty value', () => {
const stringSettingWrapper = getWrapper();
const inputElement = stringSettingWrapper.find('input');
expect(inputElement.attributes('value')).toBeUndefined();
});
test('shows value from store', () => {
const value = 'example value';
const stringSettingWrapper = getWrapper({
type: 'string',
i18n: 'input',
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.settings.input = value;
}
}
});
const inputElement = stringSettingWrapper.find('input');
expect(inputElement.element.value).toBe(value);
});
test('shows default value', () => {
const value = 'example value';
const wrapper = getWrapper({
type: 'string',
i18n: 'input',
defaultValue: value
});
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe(value);
});
test('store value instead of default value', () => {
const storeValue = 'store value';
const defaultValue = 'default value';
const wrapper = getWrapper({
type: 'string',
i18n: 'input',
defaultValue: defaultValue,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.settings.input = storeValue;
}
}
});
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe(storeValue);
});
test('stores value', async () => {
const value = 'example value';
const wrapper = getWrapper();
const store = useSettingsStore();
expect(store.get('input')).toBeUndefined();
const inputElement = wrapper.find('input');
await inputElement.setValue(value);
const form = wrapper.find('form');
await form.trigger('submit');
expect(store.get('input')).toBe(value);
});
test('reacts on store change', async () => {
const value = 'example value';
const value2 = 'example value 2';
const anotherValue = 'another value';
const wrapper = getWrapper();
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe('');
const store = useSettingsStore();
store.set('input', value);
await nextTick();
expect(inputElement.element.value).toBe(value);
await inputElement.setValue(anotherValue);
expect(inputElement.element.value).toBe(anotherValue);
// input does not update if there are unsaved changes
store.set('input', value2);
expect(inputElement.element.value).toBe(anotherValue);
const form = wrapper.find('form');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(store.get('input')).toBe(anotherValue);
expect(form.classes('done-button-shown')).toBeFalsy();
});
test('shows done button correctly', async () => {
const wrapper = getWrapper();
const form = wrapper.find('form');
const inputElement = wrapper.find('input');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('value');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('Hello, World!');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('a');
expect(form.classes('done-button-shown')).toBeTruthy();
const store = useSettingsStore();
store.set('input', 'a');
await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('');
store.set('input', '');
await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy();
});
test('shows correct translation', () => {
const translation = 'Text input';
const wrapper = mountComponent(SettingsInput, {
attrs: {
setting: {
name: 'input',
i18n: 'input',
type: 'string',
},
path: 'input'
}
}, {
input: translation
});
const label = wrapper.find('label');
expect(label.text()).toBe(translation);
});
});
@@ -1,160 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Selection from '../Selection.vue';
import SettingsInput from '../SettingsInput.vue';
import SettingsPage from '../SettingsPage.vue';
import SettingsPageLink from '../SettingsPageLink.vue';
import Switch from '../Switch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { describe, expect, test } from 'vitest';
const getWrapper = function getWrapper({ content = [], translations = {}, piniaOptions = {} }) {
return mountComponent(SettingsPage, {
attrs: {
setting: {
name: 'section',
type: 'section',
i18n: 'section',
content: content
},
path: 'section'
}
}, translations, [], piniaOptions);
};
const exampleSettings = [
{
type: 'bool',
name: 'b0',
i18n: 'settings.section.b0'
},
{
type: 'number',
name: 'n0',
i18n: 'settings.section.n0'
},
{
type: 'string',
name: 'st0',
i18n: 'settings.section1.st0'
},
{
type: 'selection',
name: 'se0',
i18n: 'settings.section.se0',
options: [
{ name: 'o0', i18n: 'settings.section.se0.o0' },
{ name: 'o1', i18n: 'settings.section.se0.o1' },
{ name: 'o2', i18n: 'settings.section.se0.o2' },
{ name: 'o3', i18n: 'settings.section.se0.o3' }
]
},
{
type: 'section',
name: 's0',
i18n: 'settings.section.s0',
content: []
},
{
type: 'section',
name: 's1',
i18n: 'settings.section.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.section.s1.b0',
default: true
}
]
}
];
describe('SettingsPage', () => {
test('shows correct heading', () => {
const translation = 'Example section';
const wrapper = getWrapper({
translations: { section: translation }
});
const heading = wrapper.find('.section-heading');
expect(heading.text()).toBe(translation);
});
test('shows settings correctly', () => {
const wrapper = getWrapper({
content: exampleSettings
});
const settingsWrappers = wrapper.findAll('.setting');
expect(settingsWrappers.length).toBe(exampleSettings.length);
exampleSettings.forEach((expectedSetting, index) => {
const settingWrapper = settingsWrappers[index];
const switchSetting = settingWrapper.findComponent(Switch);
const inputSetting = settingWrapper.findComponent(SettingsInput);
const selectionSetting = settingWrapper.findComponent(Selection);
const settingsPageLink = settingWrapper.findComponent(SettingsPageLink);
let setting;
if (expectedSetting.type === 'bool') {
expect(switchSetting.exists()).toBe(true);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(false);
setting = switchSetting;
} else if (expectedSetting.type === 'string' || expectedSetting.type === 'number') {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(true);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(false);
setting = inputSetting;
} else if (expectedSetting.type === 'selection') {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(true);
expect(settingsPageLink.exists()).toBe(false);
setting = selectionSetting;
} else {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(true);
setting = settingsPageLink;
}
if (
expectedSetting.type === 'bool' ||
expectedSetting.type === 'string' ||
expectedSetting.type === 'number' ||
expectedSetting.type === 'selection'
) {
expect(setting.props('setting')).toMatchObject(expectedSetting);
expect(setting.props('path')).toBe(`section.${expectedSetting.name}`);
} else {
expect(setting.props('displayName')).toBe(expectedSetting.i18n);
expect(setting.props('target')).toBe(`section.${expectedSetting.name}`);
}
});
});
});
@@ -1,59 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsPageLink from '../SettingsPageLink.vue';
import { mount } from '@vue/test-utils';
import { describe, expect, test, vi } from 'vitest';
import { createI18n } from 'vue-i18n';
const goToSettingsPage = vi.fn();
vi.mock('../../composables/useSettingsPage.js', () => ({
useSettingsPage: () => ({
goToSettingsPage
})
}));
const getWrapper = function getWrapper({ target, displayName }) {
return mount(SettingsPageLink, {
attrs: {
target,
displayName
},
global: {
plugins: [
createI18n()
]
}
});
};
describe('SettingsPageLink', () => {
test('shows correct display name', () => {
const displayName = 'A settings page link';
const wrapper = getWrapper({ displayName });
expect(wrapper.text()).toBe(displayName);
});
test('follows link', async () => {
const target = 'section1.subsection2';
const wrapper = getWrapper({ target });
const button = wrapper.find('button');
await button.trigger('click');
expect(goToSettingsPage).toHaveBeenCalledWith(target);
});
});
@@ -1,83 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Switch from '../Switch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper ({ i18n = 'switch1', translations = {} } = {}) {
return mountComponent(Switch, {
attrs: {
setting: {
type: 'bool',
name: 'switch',
i18n
},
path: 'switch'
}
}, translations);
};
describe('Switch', () => {
test('shows value from store', async () => {
const wrapper = getWrapper();
const store = useSettingsStore();
store.set('switch', true);
await nextTick();
const switchElement = wrapper.find('.switch-wrapper');
const switchElementClasses = switchElement.classes();
expect(switchElementClasses).toContain('enabled');
store.set('switch', false);
await nextTick();
const switchElementClasses2 = switchElement.classes();
expect(switchElementClasses2).not.toContain('enabled');
});
test('toggles value in store', async () => {
const wrapper = getWrapper();
const switchElement = wrapper.find('.switch-wrapper');
const store = useSettingsStore();
expect(store.get('switch')).toBeUndefined();
switchElement.trigger('click');
await nextTick();
expect(store.get('switch')).toBe(true);
switchElement.trigger('click');
await nextTick();
expect(store.get('switch')).toBe(false);
});
test('shows correct translation', () => {
const translation = 'Switch';
const wrapper = getWrapper({
translations: { switch1: translation }
})
const label = wrapper.find('label');
expect(label.text()).toBe(translation);
});
});
@@ -1,40 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useSettingsPage } from '../useSettingsPage.js';
import { describe, expect, test, vi } from 'vitest';
const pushRoute = vi.fn();
vi.mock('vue-router', () => ({
useRouter: () => ({
push: pushRoute
})
}));
describe('useSettingsPage', () => {
describe('goToSettingsPage', () => {
test('pushes correct route', async () => {
const { goToSettingsPage } = useSettingsPage();
await goToSettingsPage('..a.b...c.d..e......');
expect(pushRoute).toHaveBeenCalledWith({
name: 'settings',
params: {
rest: ['a', 'b', 'c', 'd', 'e']
}
});
});
});
});
@@ -1,33 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getSettingsPagePathSegments } from '../utils/settingsPage';
import { useRouter } from 'vue-router';
export const useSettingsPage = function useSettingsPage () {
const router = useRouter();
const goToSettingsPage = function goToSettingsPage (pagePath) {
return router.push({
name: 'settings',
params: {
rest: getSettingsPagePathSegments(pagePath)
}
});
};
return { goToSettingsPage };
};
@@ -1,42 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { defineStore } from 'pinia';
import { reactive } from 'vue';
export const useSettingsStore = defineStore('settings', () => {
const settings = reactive({});
const set = function set (key, value) {
settings[key] = value;
};
const get = function get (key) {
return settings[key];
};
const remove = function remove (key) {
delete settings[key];
};
const clear = function clear () {
Object.keys(settings).forEach(key => delete settings[key]);
};
return { set, get, remove, clear, settings };
}, {
persist: true
});
@@ -1,526 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the 'License');
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an 'AS IS' BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getSetting, getSettingRecursively } from '../getSetting.js';
import { test, expect } from 'vitest';
test.for([
({ settings: [], name: '', expected: undefined }),
({ settings: [], name: 'a', expected: undefined }),
({ settings: [
{
type: 'bool',
name: 'a',
i18n: 'a'
}
], name: 'a', expected: {
type: 'bool',
name: 'a',
i18n: 'a'
} })
])('returns $expected', ({ settings, name, expected }) => {
expect(getSetting(name, settings)).toStrictEqual(expected);
});
const veryNestedSettingsList = [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'bool',
name: '3a',
i18n: '3a'
},
{
type: 'section',
name: '3b',
i18n: '3b',
content: [
{
type: 'bool',
name: '4a',
i18n: '4a'
},
{
type: 'section',
name: '4b',
i18n: '4b',
content: [
{
type: 'bool',
name: '5a',
i18n: '5a'
},
{
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a'
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a'
}
]
}
]
}
]
}
]
}
]
}
]
}
];
test.for([
({ settings: [], path: ['a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1b', '2a'], expected: {
type: 'bool',
name: '2a',
i18n: '2a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'number',
name: '2b',
i18n: '2b',
default: 42
}
]
}
], path: ['1b', '2b'], expected: {
type: 'number',
name: '2b',
i18n: '2b',
default: 42
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b', '3a'], expected: {
type: 'string',
name: '3a',
i18n: '3a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b'], expected: {
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b'], expected: {
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b', '3b'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2a', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2c', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1a', '2b', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1c', '2b', '3a'], expected: undefined }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b', '6b', '7a'], expected: {
type: 'bool',
name: '7a',
i18n: '7a'
} }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b'], expected: {
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a'
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a'
}
]
}
]
} }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4ba', '5b', '6b', '7a'], expected: undefined })
])('returns $expected', ({ settings, path, expected }) => {
expect(getSettingRecursively(path, settings)).toStrictEqual(expected);
});
@@ -1,63 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getSettingsPagePathSegments } from '../settingsPage.js';
import { describe, expect, test } from 'vitest';
describe('settingsPage', () => {
describe('getSettingsPagePathSegments', () => {
test.for([
{ path: 'a.b.c', expected: ['a', 'b', 'c'] },
{ path: 'a', expected: ['a'] },
{ path: 'a.b', expected: ['a', 'b'] },
{ path: 'a..b', expected: ['a', 'b'] },
{ path: '.a.b', expected: ['a', 'b'] },
{ path: '..a.b', expected: ['a', 'b'] },
{ path: '.a.b.', expected: ['a', 'b'] },
{ path: '..a.b..', expected: ['a', 'b'] },
{ path: 'a.b.', expected: ['a', 'b'] },
{ path: 'a.b..', expected: ['a', 'b'] },
{ path: '.a.b..', expected: ['a', 'b'] },
{ path: '..a.b.', expected: ['a', 'b'] },
{ path: '.a..b', expected: ['a', 'b'] },
{ path: '..a..b', expected: ['a', 'b'] },
{ path: '.a..b.', expected: ['a', 'b'] },
{ path: '..a..b..', expected: ['a', 'b'] },
{ path: 'a..b.', expected: ['a', 'b'] },
{ path: 'a..b..', expected: ['a', 'b'] },
{ path: '.a..b..', expected: ['a', 'b'] },
{ path: '..a..b.', expected: ['a', 'b'] },
{ path: '..a.....b.c..d....', expected: ['a', 'b', 'c', 'd'] },
{ path: 'a.b.c/', expected: ['a', 'b', 'c'] },
{ path: 'a.#b.c', expected: ['a', 'b', 'c'] },
{ path: 'a.#b.c/', expected: ['a', 'b', 'c'] },
{ path: 'a1.#b.c/', expected: ['a1', 'b', 'c'] },
{ path: 'a-1.#b.c/', expected: ['a-1', 'b', 'c'] },
{ path: 'a-1.b@.c', expected: ['a-1', 'b', 'c'] },
{ path: '....@a/#...)!&§[b.#§c..d....', expected: ['a', 'b', 'c', 'd'] },
{ path: '....@a/#...)!&§[b.#§c..dä....', expected: ['a', 'b', 'c', 'd'] },
{ path: '..,...~..@a/#.+..)!&§[b.#§c..dä..)..', expected: ['a', 'b', 'c', 'd'] },
{ path: 'a.@.b', expected: ['a', 'b'] },
{ path: 1, expected: [] },
{ path: false, expected: [] },
{ path: true, expected: [] },
{ path: null, expected: [] },
{ path: undefined, expected: [] }
])('returns $expected', async ({ path, expected }) => {
expect(getSettingsPagePathSegments(path)).toStrictEqual(expected);
});
})
})
File diff suppressed because it is too large Load Diff
-42
View File
@@ -1,42 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const getSetting = function getSetting (name, settings) {
return settings.filter((setting) => setting.name === name)[0];
};
export const getSettingRecursively = function getSettingRecursively (sectionPath, settings) {
const oneSettingsLevel = function oneSettingsLevel (cursor, acc) {
const setting = getSetting(cursor[0], acc.content);
if (!setting) return;
if (setting.type === undefined) setting.type = 'section';
if (setting.type === 'section') {
const newCursor = cursor.slice(1, cursor.length);
if (newCursor.length !== 0) {
return oneSettingsLevel(cursor.slice(1, cursor.length), setting);
} else {
return setting;
};
} else if (cursor.length <= 1) {
return setting;
} else {
return;
};
};
return oneSettingsLevel(sectionPath, { content: settings });
};
@@ -1,31 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Extracts the segments of a settings page path and normalizes each segment.
* @param {string} path - The settings page path.
* @returns {Array} The settings page path segments.
*/
export const getSettingsPagePathSegments = function getSettingsPagePathSegments (path) {
if (typeof path !== 'string') return [];
return path
.replace(/^\.+|\.+$/g, '')
.split('.')
.map(
(segment) => segment.replace(/[^a-zA-Z0-9-]/g, '')
)
.filter(Boolean);
};
@@ -73,27 +73,12 @@ export const validateEntry = function validateEntry (entry, path) {
throw new Error(`[settings] "${path}.default" must be a string`); throw new Error(`[settings] "${path}.default" must be a string`);
} }
if (entry.type === 'selection') { if (entry.type === 'selection') {
if (entry.allowMultiple) { if (typeof entry.default !== 'string') {
if (!Array.isArray(entry.default)) { throw new Error(`[settings] "${path}.default" must be a string`);
throw new Error(`[settings] "${path}.default" must be an array`); };
} if (!entry.options.map((option) => option.name).includes(entry.default)) {
const allOptions = entry.options.map((option) => option.name); throw new Error(`[settings] option "${path}.default" does not exist`);
entry.default.forEach((defaultValue, index) => { };
if (typeof defaultValue !== 'string') {
throw new Error(`[settings] "${path}.default[${index}]" must be a string`);
}
if (!allOptions.includes(defaultValue)) {
throw new Error(`[settings] option "${path}.default[${index}]" does not exist`);
}
});
} else {
if (typeof entry.default !== 'string') {
throw new Error(`[settings] "${path}.default" must be a string`);
};
if (!entry.options.map((option) => option.name).includes(entry.default)) {
throw new Error(`[settings] option "${path}.default" does not exist`);
};
}
}; };
} }
} }
@@ -16,51 +16,10 @@ limitations under the License.
<script setup> <script setup>
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue'; import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import SettingsPage from '../components/SettingsPage.vue';
import { getSettingRecursively } from '../utils/getSetting.js';
import { loadSettingsConfig } from '../utils/settingsParser';
import { onMounted, ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const settingsLoaded = ref(false)
const settings = ref([]);
const activeSectionContent = ref({});
const activeSection = ref(null);
watchEffect(() => {
const segments = route.path
.split('/')
.filter(Boolean);
activeSection.value = segments.slice(1, segments.length).join('.');
});
const updateSettings = function updateSettings () {
if (activeSection.value) {
const setting = getSettingRecursively(activeSection.value.split('.'), settings.value);
if (!setting) {
router.push('/settings');
} else {
activeSectionContent.value = setting;
};
} else {
activeSectionContent.value = { content: [] };
};
};
onMounted(async () => {
settings.value = (await loadSettingsConfig()).contents;
watchEffect(() => {
updateSettings();
});
settingsLoaded.value = true;
});
</script> </script>
<template> <template>
@@ -72,29 +31,7 @@ onMounted(async () => {
</header> </header>
<LeftSidebarLayout class="layout"> <LeftSidebarLayout class="layout">
<template #sidebar> <template #sidebar>
<ul class="sidebar-sections-list">
<li v-for="section in settings">
<RouterLink
:to="`/settings/${section.name}`"
class="button button-link link sidebar-section"
:class="{ active: activeSection === section.name }"
>
{{ t(section.i18n) }}
</RouterLink>
</li>
</ul>
</template> </template>
<div v-if="!settingsLoaded" class="main-content--loading">
{{ t('loading') }}
</div>
<div v-else-if="activeSection" class="main-content--loaded">
<div class="settings-main-content">
<SettingsPage :setting="activeSectionContent" :path="activeSection" />
</div>
</div>
<div v-else class="main-content--no-section">
{{ t('settings.noSectionSelected') }}
</div>
</LeftSidebarLayout> </LeftSidebarLayout>
</div> </div>
</template> </template>
@@ -109,10 +46,6 @@ onMounted(async () => {
flex-direction: column; flex-direction: column;
} }
.active {
background-color: var(--light-hover);
}
.header { .header {
padding: var(--main-content-padding-y) var(--main-content-padding-x); padding: var(--main-content-padding-y) var(--main-content-padding-x);
} }
@@ -120,31 +53,4 @@ onMounted(async () => {
.header h1 { .header h1 {
margin: 0; margin: 0;
} }
.sidebar-sections-list {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar-section {
--padding: 0.8em;
border-radius: var(--padding);
padding: var(--padding);
margin-bottom: 4px;
width: calc(100% - 2 * var(--padding));
text-align: start;
font-size: 1rem;
display: block;
}
.settings-main-content {
padding: 0 30%;
}
@media (max-width: 80em) {
.settings-main-content {
padding: 0;
}
}
</style> </style>
@@ -1,306 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsView from '../SettingsView.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { flushPromises } from '@vue/test-utils';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const settingsHoisted = vi.hoisted(() => [
{
name: 's0',
i18n: 'settings.s0',
content: []
},
{
name: 's1',
i18n: 'settings.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s1.b0'
},
{
type: 'number',
name: 'n0',
i18n: 'settings.s1.n0'
},
{
type: 'string',
name: 'st0',
i18n: 'settings.s1.st0'
},
{
type: 'selection',
name: 'se0',
i18n: 'settings.s1.se0',
options: [
{ name: 'o0', i18n: 'settings.s1.se0.o0' },
{ name: 'o1', i18n: 'settings.s1.se0.o1' },
{ name: 'o2', i18n: 'settings.s1.se0.o2' },
{ name: 'o3', i18n: 'settings.s1.se0.o3' }
]
},
{
type: 'section',
name: 's0',
i18n: 'settings.s1.s0',
content: []
},
{
type: 'section',
name: 's1',
i18n: 'settings.s1.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s1.s1.b0',
default: true
}
]
}
]
},
{
name: 's2',
i18n: 'settings.s2',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s2.s0',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s2.s0.b0',
default: true
}
]
}
]
},
{
name: 's3',
i18n: 'settings.s3',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s3.s0',
content: []
}
]
},
{
name: 's4',
i18n: 'settings.s4',
content: [
{
type: 'selection',
name: 'se0',
i18n: 'settings.s4.se0',
options: [
{ name: 'o0', i18n: 'settings.s4.se0.o0' },
{ name: 'o1', i18n: 'settings.s4.se0.o1' },
{ name: 'o2', i18n: 'settings.s4.se0.o2' },
{ name: 'o3', i18n: 'settings.s4.se0.o3' }
]
}
]
},
{
name: 's5',
i18n: 'settings.s5',
content: [
{
type: 'string',
name: 'st0',
i18n: 'settings.s5.st0'
}
]
},
{
name: 's6',
i18n: 'settings.s6',
content: [
{
type: 'number',
name: 'n0',
i18n: 'settings.s6.n0'
}
]
},
{
name: 's7',
i18n: 'settings.s7',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s7.b0'
}
]
},
{
name: 's8',
i18n: 'settings.s8',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0.s0',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0.s0.s0',
content: [
{
type: 'selection',
name: 'se0',
i18n: 'settings.s8.s0.s0.s0.se0',
allowMultiple: true,
options: []
}
]
}
]
}
]
}
]
}
]);
const settings = settingsHoisted.values();
const originalCurrentSection = 's0';
let currentSection = originalCurrentSection;
vi.mock('../../utils/settingsParser', () => ({
loadSettingsConfig: vi.fn().mockResolvedValue({
contents: settingsHoisted
})
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useRoute: () => ({
path: `/settings/${currentSection}`
})
};
});
const getWrapper = function getWrapper ({ translations = {} } = {}) {
return mountComponent(SettingsView, {}, translations, [], {});
};
describe('SettingsView', () => {
describe('heading', () => {
test('shows correct translation', () => {
const translation = 'Settings';
const wrapper = getWrapper({
translations: {
'preferences.settings': translation
}
});
const header = wrapper.find('header');
expect(header.text()).toBe(translation);
});
});
describe('sidebar', () => {
test('shows all top-level sections', async () => {
const wrapper = getWrapper();
await flushPromises();
await wrapper.vm.$nextTick();
const sidebarItems = wrapper.findAll('.sidebar-sections-list > li');
settings.forEach((setting, index) => {
const sidebarItem = sidebarItems[index];
expect(sidebarItem.text()).toBe(setting.i18n);
const link = sidebarItem.find('*');
expect(link.attributes('href')).toBe(`/settings/${setting.name}`);
expect(link.classes('active')).toBe(setting.name === currentSection);
});
});
});
describe('main', () => {
describe('correct element', () => {
test('shows that it is loading', () => {
const wrapper = getWrapper();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(true);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(false);
});
test('shows that no section is selected', async () => {
currentSection = '';
const wrapper = getWrapper();
await flushPromises();
await wrapper.vm.$nextTick();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(true);
});
test('shows main content', async () => {
const wrapper = getWrapper();
await flushPromises();
await wrapper.vm.$nextTick();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(true);
expect(mainContentNoSection.exists()).toBe(false);
});
});
});
});
beforeEach(() => {
currentSection = originalCurrentSection;
});
+2 -47
View File
@@ -14,60 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<script setup>
import SidebarExpandButton from './SidebarExpandButton.vue';
const props = defineProps({
'expanded': {
default: false,
required: false,
type: Boolean
}
});
const emit = defineEmits(['toggleExpanded']);
const toggleSidebar = function toggleSidebar () {
emit('toggleExpanded');
};
</script>
<template> <template>
<nav class="sidebar" :class="{ expanded }"> <nav class="sidebar">
<div class="sidebar-controls"> <slot />
<SidebarExpandButton @click="toggleSidebar" class="sidebar-expand-button" />
</div>
<div class="sidebar-content" v-if="props.expanded">
<slot />
</div>
</nav> </nav>
</template> </template>
<style scoped> <style scoped>
.sidebar { .sidebar {
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
}
.sidebar-controls {
display: flex;
align-items: center;
justify-content: end;
padding: 1em 20px;
padding-bottom: 0;
margin-bottom: 8px;
}
.sidebar:not(.expanded) .sidebar-controls {
padding: 1em;
}
@media (max-width: 48rem) {
.sidebar:not(.expanded) .sidebar-expand-button {
display: none;
}
}
.sidebar-content {
padding: 20px; padding: 20px;
padding-top: 0;
} }
</style> </style>
@@ -1,41 +0,0 @@
<!--
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
const emit = defineEmits(['click']);
const click = function click (event) {
emit('click', event);
};
</script>
<template>
<button class="button sidebar-expand-button">
<Icon name="sidebar-expand" @click="click" />
</button>
</template>
<style scoped>
.sidebar-expand-button {
display: flex;
justify-content: center;
align-items: center;
padding: 4px;
border-radius: 10px;
}
</style>
+3 -64
View File
@@ -16,30 +16,11 @@ limitations under the License.
<script setup> <script setup>
import Sidebar from '@/features/sidebar/components/Sidebar.vue'; import Sidebar from '@/features/sidebar/components/Sidebar.vue';
import SidebarExpandButton from '@/features/sidebar/components/SidebarExpandButton.vue';
import { ref } from 'vue';
const props = defineProps({
'expanded': {
default: false,
required: false,
type: Boolean
}
});
const expanded = ref(props.expanded);
const toggleExpanded = function toggleExpanded () {
expanded.value = !expanded.value;
};
</script> </script>
<template> <template>
<div class="layout-container" :class="{ expanded }"> <div class="layout-container">
<div class="sidebar-expand-button-container"> <Sidebar>
<SidebarExpandButton class="sidebar-expand-button" @click="toggleExpanded" />
</div>
<Sidebar @toggle-expanded="toggleExpanded" :expanded="expanded" class="sidebar">
<slot name="sidebar" /> <slot name="sidebar" />
</Sidebar> </Sidebar>
<main class="main-content"> <main class="main-content">
@@ -51,53 +32,11 @@ const toggleExpanded = function toggleExpanded () {
<style scoped> <style scoped>
.layout-container { .layout-container {
display: grid; display: grid;
grid-template-columns: calc(32px + 2em) 1fr; grid-template-columns: min(24%, 280px) 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"sidebar-expand sidebar-expand"
"sidebar main-content";
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.layout-container.expanded {
grid-template-columns: min(24%, 280px) 1fr;
}
.sidebar-expand-button-container {
grid-area: sidebar-expand;
display: none;
}
.sidebar {
grid-area: sidebar;
}
@media (max-width: 48rem) {
.layout-container {
grid-template-columns: 0 1fr;
}
.layout-container.expanded {
grid-template-columns: 1fr 0;
}
.layout-container:not(.expanded) .sidebar-expand-button-container {
justify-content: end;
display: block;
padding: 20px;
}
.layout-container:not(.expanded) .main-content {
padding-top: 0;
}
.layout-container.expanded .main-content {
display: none;
}
}
.main-content { .main-content {
grid-area: main-content;
padding: 20px; padding: 20px;
} }
</style> </style>
+1 -25
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Laden ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Suchen", "submit": "Suchen",
@@ -38,28 +37,5 @@
} }
} }
}, },
"slogan": "Gebaut zum Suchen.", "slogan": "Gebaut zum Suchen."
"settings": {
"input": {
"done": {
"title": "Wert speichern",
"ariaLabel": "Speichert den eingegebenen Wert"
}
},
"switch": {
"title": "Wert umschalten",
"ariaLabel": "Schaltet den Wert um"
},
"pages": {
"link": {
"ariaLabel": "Geht zur Einstellungsseite {page}"
}
},
"noSectionSelected": "Kein Einstellungsabschnitt ist ausgewählt.",
"selection": {
"option": {
"ariaLabel": "Wählt die Option {option} aus"
}
}
}
} }
+1 -25
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Loading ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Search", "submit": "Search",
@@ -38,28 +37,5 @@
} }
} }
}, },
"slogan": "Built to search.", "slogan": "Built to search."
"settings": {
"input": {
"done": {
"title": "Save value",
"ariaLabel": "Saves the entered value"
}
},
"switch": {
"title": "Toggle value",
"ariaLabel": "Toggles the value"
},
"pages": {
"link": {
"ariaLabel": "Goes to settings page {page}"
}
},
"noSectionSelected": "No settings section is selected.",
"selection": {
"option": {
"ariaLabel": "Selects option {option}"
}
}
}
} }
+1 -25
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Cargando ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Buscar", "submit": "Buscar",
@@ -38,28 +37,5 @@
} }
} }
}, },
"slogan": "Hecho para buscar.", "slogan": "Hecho para buscar."
"settings": {
"input": {
"done": {
"title": "Guardar valor",
"ariaLabel": "Guarda el valor introducido"
}
},
"switch": {
"title": "Cambiar valor",
"ariaLabel": "Cambia el valor"
},
"pages": {
"link": {
"ariaLabel": "Va a la página de configuración {page}"
}
},
"noSectionSelected": "No se ha seleccionado ninguna sección de configuración.",
"selection": {
"option": {
"ariaLabel": "Selecciona la opción {option}"
}
}
}
} }
+1 -25
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Chargement ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Rechercher", "submit": "Rechercher",
@@ -38,28 +37,5 @@
} }
} }
}, },
"slogan": "Conçu pour chercher.", "slogan": "Conçu pour chercher."
"settings": {
"input": {
"done": {
"title": "Enregistrer la valeur",
"ariaLabel": "Enregistre la valeur saisie"
}
},
"switch": {
"title": "Basculer la valeur",
"ariaLabel": "Bascule la valeur"
},
"pages": {
"link": {
"ariaLabel": "Accéder à la page de paramètres {page}"
}
},
"noSectionSelected": "Aucune section des paramètres n'est sélectionnée.",
"selection": {
"option": {
"ariaLabel": "Sélectionne l'option {option}"
}
}
}
} }
+1 -25
View File
@@ -1,5 +1,4 @@
{ {
"loading": "Caricamento ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Cerca", "submit": "Cerca",
@@ -38,28 +37,5 @@
} }
} }
}, },
"slogan": "Costruito per cercare.", "slogan": "Costruito per cercare."
"settings": {
"input": {
"done": {
"title": "Salva valore",
"ariaLabel": "Salva il valore inserito"
}
},
"switch": {
"title": "Attiva/disattiva valore",
"ariaLabel": "Attiva o disattiva il valore"
},
"pages": {
"link": {
"ariaLabel": "Vai alla pagina delle impostazioni {page}"
}
},
"noSectionSelected": "Nessuna sezione delle impostazioni è selezionata.",
"selection": {
"option": {
"ariaLabel": "Seleziona l'opzione {option}"
}
}
}
} }
+1 -25
View File
@@ -1,5 +1,4 @@
{ {
"loading": "A carregar ...",
"search": { "search": {
"searchBar": { "searchBar": {
"submit": "Pesquisar", "submit": "Pesquisar",
@@ -38,28 +37,5 @@
} }
} }
}, },
"slogan": "Feito para pesquisar.", "slogan": "Feito para pesquisar."
"settings": {
"input": {
"done": {
"title": "Guardar valor",
"ariaLabel": "Guarda o valor introduzido"
}
},
"switch": {
"title": "Alternar valor",
"ariaLabel": "Alterna o valor"
},
"pages": {
"link": {
"ariaLabel": "Ir para a página de definições {page}"
}
},
"noSectionSelected": "Nenhuma secção de definições está selecionada.",
"selection": {
"option": {
"ariaLabel": "Seleciona a opção {option}"
}
}
}
} }
+3 -12
View File
@@ -14,16 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { createApp } from 'vue' import { createApp } from 'vue';
import { createPinia } from 'pinia'; import App from './App.vue';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue'
import { i18n, loadLanguage, fallbackLocale } from './i18n'; import { i18n, loadLanguage, fallbackLocale } from './i18n';
import getCurrentLanguage from './utils/currentLanguage'; import getCurrentLanguage from './utils/currentLanguage';
import router from './router' import router from './router';
import './styles/common.css'
import './styles/variables/colors.css'
import './styles/variables/dimensions.css';
import './styles/common.css'; import './styles/common.css';
import './styles/variables/colors.css'; import './styles/variables/colors.css';
@@ -32,12 +27,8 @@ import './styles/variables/colors.css';
await loadLanguage(fallbackLocale); await loadLanguage(fallbackLocale);
await loadLanguage(getCurrentLanguage()); await loadLanguage(getCurrentLanguage());
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
createApp(App) createApp(App)
.use(router) .use(router)
.use(i18n) .use(i18n)
.use(pinia)
.mount('#app'); .mount('#app');
})(); })();
+6 -17
View File
@@ -40,7 +40,7 @@ const routes = [
} }
}, },
{ {
path: '/settings/:rest(.*)*', path: '/settings',
name: 'settings', name: 'settings',
component: SettingsView, component: SettingsView,
meta: { meta: {
@@ -59,29 +59,18 @@ const router = createRouter({
routes routes
}); });
export const updatePageTitle = function updatePageTitle (route) { // set page title
router.afterEach(to => {
const title = const title =
typeof route.meta.title === 'function' typeof to.meta.title === 'function'
? route.meta.title(route) ? to.meta.title(to)
: route.meta.title; : to.meta.title;
if (title) { if (title) {
document.title = `${title} - Seekra`; document.title = `${title} - Seekra`;
} else { } else {
document.title = 'Seekra'; document.title = 'Seekra';
}; };
};
// remove trailing slash(es)
router.beforeEach((route) => {
if (route.path !== '/' && route.path.endsWith('/')) {
return {
path: route.path.replace(/\/+$/, ''),
query: route.query,
hash: route.hash,
replace: true
};
};
}); });
export default router; export default router;
+1 -50
View File
@@ -25,64 +25,15 @@ body {
text-decoration: none; text-decoration: none;
} }
.link:hover:not(.button-link), .link:focus-visible:not(.button-link) { .link:hover:not(.button-link) {
outline: none;
text-decoration: underline; text-decoration: underline;
} }
.button-link {
color: var(--dark);
}
.button-link:hover {
color: var(--dark);
}
.button-link:visited {
color: var(--dark);
}
input { input {
color: var(--dark); color: var(--dark);
} }
.input {
padding: var(--input-padding);
display: block;
border-radius: calc(var(--input-padding) + 0.5 * var(--input-height) + 1px);
height: var(--input-height);
border: 1px solid var(--border);
background: none;
transition: border-color 0.2s ease;
color: inherit;
font-family: inherit;
font-size: inherit;
}
.input:focus-visible {
border-color: var(--primary-color);
outline: none;
}
.main-content-padding { .main-content-padding {
padding: var(--main-content-padding); padding: var(--main-content-padding);
width: calc(100% - var(--main-content-padding-x) * 2); width: calc(100% - var(--main-content-padding-x) * 2);
} }
.button {
background-color: var(--light-bg);
border: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
color: inherit;
}
.button:focus-visible {
background-color: var(--light-hover);
outline: none;
}
.button:hover {
background-color: var(--light-hover);
}
-4
View File
@@ -1,4 +0,0 @@
:root {
--input-padding: 0.7em;
--input-height: 1em;
}
-62
View File
@@ -1,62 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { createMemoryHistory, createRouter } from 'vue-router';
export const mountComponent = function mountComponent (component, options = {}, i18nMessages = {}, routes = [], piniaOptions = {}) {
const {
setupStores,
...testingPiniaOptions
} = piniaOptions;
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: i18nMessages
}
});
const router = createRouter({
history: createMemoryHistory(),
routes
});
const testingPinia = createTestingPinia({
...testingPiniaOptions,
stubActions: false,
createSpy: vi.fn
});
if (setupStores) {
setupStores();
}
return mount(component, {
global: {
plugins: [
i18n,
router,
testingPinia
],
},
...options
});
};
+89 -91
View File
@@ -14,111 +14,109 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { describe, expect, test } from 'vitest'; import { expect, test } from 'vitest';
import { ensureUnit } from '../cssDimensions'; import { ensureUnit } from '../cssDimensions';
describe('cssDimensions', () => { test.for([
test.for([ { dimension: null, expected: '0px' },
{ dimension: null, expected: '0px' }, { dimension: undefined, expected: '0px' },
{ dimension: undefined, expected: '0px' },
{ dimension: 0, expected: '0px' }, { dimension: 0, expected: '0px' },
{ dimension: -0, expected: '0px' }, { dimension: -0, expected: '0px' },
{ dimension: -1, expected: '-1px' }, { dimension: -1, expected: '-1px' },
{ dimension: -42, expected: '-42px' }, { dimension: -42, expected: '-42px' },
{ dimension: 1, expected: '1px' }, { dimension: 1, expected: '1px' },
{ dimension: 42, expected: '42px' }, { dimension: 42, expected: '42px' },
{ dimension: 0.0, expected: '0px' }, { dimension: 0.0, expected: '0px' },
{ dimension: -0.0, expected: '0px' }, { dimension: -0.0, expected: '0px' },
{ dimension: -1.0, expected: '-1px' }, { dimension: -1.0, expected: '-1px' },
{ dimension: -42.0, expected: '-42px' }, { dimension: -42.0, expected: '-42px' },
{ dimension: 1.0, expected: '1px' }, { dimension: 1.0, expected: '1px' },
{ dimension: 42.0, expected: '42px' }, { dimension: 42.0, expected: '42px' },
{ dimension: 0.42, expected: '0.42px' }, { dimension: 0.42, expected: '0.42px' },
{ dimension: -0.42, expected: '-0.42px' }, { dimension: -0.42, expected: '-0.42px' },
{ dimension: -1.42, expected: '-1.42px' }, { dimension: -1.42, expected: '-1.42px' },
{ dimension: -42.42, expected: '-42.42px' }, { dimension: -42.42, expected: '-42.42px' },
{ dimension: 1.42, expected: '1.42px' }, { dimension: 1.42, expected: '1.42px' },
{ dimension: 42.42, expected: '42.42px' }, { dimension: 42.42, expected: '42.42px' },
{ dimension: '0', expected: '0px' }, { dimension: '0', expected: '0px' },
{ dimension: '-0', expected: '-0px' }, { dimension: '-0', expected: '-0px' },
{ dimension: '-1', expected: '-1px' }, { dimension: '-1', expected: '-1px' },
{ dimension: '-42', expected: '-42px' }, { dimension: '-42', expected: '-42px' },
{ dimension: '1', expected: '1px' }, { dimension: '1', expected: '1px' },
{ dimension: '42', expected: '42px' }, { dimension: '42', expected: '42px' },
{ dimension: '0.0', expected: '0.0px' }, { dimension: '0.0', expected: '0.0px' },
{ dimension: '-0.0', expected: '-0.0px' }, { dimension: '-0.0', expected: '-0.0px' },
{ dimension: '-1.0', expected: '-1.0px' }, { dimension: '-1.0', expected: '-1.0px' },
{ dimension: '-42.0', expected: '-42.0px' }, { dimension: '-42.0', expected: '-42.0px' },
{ dimension: '1.0', expected: '1.0px' }, { dimension: '1.0', expected: '1.0px' },
{ dimension: '42.0', expected: '42.0px' }, { dimension: '42.0', expected: '42.0px' },
{ dimension: '0.42', expected: '0.42px' }, { dimension: '0.42', expected: '0.42px' },
{ dimension: '-0.42', expected: '-0.42px' }, { dimension: '-0.42', expected: '-0.42px' },
{ dimension: '-1.42', expected: '-1.42px' }, { dimension: '-1.42', expected: '-1.42px' },
{ dimension: '-42.42', expected: '-42.42px' }, { dimension: '-42.42', expected: '-42.42px' },
{ dimension: '1.42', expected: '1.42px' }, { dimension: '1.42', expected: '1.42px' },
{ dimension: '42.42', expected: '42.42px' }, { dimension: '42.42', expected: '42.42px' },
{ dimension: '0px', expected: '0px' }, { dimension: '0px', expected: '0px' },
{ dimension: '-0px', expected: '-0px' }, { dimension: '-0px', expected: '-0px' },
{ dimension: '-1px', expected: '-1px' }, { dimension: '-1px', expected: '-1px' },
{ dimension: '-42px', expected: '-42px' }, { dimension: '-42px', expected: '-42px' },
{ dimension: '1px', expected: '1px' }, { dimension: '1px', expected: '1px' },
{ dimension: '42px', expected: '42px' }, { dimension: '42px', expected: '42px' },
{ dimension: '0.0px', expected: '0.0px' }, { dimension: '0.0px', expected: '0.0px' },
{ dimension: '-0.0px', expected: '-0.0px' }, { dimension: '-0.0px', expected: '-0.0px' },
{ dimension: '-1.0px', expected: '-1.0px' }, { dimension: '-1.0px', expected: '-1.0px' },
{ dimension: '-42.0px', expected: '-42.0px' }, { dimension: '-42.0px', expected: '-42.0px' },
{ dimension: '1.0px', expected: '1.0px' }, { dimension: '1.0px', expected: '1.0px' },
{ dimension: '42.0px', expected: '42.0px' }, { dimension: '42.0px', expected: '42.0px' },
{ dimension: '0.42px', expected: '0.42px' }, { dimension: '0.42px', expected: '0.42px' },
{ dimension: '-0.42px', expected: '-0.42px' }, { dimension: '-0.42px', expected: '-0.42px' },
{ dimension: '-1.42px', expected: '-1.42px' }, { dimension: '-1.42px', expected: '-1.42px' },
{ dimension: '-42.42px', expected: '-42.42px' }, { dimension: '-42.42px', expected: '-42.42px' },
{ dimension: '1.42px', expected: '1.42px' }, { dimension: '1.42px', expected: '1.42px' },
{ dimension: '42.42px', expected: '42.42px' }, { dimension: '42.42px', expected: '42.42px' },
{ dimension: '0em', expected: '0em' }, { dimension: '0em', expected: '0em' },
{ dimension: '-0em', expected: '-0em' }, { dimension: '-0em', expected: '-0em' },
{ dimension: '-1em', expected: '-1em' }, { dimension: '-1em', expected: '-1em' },
{ dimension: '-42em', expected: '-42em' }, { dimension: '-42em', expected: '-42em' },
{ dimension: '1em', expected: '1em' }, { dimension: '1em', expected: '1em' },
{ dimension: '42em', expected: '42em' }, { dimension: '42em', expected: '42em' },
{ dimension: '0.0em', expected: '0.0em' }, { dimension: '0.0em', expected: '0.0em' },
{ dimension: '-0.0em', expected: '-0.0em' }, { dimension: '-0.0em', expected: '-0.0em' },
{ dimension: '-1.0em', expected: '-1.0em' }, { dimension: '-1.0em', expected: '-1.0em' },
{ dimension: '-42.0em', expected: '-42.0em' }, { dimension: '-42.0em', expected: '-42.0em' },
{ dimension: '1.0em', expected: '1.0em' }, { dimension: '1.0em', expected: '1.0em' },
{ dimension: '42.0em', expected: '42.0em' }, { dimension: '42.0em', expected: '42.0em' },
{ dimension: '0.42em', expected: '0.42em' }, { dimension: '0.42em', expected: '0.42em' },
{ dimension: '-0.42em', expected: '-0.42em' }, { dimension: '-0.42em', expected: '-0.42em' },
{ dimension: '-1.42em', expected: '-1.42em' }, { dimension: '-1.42em', expected: '-1.42em' },
{ dimension: '-42.42em', expected: '-42.42em' }, { dimension: '-42.42em', expected: '-42.42em' },
{ dimension: '1.42em', expected: '1.42em' }, { dimension: '1.42em', expected: '1.42em' },
{ dimension: '42.42em', expected: '42.42em' }, { dimension: '42.42em', expected: '42.42em' },
{ dimension: 'calc(42px - 1em)', expected: 'calc(42px - 1em)' }, { dimension: 'calc(42px - 1em)', expected: 'calc(42px - 1em)' },
{ dimension: 'calc(42px)', expected: 'calc(42px)' }, { dimension: 'calc(42px)', expected: 'calc(42px)' },
{ dimension: 'calc(42vh)', expected: 'calc(42vh)' }, { dimension: 'calc(42vh)', expected: 'calc(42vh)' },
{ dimension: 'min(42px, 1em)', expected: 'min(42px, 1em)' }, { dimension: 'min(42px, 1em)', expected: 'min(42px, 1em)' },
{ dimension: 'max(42px, 5rem)', expected: 'max(42px, 5rem)' }, { dimension: 'max(42px, 5rem)', expected: 'max(42px, 5rem)' },
{ dimension: 'clamp(42vh, 23vw, 13cap)', expected: 'clamp(42vh, 23vw, 13cap)' }, { dimension: 'clamp(42vh, 23vw, 13cap)', expected: 'clamp(42vh, 23vw, 13cap)' },
{ dimension: 'calc(42px-1em)', expected: 'calc(42px-1em)' }, { dimension: 'calc(42px-1em)', expected: 'calc(42px-1em)' },
{ dimension: 'min(42px,1em)', expected: 'min(42px,1em)' }, { dimension: 'min(42px,1em)', expected: 'min(42px,1em)' },
{ dimension: 'max(42px,5rem)', expected: 'max(42px,5rem)' }, { dimension: 'max(42px,5rem)', expected: 'max(42px,5rem)' },
{ dimension: 'clamp(42vh,23vw,13cap)', expected: 'clamp(42vh,23vw,13cap)' }, { dimension: 'clamp(42vh,23vw,13cap)', expected: 'clamp(42vh,23vw,13cap)' },
{ dimension: 'clamp( 42vh,23vw,13cap )', expected: 'clamp( 42vh,23vw,13cap )' } { dimension: 'clamp( 42vh,23vw,13cap )', expected: 'clamp( 42vh,23vw,13cap )' }
])('ensureUnit returns $expected with input $dimension', ({ dimension, expected }) => { ])('ensureUnit returns $expected with input $dimension', ({ dimension, expected }) => {
expect(ensureUnit(dimension)).toBe(expected); expect(ensureUnit(dimension)).toBe(expected);
});
}); });
+35 -35
View File
@@ -14,48 +14,48 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { describe, expect, test } from 'vitest'; import { expect, test } from 'vitest';
import getCurrentLanguage from '../currentLanguage'; import getCurrentLanguage from '../currentLanguage';
describe('currentLanguage', () => { const locales = [
test.for([ { navigatorLanguage: 'en', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'en', localStorageLanguage: null, expected: 'en'}, { navigatorLanguage: 'de', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'de', localStorageLanguage: null, expected: 'de'}, { navigatorLanguage: 'fr', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'fr', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'en-US', localStorageLanguage: null, expected: 'en'}, { navigatorLanguage: 'en-US', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'en-AU', localStorageLanguage: null, expected: 'en'}, { navigatorLanguage: 'en-AU', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-DE', localStorageLanguage: null, expected: 'de'}, { navigatorLanguage: 'de-DE', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr-FR', localStorageLanguage: null, expected: 'fr'}, { navigatorLanguage: 'fr-FR', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'en-us', localStorageLanguage: null, expected: 'en'}, { navigatorLanguage: 'en-us', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'en-au', localStorageLanguage: null, expected: 'en'}, { navigatorLanguage: 'en-au', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-de', localStorageLanguage: null, expected: 'de'}, { navigatorLanguage: 'de-de', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr-fr', localStorageLanguage: null, expected: 'fr'}, { navigatorLanguage: 'fr-fr', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: null, expected: 'zh'}, { navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'zh-Hant-TW', localStorageLanguage: null, expected: 'zh'}, { navigatorLanguage: 'zh-Hant-TW', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'uz-Latn-UZ', localStorageLanguage: null, expected: 'uz'}, { navigatorLanguage: 'uz-Latn-UZ', localStorageLanguage: null, expected: 'uz'},
{ navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: null, expected: 'en'}, { navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-DE-u-co-phonebk', localStorageLanguage: null, expected: 'de'}, { navigatorLanguage: 'de-DE-u-co-phonebk', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', localStorageLanguage: null, expected: 'zh'}, { navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'en', localStorageLanguage: 'de', expected: 'de'}, { navigatorLanguage: 'en', localStorageLanguage: 'de', expected: 'de'},
{ navigatorLanguage: 'de-DE', localStorageLanguage: 'en', expected: 'en'}, { navigatorLanguage: 'de-DE', localStorageLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'de-de', localStorageLanguage: 'en', expected: 'en'}, { navigatorLanguage: 'de-de', localStorageLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: 'fr', expected: 'fr'}, { navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: 'fr', expected: 'fr'},
{ navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: 'zh', expected: 'zh'} { navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: 'zh', expected: 'zh'}
])('returns the language $expected (navigator: $navigatorLanguage; local storage: $localStorageLanguage)', ({ navigatorLanguage, localStorageLanguage, expected }) => { ];
Object.defineProperty(navigator, 'language', {
value: navigatorLanguage,
configurable: true
});
if (localStorageLanguage) { test.for(locales)('returns the language $expected (navigator: $navigatorLanguage; local storage: $localStorageLanguage)', ({ navigatorLanguage, localStorageLanguage, expected }) => {
localStorage.setItem('locale', localStorageLanguage); Object.defineProperty(navigator, 'language', {
}; value: navigatorLanguage,
configurable: true
expect(getCurrentLanguage()).toBe(expected);
}); });
if (localStorageLanguage) {
localStorage.setItem('locale', localStorageLanguage);
};
expect(getCurrentLanguage()).toBe(expected);
}); });