feat: preferences syncing frontend (#7192)

* feat: prefs frontend

* fix: DI issue

* fix: lint

* feat: appearance settings cleanup + fix settings, remove pinia

* fix: sync btn when logged out

* fix: prepr

* fix: sidebar issue

* feat: language coverage + cleanup

* feat: cleanup lang settings

* fix: fmt

* fix: CI

* fix: ci

* fix: storybook

* feat: bring back loader/game version sort

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-08-20 07:23:01 +00:00
committed by GitHub
co-authored by tdgao
parent 79fcf41316
commit 3e07267e10
191 changed files with 3356 additions and 4372 deletions
+1 -2
View File
@@ -1,5 +1,3 @@
export { default as AccountProfileSettings } from './AccountProfileSettings.vue'
export { default as AccountSocialSettings } from './AccountSocialSettings.vue'
export { default as ServersManageAccessPage } from './hosting/manage/[id]/access/access.vue'
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
@@ -8,3 +6,4 @@ export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue'
export { default as ServersManageRootLayout } from './hosting/manage/root.vue'
export * from './settings'
@@ -29,13 +29,14 @@
{{ formatMessage(messages.friendRequestsTitle) }}
</h2>
<Chips
v-model="friendRequestSource"
:items="friendRequestSourceOptions"
:model-value="friendPrivacy"
:items="friendPrivacyOptions"
:format-label="formatInteractionSource"
:disabled-items="friendRequestSourceOptions"
:disabled-tooltip="formatMessage(messages.comingSoon)"
:disabled-items="preferenceControlsDisabled ? friendPrivacyOptions : undefined"
:disabled-tooltip="preferenceControlsTooltip"
:capitalize="false"
:aria-label="formatMessage(messages.friendRequestsTitle)"
@update:model-value="setFriendPrivacy"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.friendRequestsDescription) }}
@@ -47,18 +48,38 @@
{{ formatMessage(messages.sharedInstanceInvitesTitle) }}
</h2>
<Chips
v-model="sharedInstanceInviteSource"
:items="sharedInstanceInviteSourceOptions"
:model-value="sharedInstancesPrivacy"
:items="invitePrivacyOptions"
:format-label="formatInteractionSource"
:disabled-items="sharedInstanceInviteSourceOptions"
:disabled-tooltip="formatMessage(messages.comingSoon)"
:disabled-items="preferenceControlsDisabled ? invitePrivacyOptions : undefined"
:disabled-tooltip="preferenceControlsTooltip"
:capitalize="false"
:aria-label="formatMessage(messages.sharedInstanceInvitesTitle)"
@update:model-value="setSharedInstancesPrivacy"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.sharedInstanceInvitesDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hostingAccessTitle) }}
</h2>
<Chips
:model-value="hostingAccessPrivacy"
:items="invitePrivacyOptions"
:format-label="formatInteractionSource"
:disabled-items="preferenceControlsDisabled ? invitePrivacyOptions : undefined"
:disabled-tooltip="preferenceControlsTooltip"
:capitalize="false"
:aria-label="formatMessage(messages.hostingAccessTitle)"
@update:model-value="setHostingAccessPrivacy"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.hostingAccessDescription) }}
</p>
</div>
</section>
<section class="flex flex-col gap-4">
@@ -187,7 +208,7 @@
import type { Labrinth } from '@modrinth/api-client'
import { LogInIcon, SpinnerIcon, ThinkingRinthbot } from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import { Button } from '#ui/components/base/buttons'
@@ -195,15 +216,20 @@ import Chips from '#ui/components/base/Chips.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import Table, { type TableColumn } from '#ui/components/base/Table.vue'
import { defineMessages, useScrollIndicator, useVIntl } from '#ui/composables'
import { injectAuth, injectNotificationManager } from '#ui/providers'
import { injectAuth, injectNotificationManager, injectUserPreferences } from '#ui/providers'
import { commonMessages } from '#ui/utils'
import { blockedUsersQueryKey } from '../shared/user-profile/providers'
import { blockedUsersQueryKey } from '../../shared/user-profile/providers'
type BlockedUserTableColumn = 'user' | 'actions'
type BlockedUser = Labrinth.Users.v2.User & Record<BlockedUserTableColumn, unknown>
type FriendRequestSource = 'everyone' | 'mutuals' | 'no-one'
type SharedInstanceInviteSource = 'everyone' | 'friends' | 'no-one'
type FriendPrivacy = Labrinth.Users.v3.FriendPrivacy
type InvitePrivacy = Labrinth.Users.v3.InvitePrivacy
type SocialSettingsState = {
friendPrivacy: FriendPrivacy
sharedInstancesPrivacy: InvitePrivacy
hostingAccessPrivacy: InvitePrivacy
}
const props = defineProps<{
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
@@ -213,33 +239,120 @@ const props = defineProps<{
const auth = injectAuth()
const notificationManager = injectNotificationManager()
const {
preferences,
isLoading: preferencesLoading,
isUpdating: preferencesUpdating,
updatePreferences,
} = injectUserPreferences()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const blockedUsersTable = ref<HTMLElement | null>(null)
const unblockingUserId = ref<string | null>(null)
const friendRequestSource = ref<FriendRequestSource>('everyone')
const sharedInstanceInviteSource = ref<SharedInstanceInviteSource>('everyone')
const friendRequestSourceOptions: FriendRequestSource[] = ['everyone', 'mutuals', 'no-one']
const sharedInstanceInviteSourceOptions: SharedInstanceInviteSource[] = [
'everyone',
'friends',
'no-one',
]
const saving = ref(false)
const preferencesInitialized = ref(false)
const friendPrivacy = ref<FriendPrivacy>('everyone')
const sharedInstancesPrivacy = ref<InvitePrivacy>('everyone')
const hostingAccessPrivacy = ref<InvitePrivacy>('everyone')
const friendPrivacyOptions: FriendPrivacy[] = ['everyone', 'mutual', 'none']
const invitePrivacyOptions: InvitePrivacy[] = ['everyone', 'friends', 'none']
const preferenceControlsDisabled = computed(
() => preferencesLoading.value || preferencesUpdating.value || saving.value || !preferences.value,
)
const preferenceControlsTooltip = computed(() => {
if (preferencesLoading.value) return formatMessage(messages.loadingPreferences)
if (saving.value || preferencesUpdating.value) return formatMessage(messages.savingPreferences)
return formatMessage(messages.preferencesUnavailable)
})
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(blockedUsersTable)
function formatInteractionSource(source: FriendRequestSource | SharedInstanceInviteSource): string {
const originalState = computed<SocialSettingsState>(() => ({
friendPrivacy: preferences.value?.social.friend_privacy ?? 'everyone',
sharedInstancesPrivacy: preferences.value?.social.shared_instances_privacy ?? 'everyone',
hostingAccessPrivacy: preferences.value?.social.hosting_access_privacy ?? 'everyone',
}))
const modifiedState = computed<Partial<SocialSettingsState>>(() => ({
...(friendPrivacy.value !== originalState.value.friendPrivacy
? { friendPrivacy: friendPrivacy.value }
: {}),
...(sharedInstancesPrivacy.value !== originalState.value.sharedInstancesPrivacy
? { sharedInstancesPrivacy: sharedInstancesPrivacy.value }
: {}),
...(hostingAccessPrivacy.value !== originalState.value.hostingAccessPrivacy
? { hostingAccessPrivacy: hostingAccessPrivacy.value }
: {}),
}))
const hasChanges = computed(() => Object.keys(modifiedState.value).length > 0)
function formatInteractionSource(source: FriendPrivacy | InvitePrivacy): string {
switch (source) {
case 'everyone':
return formatMessage(messages.everyone)
case 'mutuals':
case 'mutual':
return formatMessage(messages.friendsOfFriends)
case 'friends':
return formatMessage(messages.friends)
case 'no-one':
case 'none':
return formatMessage(messages.noOne)
}
}
watch(
preferences,
(value) => {
if (!value) return
if (preferencesInitialized.value && hasChanges.value && !saving.value) return
friendPrivacy.value = value.social.friend_privacy
sharedInstancesPrivacy.value = value.social.shared_instances_privacy
hostingAccessPrivacy.value = value.social.hosting_access_privacy
preferencesInitialized.value = true
},
{ immediate: true, flush: 'sync' },
)
function setFriendPrivacy(value: FriendPrivacy | null): void {
if (!value) return
friendPrivacy.value = value
}
function setSharedInstancesPrivacy(value: InvitePrivacy | null): void {
if (!value) return
sharedInstancesPrivacy.value = value
}
function setHostingAccessPrivacy(value: InvitePrivacy | null): void {
if (!value) return
hostingAccessPrivacy.value = value
}
function reset(): void {
friendPrivacy.value = originalState.value.friendPrivacy
sharedInstancesPrivacy.value = originalState.value.sharedInstancesPrivacy
hostingAccessPrivacy.value = originalState.value.hostingAccessPrivacy
}
async function save(): Promise<void> {
if (!hasChanges.value || saving.value) return
saving.value = true
try {
await updatePreferences({
social: {
friend_privacy: friendPrivacy.value,
shared_instances_privacy: sharedInstancesPrivacy.value,
hosting_access_privacy: hostingAccessPrivacy.value,
},
})
} catch {
return
} finally {
saving.value = false
}
}
defineExpose({ hasChanges, originalState, modifiedState, saving, reset, save })
const columns = computed<TableColumn<BlockedUserTableColumn>[]>(() => [
{
key: 'user',
@@ -342,12 +455,19 @@ const messages = defineMessages({
},
sharedInstanceInvitesTitle: {
id: 'settings.social.shared-instance-invites.title',
defaultMessage: 'Invitations',
defaultMessage: 'Shared instance invites',
},
sharedInstanceInvitesDescription: {
id: 'settings.social.shared-instance-invites.description',
defaultMessage:
'Control who can send you invites to shared instances and Modrinth Hosting panels.',
defaultMessage: 'Control who can send you invites to shared instances.',
},
hostingAccessTitle: {
id: 'settings.social.hosting-access.title',
defaultMessage: 'Hosting access invites',
},
hostingAccessDescription: {
id: 'settings.social.hosting-access.description',
defaultMessage: 'Control who can invite you to manage a Modrinth Hosting server.',
},
everyone: {
id: 'settings.social.interaction-source.everyone',
@@ -365,9 +485,17 @@ const messages = defineMessages({
id: 'settings.social.interaction-source.no-one',
defaultMessage: 'No one',
},
comingSoon: {
id: 'settings.social.interaction-source.coming-soon',
defaultMessage: 'Coming soon!',
savingPreferences: {
id: 'settings.social.interaction-source.saving',
defaultMessage: 'Saving your preferences…',
},
loadingPreferences: {
id: 'settings.social.interaction-source.loading',
defaultMessage: 'Loading your preferences…',
},
preferencesUnavailable: {
id: 'settings.social.interaction-source.unavailable',
defaultMessage: 'Your preferences are currently unavailable.',
},
blockedUsersTitle: {
id: 'settings.social.blocked-users.title',
@@ -0,0 +1,3 @@
export { default as AccountProfileSettings } from './AccountProfileSettings.vue'
export { default as AccountSocialSettings } from './AccountSocialSettings.vue'
export { default as LanguageSettings } from './language-settings/index.vue'
@@ -0,0 +1,121 @@
<template>
<div>
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(commonSettingsMessages.language) }}
</h2>
<Admonition type="warning" class="mb-4 mt-2">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
</Admonition>
<p class="m-0 mb-4 text-secondary">
<IntlFormatted
:message-id="languageSelectorMessages.languagesDescription"
:values="{ platform }"
>
<template #~crowdin-link="{ children }">
<AutoLink to="https://translate.modrinth.com" class="text-link">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<LanguageSettingsSelector
:product="product"
:current-locale="current.locale"
:locales="LOCALES"
:on-locale-change="onLocaleChange"
:is-changing="saving"
:coverage-by-locale="languageCoverage[product]"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Admonition, AutoLink, IntlFormatted } from '#ui/components/base'
import { LOCALES, useVIntl } from '#ui/composables'
import { injectI18n, injectUserPreferences } from '#ui/providers'
import { commonSettingsMessages, languageSelectorMessages, useSavable } from '#ui/utils'
import { languageCoverage } from './language-settings-coverage.generated'
import LanguageSettingsSelector from './language-settings-selector.vue'
const props = defineProps<{
product: 'app' | 'website'
persistLocale?: (locale: string) => void | Promise<void>
}>()
const { formatMessage } = useVIntl()
const { locale, setLocale } = injectI18n()
const { preferences, updatePreferences } = injectUserPreferences()
const platform = computed(() =>
formatMessage(
props.product === 'app'
? languageSelectorMessages.platformApp
: languageSelectorMessages.platformSite,
),
)
const persistedLocale = ref(locale.value)
let localeChangeQueue = Promise.resolve()
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => ({ locale: persistedLocale.value }),
async () => {
await updatePreferences({ localization: { locale: current.value.locale } })
await queueLocaleChange(current.value.locale, true)
await props.persistLocale?.(current.value.locale)
persistedLocale.value = current.value.locale
},
)
function queueLocaleChange(newLocale: string, persist = false): Promise<void> {
const request = localeChangeQueue.then(() => Promise.resolve(setLocale(newLocale, { persist })))
localeChangeQueue = request.catch(() => undefined)
return request
}
function onLocaleChange(newLocale: string): void {
current.value.locale = newLocale
void queueLocaleChange(newLocale).catch(() => undefined)
}
function resetLanguageSettings(): void {
reset()
void queueLocaleChange(current.value.locale).catch(() => undefined)
}
async function saveLanguageSettings(): Promise<void> {
try {
await save()
} catch {
return
}
}
watch(
preferences,
(value) => {
if (!value || hasChanges.value) return
persistedLocale.value = value.localization.locale
},
{ immediate: true, flush: 'sync' },
)
onBeforeUnmount(() => {
if (hasChanges.value || locale.value !== persistedLocale.value) {
void queueLocaleChange(persistedLocale.value).catch(() => undefined)
}
})
defineExpose({
originalState: saved,
modifiedState: changes,
hasChanges,
saving,
reset: resetLanguageSettings,
save: saveLanguageSettings,
})
</script>
@@ -0,0 +1,3 @@
import type { LanguageCoverageByProduct } from './language-settings-coverage'
export declare const languageCoverage: LanguageCoverageByProduct
@@ -0,0 +1,15 @@
export type LanguageProduct = 'app' | 'website'
export interface LanguageCoverageStats {
percentage: number
interfaceCoverage: number
translationCoverage: number
translatedMessages: number
totalMessages: number
unlocalizedStrings: number
}
export type LanguageCoverageByProduct = Record<
LanguageProduct,
Record<string, LanguageCoverageStats>
>
@@ -0,0 +1,335 @@
<script setup lang="ts">
import { SearchIcon } from '@modrinth/assets'
import Fuse from 'fuse.js/dist/fuse.basic'
import { computed, onMounted, ref } from 'vue'
import Button from '#ui/components/base/buttons/Button.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import {
buildLocaleMessages,
defineMessages,
type LocaleDefinition,
useVIntl,
} from '#ui/composables/i18n'
import { metaLocaleModules } from '#ui/locales.ts'
import { isModifierKeyDown } from '#ui/utils/events'
import type { LanguageCoverageStats } from './language-settings-coverage'
const { formatMessage } = useVIntl()
const props = defineProps<{
product: 'app' | 'website'
currentLocale: string
locales: LocaleDefinition[]
onLocaleChange: (locale: string) => void | Promise<void>
isChanging?: boolean
coverageByLocale?: Record<string, LanguageCoverageStats>
}>()
const messages = defineMessages({
noResults: {
id: 'settings.language.languages.search.no-results',
defaultMessage: 'No languages match your search.',
},
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.',
},
standardLanguages: {
id: 'settings.language.categories.default',
defaultMessage: 'Standard languages',
},
searchResults: {
id: 'settings.language.categories.search-result',
defaultMessage: 'Search results',
},
coverageLabel: {
id: 'settings.language.coverage.label',
defaultMessage: '{percentage}% supported',
},
appCoverageTooltip: {
id: 'settings.language.coverage.app-tooltip',
defaultMessage: 'About {percentage}% of the Modrinth App is available in this language.',
},
websiteCoverageTooltip: {
id: 'settings.language.coverage.website-tooltip',
defaultMessage: 'About {percentage}% of the website is available in this language.',
},
})
const localeMetas = buildLocaleMessages(metaLocaleModules)
type Category = 'default' | 'searchResult'
type LocaleInfo = {
category: Category
tag: string
displayName: string
browserDisplayName: string
flagUrl?: string
searchTerms?: string[]
coverage?: LanguageCoverageStats
}
const localeFlagRegions: Record<string, string> = {
'es-419': 'mx',
'sr-CS': 'rs',
}
const $browserLocales = ref([props.currentLocale])
onMounted(() => {
$browserLocales.value = navigator.languages.length
? [...navigator.languages]
: [navigator.language]
})
const $browserDisplayNames = computed(() => {
try {
return new Intl.DisplayNames($browserLocales.value, { type: 'language' })
} catch {
return undefined
}
})
function getBrowserDisplayName(tag: string, fallback: string): string {
try {
return $browserDisplayNames.value?.of(tag) ?? fallback
} catch {
try {
return $browserDisplayNames.value?.of(tag.split('-')[0]) ?? fallback
} catch {
return fallback
}
}
}
function getFlagUrl(tag: string): string | undefined {
const region = localeFlagRegions[tag] ?? tag.split('-').at(-1)
if (!region || !/^[a-z]{2}$/i.test(region)) return undefined
return `https://flagcdn.com/${region.toLowerCase()}.svg`
}
const $locales = computed(() => {
const result: LocaleInfo[] = []
for (const loc of props.locales) {
const tag = loc.code
const meta = localeMetas[tag] ?? null
const displayName = meta?.displayName ?? loc.name
const translatedName = formatMessage(loc.translatedName)
const browserDisplayName = getBrowserDisplayName(tag, translatedName)
const searchTerms = meta?.searchTerms === '-' ? undefined : meta?.searchTerms?.split('\n')
result.push({
tag,
category: 'default',
displayName,
browserDisplayName,
flagUrl: getFlagUrl(tag),
searchTerms,
coverage: props.coverageByLocale?.[tag],
})
}
return result.sort((a, b) => (b.coverage?.percentage ?? -1) - (a.coverage?.percentage ?? -1))
})
const $query = ref('')
const isQueryEmpty = () => $query.value.trim().length === 0
const fuse = computed(
() =>
new Fuse<LocaleInfo>($locales.value, {
keys: ['tag', 'displayName', 'browserDisplayName', 'searchTerms'],
threshold: 0.4,
distance: 100,
}),
)
const $categories = computed(() => {
const categories = new Map<Category, LocaleInfo[]>()
categories.set('default', $locales.value)
return categories
})
const $searchResults = computed(() => {
return new Map<Category, LocaleInfo[]>([
['searchResult', isQueryEmpty() ? [] : fuse.value.search($query.value).map(({ item }) => item)],
])
})
const $displayCategories = computed(() =>
isQueryEmpty() ? $categories.value : $searchResults.value,
)
const $changingTo = ref<string | undefined>()
const isChangingLocale = () => $changingTo.value != null || props.isChanging
const $activeLocale = computed(() => {
if ($changingTo.value != null) return $changingTo.value
return props.currentLocale
})
function changeLocale(value: string) {
if ($activeLocale.value === value) return
const result = props.onLocaleChange(value)
if (!result) return
$changingTo.value = value
void result.then(
() => {
$changingTo.value = undefined
},
() => {
$changingTo.value = undefined
},
)
}
const $languagesList = ref<HTMLDivElement | undefined>()
function onSearchKeydown(e: KeyboardEvent) {
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
const focusableTarget = $languagesList.value?.querySelector(
'button:not(:disabled), [tabindex]:not([tabindex="-1"])',
) as HTMLElement | undefined
focusableTarget?.focus()
}
function onItemClick(e: MouseEvent, loc: LocaleInfo) {
if (isModifierKeyDown(e) || isChangingLocale()) return
changeLocale(loc.tag)
}
function showBrowserDisplayName(loc: LocaleInfo): boolean {
return (
loc.browserDisplayName.localeCompare(loc.displayName, undefined, { sensitivity: 'base' }) !== 0
)
}
function getItemLabel(loc: LocaleInfo) {
const coverageLabel = loc.coverage
? `. ${formatMessage(messages.coverageLabel, { percentage: loc.coverage.percentage })}`
: ''
const browserDisplayName = showBrowserDisplayName(loc) ? `. ${loc.browserDisplayName}` : ''
return `${loc.displayName}${browserDisplayName}${coverageLabel}`
}
function getCoverageTooltip(coverage: LanguageCoverageStats): string {
const message =
props.product === 'app' ? messages.appCoverageTooltip : messages.websiteCoverageTooltip
return formatMessage(message, {
percentage: coverage.percentage,
})
}
function getCategoryName(category: Category): string {
if (category === 'searchResult') {
return formatMessage(messages.searchResults)
}
return formatMessage(messages.standardLanguages)
}
</script>
<template>
<div class="flex flex-col gap-4">
<div v-if="$locales.length > 1" class="-mb-4">
<StyledInput
id="language-search"
v-model="$query"
:icon="SearchIcon"
name="language"
type="search"
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
:disabled="isChangingLocale()"
wrapper-class="w-full"
@keydown="onSearchKeydown"
/>
<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="flex flex-col gap-2.5">
<template v-for="[category, categoryLocales] in $displayCategories" :key="category">
<strong class="mt-4 font-semibold text-contrast">
{{ getCategoryName(category) }}
</strong>
<div
v-if="category === 'searchResult' && categoryLocales.length === 0"
class="p-4 text-secondary"
tabindex="0"
>
{{ formatMessage(messages.noResults) }}
</div>
<template v-for="loc in categoryLocales" :key="loc.tag">
<Button
:type="$activeLocale === loc.tag ? 'colored' : 'base'"
:color="$activeLocale === loc.tag ? 'green' : undefined"
:aria-pressed="$activeLocale === loc.tag"
:disabled="isChangingLocale() && $changingTo !== loc.tag"
:aria-label="getItemLabel(loc)"
class="w-full !justify-start !gap-2 !text-left sm:!h-10"
:class="
$activeLocale === loc.tag
? '!bg-[var(--color-button-bg-selected)] !text-[var(--color-button-text-selected)]'
: ''
"
@click="(e) => onItemClick(e, loc)"
>
<img
v-if="loc.flagUrl"
:src="loc.flagUrl"
alt=""
aria-hidden="true"
class="h-4 w-6 shrink-0 rounded-sm object-cover"
loading="lazy"
/>
<span class="flex min-w-0 flex-1 items-baseline gap-2 overflow-hidden">
<span class="truncate text-sm sm:text-base">{{ loc.displayName }}</span>
<span
v-if="showBrowserDisplayName(loc)"
class="truncate text-xs font-normal text-secondary sm:text-sm"
>
{{ loc.browserDisplayName }}
</span>
</span>
<span
v-if="loc.coverage"
v-tooltip="getCoverageTooltip(loc.coverage)"
class="ml-auto shrink-0 text-xs font-normal text-secondary sm:text-sm"
>
{{ loc.coverage.percentage }}%
</span>
</Button>
</template>
</template>
</div>
</div>
</template>