Add the language setting page (#1210)
* 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).
This commit is contained in:
parent
a420d5b203
commit
467b0fa988
1
assets/images/utils/languages.svg
Normal file
1
assets/images/utils/languages.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-languages"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
1
assets/images/utils/radio-button-checked.svg
Normal file
1
assets/images/utils/radio-button-checked.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8s8 3.58 8 8s-3.58 8-8 8z"/><circle cx="12" cy="12" r="5" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 298 B |
1
assets/images/utils/radio-button.svg
Normal file
1
assets/images/utils/radio-button.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8s8 3.58 8 8s-3.58 8-8 8z"/></svg>
|
||||
|
After Width: | Height: | Size: 247 B |
@ -14,3 +14,22 @@ body {
|
||||
.text-container p {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
// From the Bootstrap project
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2011-2023 The Bootstrap Authors
|
||||
// https://github.com/twbs/bootstrap/blob/2f617215755b066904248525a8c56ea425dde871/scss/mixins/_visually-hidden.scss#L8
|
||||
.visually-hidden {
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
|
||||
&:not(caption) {
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
|
||||
13
composables/auto-ref.ts
Normal file
13
composables/auto-ref.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type AutoRef<T> = [T] extends [(...args: any[]) => any]
|
||||
? Ref<T> | (() => T)
|
||||
: T | Ref<T> | (() => T)
|
||||
|
||||
/**
|
||||
* Accepts a value directly, a ref with the value or a getter function and returns a Vue ref.
|
||||
* @param value The value to use.
|
||||
* @returns Either the original or newly created ref.
|
||||
*/
|
||||
export function useAutoRef<T>(value: AutoRef<T>): Ref<T> {
|
||||
if (typeof value === 'function') return computed(() => value())
|
||||
return isRef(value) ? value : ref(value as any)
|
||||
}
|
||||
91
composables/display-names.ts
Normal file
91
composables/display-names.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { useAutoRef, type AutoRef } from './auto-ref.ts'
|
||||
|
||||
const safeTags = new Map<string, string>()
|
||||
|
||||
function safeTagFor(locale: string) {
|
||||
let safeTag = safeTags.get(locale)
|
||||
if (safeTag == null) {
|
||||
safeTag = new Intl.Locale(locale).baseName
|
||||
safeTags.set(locale, safeTag)
|
||||
}
|
||||
return safeTag
|
||||
}
|
||||
|
||||
type DisplayNamesWrapper = Intl.DisplayNames & {
|
||||
of(tag: string): string | undefined
|
||||
}
|
||||
|
||||
const displayNamesDicts = new Map<string, DisplayNamesWrapper>()
|
||||
|
||||
function getWrapperKey(locale: string, options: Intl.DisplayNamesOptions) {
|
||||
return JSON.stringify({ ...options, locale })
|
||||
}
|
||||
|
||||
export function createDisplayNames(
|
||||
locale: string,
|
||||
options: Intl.DisplayNamesOptions = { type: 'language' }
|
||||
) {
|
||||
const wrapperKey = getWrapperKey(locale, options)
|
||||
let wrapper = displayNamesDicts.get(wrapperKey)
|
||||
|
||||
if (wrapper == null) {
|
||||
const dict = new Intl.DisplayNames(locale, options)
|
||||
|
||||
const badTags: string[] = []
|
||||
|
||||
wrapper = {
|
||||
resolvedOptions() {
|
||||
return dict.resolvedOptions()
|
||||
},
|
||||
of(tag: string) {
|
||||
let attempt = 0
|
||||
|
||||
// eslint-disable-next-line no-labels
|
||||
lookupLoop: do {
|
||||
let lookup: string
|
||||
switch (attempt) {
|
||||
case 0:
|
||||
lookup = tag
|
||||
break
|
||||
case 1:
|
||||
lookup = safeTagFor(tag)
|
||||
break
|
||||
default:
|
||||
// eslint-disable-next-line no-labels
|
||||
break lookupLoop
|
||||
}
|
||||
|
||||
if (badTags.includes(lookup)) continue
|
||||
|
||||
try {
|
||||
return dict.of(lookup)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to get display name for ${lookup} using dictionary for ${
|
||||
this.resolvedOptions().locale
|
||||
}`
|
||||
)
|
||||
badTags.push(lookup)
|
||||
continue
|
||||
}
|
||||
} while (++attempt < 5)
|
||||
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
displayNamesDicts.set(wrapperKey, wrapper)
|
||||
}
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
export function useDisplayNames(
|
||||
locale: AutoRef<string>,
|
||||
options?: AutoRef<Intl.DisplayNamesOptions | undefined>
|
||||
) {
|
||||
const $locale = useAutoRef(locale)
|
||||
const $options = useAutoRef(options)
|
||||
|
||||
return computed(() => createDisplayNames($locale.value, $options.value))
|
||||
}
|
||||
10
helpers/events.ts
Normal file
10
helpers/events.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Checks if any of the modifier keys is down for the event.
|
||||
* @param e Event that is triggered with the state of modified keys.
|
||||
* @returns Whether any of the modifier keys is pressed.
|
||||
*/
|
||||
export function isModifierKeyDown(
|
||||
e: Pick<KeyboardEvent, 'ctrlKey' | 'altKey' | 'metaKey' | 'shiftKey'>
|
||||
) {
|
||||
return e.ctrlKey || e.altKey || e.metaKey || e.shiftKey
|
||||
}
|
||||
@ -13,5 +13,53 @@
|
||||
},
|
||||
"frog.title": {
|
||||
"message": "Frog"
|
||||
},
|
||||
"settings.language.categories.auto": {
|
||||
"message": "Automatic"
|
||||
},
|
||||
"settings.language.categories.default": {
|
||||
"message": "Standard languages"
|
||||
},
|
||||
"settings.language.categories.experimental": {
|
||||
"message": "Experimental languages"
|
||||
},
|
||||
"settings.language.categories.fun": {
|
||||
"message": "Fun languages"
|
||||
},
|
||||
"settings.language.categories.search-result": {
|
||||
"message": "Search results"
|
||||
},
|
||||
"settings.language.description": {
|
||||
"message": "Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>."
|
||||
},
|
||||
"settings.language.languages.automatic": {
|
||||
"message": "Sync with the system language"
|
||||
},
|
||||
"settings.language.languages.language-label": {
|
||||
"message": "{translatedName}. {displayName}"
|
||||
},
|
||||
"settings.language.languages.language-label-applying": {
|
||||
"message": "{label}. Applying..."
|
||||
},
|
||||
"settings.language.languages.language-label-error": {
|
||||
"message": "{label}. Error"
|
||||
},
|
||||
"settings.language.languages.load-failed": {
|
||||
"message": "Cannot load this language. Try again in a bit."
|
||||
},
|
||||
"settings.language.languages.search-field.description": {
|
||||
"message": "Submit to focus the first search result"
|
||||
},
|
||||
"settings.language.languages.search-field.placeholder": {
|
||||
"message": "Search for a language..."
|
||||
},
|
||||
"settings.language.languages.search-results-announcement": {
|
||||
"message": "{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search."
|
||||
},
|
||||
"settings.language.languages.search.no-results": {
|
||||
"message": "No languages match your search."
|
||||
},
|
||||
"settings.language.title": {
|
||||
"message": "Language"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"en-US": "American English"
|
||||
"en-US": "English (United States)"
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
{
|
||||
"displayName": {
|
||||
"description": "The name of the language in dialect form (e.g. Français canadien for French spoken in Canada, not French (Canada))",
|
||||
"message": "American English"
|
||||
"description": "Please enter the name of the language in its specific variant or regional form (e.g., English (US) for American English, not just English). If the language does not have any specific variant, simply enter the name of the language (e.g., Français, Deutsch).",
|
||||
"message": "English (United States)"
|
||||
},
|
||||
"searchTerms": {
|
||||
"description": "Please provide additional search terms associated with the language, if needed, to enhance the search functionality (e.g., American English, Deutschland). Each search term should be entered on a separate line. Translate as a hyphen (-) if no additional terms are needed.",
|
||||
"message": "USA\nAmerican English"
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,18 @@ const meta = {
|
||||
* Preferably only the locales that reach a certain threshold of complete
|
||||
* translations would be included in this array.
|
||||
*/
|
||||
const ENABLED_LOCALES: string[] = []
|
||||
const enabledLocales: string[] = []
|
||||
|
||||
/**
|
||||
* Overrides for the categories of the certain locales.
|
||||
*/
|
||||
const localesCategoriesOverrides: Partial<Record<string, 'fun' | 'experimental'>> = {
|
||||
'en-x-pirate': 'fun',
|
||||
'en-x-updown': 'fun',
|
||||
'en-x-lolcat': 'fun',
|
||||
'en-x-uwu': 'fun',
|
||||
'ru-x-bandit': 'fun',
|
||||
}
|
||||
|
||||
export default defineNuxtConfig({
|
||||
app: {
|
||||
@ -214,7 +225,7 @@ export default defineNuxtConfig({
|
||||
|
||||
for await (const localeDir of globIterate('locales/*/', { posix: true })) {
|
||||
const tag = basename(localeDir)
|
||||
if (!ENABLED_LOCALES.includes(tag) && opts.defaultLocale !== tag) continue
|
||||
if (!enabledLocales.includes(tag) && opts.defaultLocale !== tag) continue
|
||||
|
||||
const locale =
|
||||
opts.locales.find((locale) => locale.tag === tag) ??
|
||||
@ -246,6 +257,11 @@ export default defineNuxtConfig({
|
||||
}
|
||||
}
|
||||
|
||||
const categoryOverride = localesCategoriesOverrides[tag]
|
||||
if (categoryOverride != null) {
|
||||
;(locale.meta ??= {}).category = categoryOverride
|
||||
}
|
||||
|
||||
const cnDataImport = resolveCompactNumberDataImport(tag)
|
||||
if (cnDataImport != null) {
|
||||
;(locale.additionalImports ??= []).push({
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"@typescript-eslint/parser": "^5.59.8",
|
||||
"@vintl/compact-number": "^2.0.4",
|
||||
"@vintl/how-ago": "^2.0.1",
|
||||
"@vintl/nuxt": "^1.2.3",
|
||||
"@vintl/nuxt": "^1.3.0",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.5",
|
||||
@ -41,6 +41,7 @@
|
||||
"@ltd/j-toml": "^1.38.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"floating-vue": "^2.0.0-beta.20",
|
||||
"fuse.js": "^6.6.2",
|
||||
"highlight.js": "^11.7.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
@ -22,6 +22,9 @@
|
||||
<CurrencyIcon />
|
||||
</NavStackItem>
|
||||
</template>
|
||||
<NavStackItem link="/settings/language" label="Language">
|
||||
<LanguagesIcon />
|
||||
</NavStackItem>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
@ -39,6 +42,7 @@ import UserIcon from '~/assets/images/utils/user.svg'
|
||||
import CurrencyIcon from '~/assets/images/utils/currency.svg'
|
||||
import ShieldIcon from '~/assets/images/utils/shield.svg'
|
||||
import KeyIcon from '~/assets/images/utils/key.svg'
|
||||
import LanguagesIcon from '~/assets/images/utils/languages.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = await useAuth()
|
||||
|
||||
537
pages/settings/language.vue
Normal file
537
pages/settings/language.vue
Normal file
@ -0,0 +1,537 @@
|
||||
<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>
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@ -19,6 +19,9 @@ dependencies:
|
||||
floating-vue:
|
||||
specifier: ^2.0.0-beta.20
|
||||
version: 2.0.0-beta.20(vue@3.3.4)
|
||||
fuse.js:
|
||||
specifier: ^6.6.2
|
||||
version: 6.6.2
|
||||
highlight.js:
|
||||
specifier: ^11.7.0
|
||||
version: 11.7.0
|
||||
@ -76,8 +79,8 @@ devDependencies:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(@formatjs/intl@2.7.2)
|
||||
'@vintl/nuxt':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(typescript@5.0.4)(vite@4.3.9)(vue@3.3.4)
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(typescript@5.0.4)(vite@4.3.9)(vue@3.3.4)
|
||||
eslint:
|
||||
specifier: ^8.41.0
|
||||
version: 8.41.0
|
||||
@ -1016,6 +1019,12 @@ packages:
|
||||
tslib: 2.6.0
|
||||
dev: true
|
||||
|
||||
/@formatjs/intl-localematcher@0.4.0:
|
||||
resolution: {integrity: sha512-bRTd+rKomvfdS4QDlVJ6TA/Jx1F2h/TBVO5LjvhQ7QPPHp19oPNMIum7W2CMEReq/zPxpmCeB31F9+5gl/qtvw==}
|
||||
dependencies:
|
||||
tslib: 2.6.0
|
||||
dev: true
|
||||
|
||||
/@formatjs/intl@2.7.2(typescript@5.0.4):
|
||||
resolution: {integrity: sha512-ziiQfnXwY0/rXhtohSAmYMqDjRsihoMKdl8H2aA+FvxG9638E0XrvfBFCb+1HhimNiuqRz5fTY7F/bZtsJxsjA==}
|
||||
peerDependencies:
|
||||
@ -2102,10 +2111,11 @@ packages:
|
||||
intl-messageformat: 10.3.5
|
||||
dev: true
|
||||
|
||||
/@vintl/nuxt@1.2.3(typescript@5.0.4)(vite@4.3.9)(vue@3.3.4):
|
||||
resolution: {integrity: sha512-7grMFQqWc6S9nUU7q9K+fhRZ/SvPzG4Dll/fcCnDbIzGBgzH+Nni2b+yFuoT0sjmXN3lsksHa+5hxyJDxq9Q0A==}
|
||||
/@vintl/nuxt@1.3.0(typescript@5.0.4)(vite@4.3.9)(vue@3.3.4):
|
||||
resolution: {integrity: sha512-gH0Db4XB3RCzOaBQtoBf/Sc9bMneyVtt7RBjrLWzzNf7gN1NJNPlrfdCVAp2si/P7dt06wRegGHaSBuQ3oQQZg==}
|
||||
dependencies:
|
||||
'@formatjs/intl': 2.7.2(typescript@5.0.4)
|
||||
'@formatjs/intl-localematcher': 0.4.0
|
||||
'@nuxt/kit': 3.6.1
|
||||
'@vintl/unplugin': 1.2.4(vite@4.3.9)
|
||||
'@vintl/vintl': 4.2.1(typescript@5.0.4)(vue@3.3.4)
|
||||
@ -4498,6 +4508,11 @@ packages:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
dev: true
|
||||
|
||||
/fuse.js@6.6.2:
|
||||
resolution: {integrity: sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==}
|
||||
engines: {node: '>=10'}
|
||||
dev: false
|
||||
|
||||
/gauge@3.0.2:
|
||||
resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
4
types/fusejs.d.ts
vendored
Normal file
4
types/fusejs.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module 'fuse.js/dist/fuse.basic' {
|
||||
import Fuse from 'fuse.js'
|
||||
export default Fuse
|
||||
}
|
||||
10
types/vintl.d.ts
vendored
10
types/vintl.d.ts
vendored
@ -6,5 +6,15 @@ declare global {
|
||||
interface MessageValueTypes {
|
||||
compactNumber: CompactNumber
|
||||
}
|
||||
|
||||
interface LocaleResources {
|
||||
'languages.json'?: Partial<Record<string, string>>
|
||||
}
|
||||
|
||||
interface LocaleMeta {
|
||||
displayName?: string
|
||||
category?: string
|
||||
searchTerms?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user