* Add initial language picker prototype * Heap o' improvements and Pirate tongue * Move .visually-hidden to shared utils and add copyright notice * Add a little space before categories names * Simplify search to input focus logic * Remove larger font size and padding from the search field * Some refactors * Braw's descent into madness Thanks web development! In seriousness though, tried to make the list more accessible. Making it fully accessible feels like unbearable task, so at least that. * Litol refactoring * Extract new strings and remove old ones * Update @vintl/nuxt to 1.3.0 This fixes the bug where default locale won't be saved. * A buncha refactorings and cleanup * Scuttle the Pirate lingo 'Twas employed 'ere for testin' purposes, but fear not, for it shall be returnin' in the days to come. Should ye require it fer testin', simply roll back this here commit. * Clean languages source file * Change "US" to "United States" I think it would make distinguishing two languages simpler as now there's more than one letter of difference (US/UK vs United States/ United Kingdom).
538 lines
13 KiB
Vue
538 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import Fuse from 'fuse.js/dist/fuse.basic'
|
|
import RadioButtonIcon from '~/assets/images/utils/radio-button.svg'
|
|
import RadioButtonCheckedIcon from '~/assets/images/utils/radio-button-checked.svg'
|
|
import WarningIcon from '~/assets/images/utils/issues.svg'
|
|
import { isModifierKeyDown } from '~/helpers/events.ts'
|
|
|
|
const vintl = useVIntl()
|
|
const { formatMessage } = vintl
|
|
|
|
const messages = defineMessages({
|
|
languagesTitle: {
|
|
id: 'settings.language.title',
|
|
defaultMessage: 'Language',
|
|
},
|
|
languagesDescription: {
|
|
id: 'settings.language.description',
|
|
defaultMessage:
|
|
'Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.',
|
|
},
|
|
automaticLocale: {
|
|
id: 'settings.language.languages.automatic',
|
|
defaultMessage: 'Sync with the system language',
|
|
},
|
|
noResults: {
|
|
id: 'settings.language.languages.search.no-results',
|
|
defaultMessage: 'No languages match your search.',
|
|
},
|
|
searchFieldDescription: {
|
|
id: 'settings.language.languages.search-field.description',
|
|
defaultMessage: 'Submit to focus the first search result',
|
|
},
|
|
searchFieldPlaceholder: {
|
|
id: 'settings.language.languages.search-field.placeholder',
|
|
defaultMessage: 'Search for a language...',
|
|
},
|
|
searchResultsAnnouncement: {
|
|
id: 'settings.language.languages.search-results-announcement',
|
|
defaultMessage:
|
|
'{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.',
|
|
},
|
|
loadFailed: {
|
|
id: 'settings.language.languages.load-failed',
|
|
defaultMessage: 'Cannot load this language. Try again in a bit.',
|
|
},
|
|
languageLabel: {
|
|
id: 'settings.language.languages.language-label',
|
|
defaultMessage: '{translatedName}. {displayName}',
|
|
},
|
|
languageLabelApplying: {
|
|
id: 'settings.language.languages.language-label-applying',
|
|
defaultMessage: '{label}. Applying...',
|
|
},
|
|
languageLabelError: {
|
|
id: 'settings.language.languages.language-label-error',
|
|
defaultMessage: '{label}. Error',
|
|
},
|
|
})
|
|
|
|
const categoryNames = defineMessages({
|
|
auto: {
|
|
id: 'settings.language.categories.auto',
|
|
defaultMessage: 'Automatic',
|
|
},
|
|
default: {
|
|
id: 'settings.language.categories.default',
|
|
defaultMessage: 'Standard languages',
|
|
},
|
|
fun: {
|
|
id: 'settings.language.categories.fun',
|
|
defaultMessage: 'Fun languages',
|
|
},
|
|
experimental: {
|
|
id: 'settings.language.categories.experimental',
|
|
defaultMessage: 'Experimental languages',
|
|
},
|
|
searchResult: {
|
|
id: 'settings.language.categories.search-result',
|
|
defaultMessage: 'Search results',
|
|
},
|
|
})
|
|
|
|
type Category = keyof typeof categoryNames
|
|
|
|
const categoryOrder: Category[] = ['auto', 'default', 'fun', 'experimental']
|
|
|
|
function normalizeCategoryName(name?: string): keyof typeof categoryNames {
|
|
switch (name) {
|
|
case 'auto':
|
|
case 'fun':
|
|
case 'experimental':
|
|
return name
|
|
default:
|
|
return 'default'
|
|
}
|
|
}
|
|
|
|
type LocaleBase = {
|
|
category: Category
|
|
tag: string
|
|
searchTerms?: string[]
|
|
}
|
|
|
|
type AutomaticLocale = LocaleBase & {
|
|
auto: true
|
|
}
|
|
|
|
type CommonLocale = LocaleBase & {
|
|
auto?: never
|
|
displayName: string
|
|
defaultName: string
|
|
translatedName: string
|
|
}
|
|
|
|
type Locale = AutomaticLocale | CommonLocale
|
|
|
|
const $defaultNames = useDisplayNames(() => vintl.defaultLocale)
|
|
|
|
const $translatedNames = useDisplayNames(() => vintl.locale)
|
|
|
|
const $locales = computed(() => {
|
|
const locales: Locale[] = []
|
|
|
|
locales.push({
|
|
auto: true,
|
|
tag: 'auto',
|
|
category: 'auto',
|
|
searchTerms: [
|
|
'automatic',
|
|
'Sync with the system language',
|
|
formatMessage(messages.automaticLocale),
|
|
],
|
|
})
|
|
|
|
for (const locale of vintl.availableLocales) {
|
|
let displayName = locale.meta?.displayName
|
|
|
|
if (displayName == null) {
|
|
displayName = createDisplayNames(locale.tag).of(locale.tag) ?? locale.tag
|
|
}
|
|
|
|
let defaultName = vintl.defaultResources['languages.json']?.[locale.tag]
|
|
|
|
if (defaultName == null) {
|
|
defaultName = $defaultNames.value.of(locale.tag) ?? locale.tag
|
|
}
|
|
|
|
let translatedName = vintl.resources['languages.json']?.[locale.tag]
|
|
|
|
if (translatedName == null) {
|
|
translatedName = $translatedNames.value.of(locale.tag) ?? locale.tag
|
|
}
|
|
|
|
let searchTerms = locale.meta?.searchTerms
|
|
if (searchTerms === '-') searchTerms = undefined
|
|
|
|
locales.push({
|
|
tag: locale.tag,
|
|
category: normalizeCategoryName(locale.meta?.category),
|
|
displayName,
|
|
defaultName,
|
|
translatedName,
|
|
searchTerms: searchTerms?.split('\n'),
|
|
})
|
|
}
|
|
|
|
return locales
|
|
})
|
|
|
|
const $query = ref('')
|
|
|
|
const isQueryEmpty = () => $query.value.trim().length === 0
|
|
|
|
const fuse = new Fuse<Locale>([], {
|
|
keys: ['tag', 'displayName', 'translatedName', 'englishName', 'searchTerms'],
|
|
threshold: 0.4,
|
|
distance: 100,
|
|
})
|
|
|
|
watchSyncEffect(() => fuse.setCollection($locales.value))
|
|
|
|
const $categories = computed(() => {
|
|
const categories = new Map<Category, Locale[]>()
|
|
|
|
for (const category of categoryOrder) categories.set(category, [])
|
|
|
|
for (const locale of $locales.value) {
|
|
let categoryLocales = categories.get(locale.category)
|
|
|
|
if (categoryLocales == null) {
|
|
categoryLocales = []
|
|
categories.set(locale.category, categoryLocales)
|
|
}
|
|
|
|
categoryLocales.push(locale)
|
|
}
|
|
|
|
for (const categoryKey of [...categories.keys()]) {
|
|
if (categories.get(categoryKey)?.length === 0 ?? false) {
|
|
categories.delete(categoryKey)
|
|
}
|
|
}
|
|
|
|
return categories
|
|
})
|
|
|
|
const $searchResults = computed(() => {
|
|
return new Map<Category, Locale[]>([
|
|
['searchResult', isQueryEmpty() ? [] : fuse.search($query.value).map(({ item }) => item)],
|
|
])
|
|
})
|
|
|
|
const $displayCategories = computed(() =>
|
|
isQueryEmpty() ? $categories.value : $searchResults.value
|
|
)
|
|
|
|
const $changingTo = ref<string | undefined>()
|
|
|
|
const isChanging = () => $changingTo.value != null
|
|
|
|
const $failedLocale = ref<string>()
|
|
|
|
const $activeLocale = computed(() => {
|
|
if ($changingTo.value != null) return $changingTo.value
|
|
return vintl.automatic ? 'auto' : vintl.locale
|
|
})
|
|
|
|
async function changeLocale(value: string) {
|
|
if ($activeLocale.value === value) return
|
|
|
|
$changingTo.value = value
|
|
|
|
try {
|
|
await vintl.changeLocale(value)
|
|
$failedLocale.value = undefined
|
|
} catch (err) {
|
|
$failedLocale.value = value
|
|
} finally {
|
|
$changingTo.value = undefined
|
|
}
|
|
}
|
|
|
|
const $languagesList = ref<HTMLDivElement | undefined>()
|
|
|
|
function onSearchKeydown(e: KeyboardEvent) {
|
|
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
|
|
|
|
const focusableTarget = $languagesList.value?.querySelector(
|
|
'input, [tabindex]:not([tabindex="-1"])'
|
|
) as HTMLElement | undefined
|
|
|
|
focusableTarget?.focus()
|
|
}
|
|
|
|
function onItemKeydown(e: KeyboardEvent, locale: Locale) {
|
|
switch (e.key) {
|
|
case 'Enter':
|
|
case ' ':
|
|
break
|
|
default:
|
|
return
|
|
}
|
|
|
|
if (isModifierKeyDown(e) || isChanging()) return
|
|
|
|
changeLocale(locale.tag)
|
|
}
|
|
|
|
function onItemClick(e: MouseEvent, locale: Locale) {
|
|
if (isModifierKeyDown(e) || isChanging()) return
|
|
|
|
changeLocale(locale.tag)
|
|
}
|
|
|
|
function getItemLabel(locale: Locale) {
|
|
const label = locale.auto
|
|
? formatMessage(messages.automaticLocale)
|
|
: formatMessage(messages.languageLabel, {
|
|
translatedName: locale.translatedName,
|
|
displayName: locale.displayName,
|
|
})
|
|
|
|
if ($changingTo.value === locale.tag) {
|
|
return formatMessage(messages.languageLabelApplying, { label })
|
|
}
|
|
|
|
if ($failedLocale.value === locale.tag) {
|
|
return formatMessage(messages.languageLabelError, { label })
|
|
}
|
|
|
|
return label
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<section class="universal-card">
|
|
<h2>{{ formatMessage(messages.languagesTitle) }}</h2>
|
|
|
|
<div class="card-description">
|
|
<IntlFormatted :message-id="messages.languagesDescription">
|
|
<template #crowdin-link="{ children }">
|
|
<a href="https://crowdin.com/project/modrinth">
|
|
<component :is="() => children" />
|
|
</a>
|
|
</template>
|
|
</IntlFormatted>
|
|
</div>
|
|
|
|
<div class="search-container">
|
|
<input
|
|
id="language-search"
|
|
v-model="$query"
|
|
name="language"
|
|
type="search"
|
|
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
|
|
class="language-search"
|
|
aria-describedby="language-search-description"
|
|
:disabled="isChanging()"
|
|
@keydown="onSearchKeydown"
|
|
/>
|
|
|
|
<div id="language-search-description" class="visually-hidden">
|
|
{{ formatMessage(messages.searchFieldDescription) }}
|
|
</div>
|
|
|
|
<div id="language-search-results-announcements" class="visually-hidden" aria-live="polite">
|
|
{{
|
|
isQueryEmpty()
|
|
? ''
|
|
: formatMessage(messages.searchResultsAnnouncement, {
|
|
matches: $searchResults.get('searchResult')?.length ?? 0,
|
|
})
|
|
}}
|
|
</div>
|
|
</div>
|
|
|
|
<div ref="$languagesList" class="languages-list">
|
|
<template v-for="[category, locales] in $displayCategories" :key="category">
|
|
<strong class="category-name">
|
|
{{ formatMessage(categoryNames[category]) }}
|
|
</strong>
|
|
|
|
<div
|
|
v-if="category === 'searchResult' && locales.length === 0"
|
|
class="no-results"
|
|
tabindex="0"
|
|
>
|
|
{{ formatMessage(messages.noResults) }}
|
|
</div>
|
|
|
|
<template v-for="locale in locales" :key="locale.tag">
|
|
<div
|
|
role="button"
|
|
:aria-pressed="$activeLocale === locale.tag"
|
|
:class="{
|
|
'language-item': true,
|
|
pending: $changingTo == locale.tag,
|
|
errored: $failedLocale == locale.tag,
|
|
}"
|
|
:aria-describedby="
|
|
$failedLocale == locale.tag ? `language__${locale.tag}__fail` : undefined
|
|
"
|
|
:aria-disabled="isChanging() && $changingTo !== locale.tag"
|
|
:tabindex="0"
|
|
:aria-label="getItemLabel(locale)"
|
|
@click="(e) => onItemClick(e, locale)"
|
|
@keydown="(e) => onItemKeydown(e, locale)"
|
|
>
|
|
<RadioButtonCheckedIcon v-if="$activeLocale === locale.tag" class="radio" />
|
|
<RadioButtonIcon v-else class="radio" />
|
|
|
|
<div class="language-names">
|
|
<div class="language-name">
|
|
{{ locale.auto ? formatMessage(messages.automaticLocale) : locale.displayName }}
|
|
</div>
|
|
|
|
<div v-if="!locale.auto" class="language-translated-name">
|
|
{{ locale.translatedName }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="$failedLocale === locale.tag"
|
|
:id="`language__${locale.tag}__fail`"
|
|
class="language-load-error"
|
|
>
|
|
<WarningIcon /> {{ formatMessage(messages.loadFailed) }}
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.languages-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
}
|
|
|
|
.language-item {
|
|
display: flex;
|
|
align-items: center;
|
|
column-gap: 0.5rem;
|
|
border: 0.15rem solid transparent;
|
|
border-radius: var(--spacing-card-md);
|
|
background: var(--color-button-bg);
|
|
padding: var(--spacing-card-md);
|
|
cursor: pointer;
|
|
position: relative;
|
|
overflow: hidden;
|
|
|
|
&:not([aria-disabled='true']):hover {
|
|
border-color: var(--color-button-bg-hover);
|
|
}
|
|
|
|
&:focus-visible,
|
|
&:has(:focus-visible) {
|
|
outline: 2px solid var(--color-brand);
|
|
}
|
|
|
|
&.errored {
|
|
border-color: var(--color-special-red);
|
|
|
|
&:hover {
|
|
border-color: var(--color-special-red);
|
|
}
|
|
}
|
|
|
|
&.pending::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
background-image: linear-gradient(
|
|
102deg,
|
|
rgba(0, 0, 0, 0) 0%,
|
|
rgba(0, 0, 0, 0) 20%,
|
|
rgba(0, 0, 0, 0.1) 45%,
|
|
rgba(0, 0, 0, 0.1) 50%,
|
|
rgba(0, 0, 0, 0) 80%,
|
|
rgba(0, 0, 0, 0) 100%
|
|
);
|
|
|
|
background-repeat: no-repeat;
|
|
animation: shimmerSliding 2.5s ease-out infinite;
|
|
|
|
.dark-mode &,
|
|
.oled-mode & {
|
|
background-image: linear-gradient(
|
|
102deg,
|
|
rgba(255, 255, 255, 0) 0%,
|
|
rgba(255, 255, 255, 0) 20%,
|
|
rgba(255, 255, 255, 0.1) 45%,
|
|
rgba(255, 255, 255, 0.1) 50%,
|
|
rgba(255, 255, 255, 0) 80%,
|
|
rgba(255, 255, 255, 0) 100%
|
|
);
|
|
}
|
|
|
|
@keyframes shimmerSliding {
|
|
from {
|
|
left: -100%;
|
|
}
|
|
to {
|
|
left: 100%;
|
|
}
|
|
}
|
|
}
|
|
|
|
&[aria-disabled='true']:not(.pending) {
|
|
opacity: 0.8;
|
|
pointer-events: none;
|
|
cursor: default;
|
|
}
|
|
}
|
|
|
|
.language-load-error {
|
|
color: var(--color-special-red);
|
|
font-size: var(--font-size-sm);
|
|
margin-left: 0.3rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.radio {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
|
|
.language-names {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
flex: 1;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.language-name {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.language-search {
|
|
width: 100%;
|
|
}
|
|
|
|
.search-container {
|
|
margin-bottom: var(--spacing-card-md);
|
|
}
|
|
|
|
.card-description {
|
|
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
|
|
|
|
a {
|
|
color: var(--color-link);
|
|
|
|
&:hover {
|
|
color: var(--color-link-hover);
|
|
}
|
|
|
|
&:active {
|
|
color: var(--color-link-active);
|
|
}
|
|
}
|
|
}
|
|
|
|
.category-name {
|
|
margin-top: var(--spacing-card-md);
|
|
}
|
|
</style>
|