langie 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/styles/teleport.css","/builds/codelits/langie-sdk/src/components/LanguageSelect.vue","/builds/codelits/langie-sdk/src/components/InterfaceLanguageSelect.vue","/builds/codelits/langie-sdk/src/components/lt.vue"],"sourcesContent":["/* Teleport styles for multiselect components */\n/* These styles are used by components that are teleported outside the normal DOM flow */\n\n.multiselect-single-label,\n.multiselect-option {\n display: flex;\n align-items: center;\n}\n\n.lang-flag {\n width: 20px;\n height: 15px;\n margin-right: 10px;\n border: 1px solid var(--langie-flag-border, #eee);\n border-radius: 2px;\n object-fit: cover;\n}\n\n.is-dark .lang-flag {\n border-color: var(--langie-flag-border-dark, #4b5563);\n}\n\n.native-name {\n color: var(--langie-text-secondary, #6b7280);\n margin-left: 0.5rem;\n font-size: 0.9em;\n}\n\n.is-dark .native-name {\n color: var(--langie-text-secondary-dark, #9ca3af);\n}\n\n.multiselect-no-results,\n.multiselect-no-options {\n padding: 0.5rem 0.875rem;\n color: var(--langie-text-secondary, #6b7280);\n}\n\n.is-dark .multiselect-no-results,\n.is-dark .multiselect-no-options {\n color: var(--langie-text-secondary-dark, #9ca3af);\n}\n","<template>\n <div class=\"language-select\" :class=\"{ 'is-dark': isDark }\">\n <!-- Show multiselect when we have languages OR when not loading -->\n <Multiselect\n v-if=\"validLanguages.length > 0 || !isLoading\"\n ref=\"multiselectRef\"\n :key=\"multiselectKey\"\n v-model=\"selectedLanguage\"\n :options=\"filteredLanguages\"\n :searchable=\"true\"\n @search-change=\"handleSearch\"\n :can-clear=\"false\"\n :allow-empty=\"false\"\n :object=\"true\"\n :placeholder=\"validLanguages.length === 0 ? 'No languages available' : placeholder\"\n :disabled=\"props.disabled || validLanguages.length === 0\"\n :loading=\"isLoading\"\n track-by=\"code\"\n label=\"name\"\n value-prop=\"code\"\n :filter-results=\"false\"\n :id=\"multiselectId\"\n :aria=\"COMBOBOX_ARIA\"\n :attrs=\"searchInputAttrs\"\n @keydown=\"handleKeydown\"\n >\n <template #singlelabel=\"{ value }\">\n <div v-if=\"value\" class=\"multiselect-single-label\">\n <img\n v-if=\"value.code\"\n :key=\"selectedLanguageKey\"\n :src=\"getFlagImageUrl(value)\"\n class=\"lang-flag\"\n :alt=\"`${value.native_name || value.name} flag`\"\n :data-custom-flag=\"isReadyFlagUrl(getFlagCode(value)) || undefined\"\n @error=\"onFlagError\"\n />\n <span class=\"language-text\"\n >{{ value.name }}\n <span v-if=\"value.native_name && value.native_name !== value.name\" class=\"native-name\"\n >({{ displayNative(value.name, value.native_name) }})</span\n ></span\n >\n </div>\n </template>\n <template #option=\"{ option }\">\n <div class=\"multiselect-option\" :data-lang-code=\"option.code\">\n <img\n :key=\"`${option.code}-${option.flag_country || 'fallback'}`\"\n :src=\"getFlagImageUrl(option)\"\n class=\"lang-flag\"\n :alt=\"`${option.name} flag`\"\n :data-custom-flag=\"isReadyFlagUrl(getFlagCode(option)) || undefined\"\n @error=\"onFlagError\"\n />\n <span class=\"language-text\"\n >{{ option.name }}\n <span\n v-if=\"option.native_name && option.native_name !== option.name\"\n class=\"native-name\"\n >({{ displayNative(option.name, option.native_name) }})</span\n ></span\n >\n </div>\n </template>\n <template #noresults>\n <div class=\"multiselect-no-results\">No languages found.</div>\n </template>\n <template #nooptions>\n <div class=\"multiselect-no-options\">\n {{\n validLanguages.length === 0\n ? 'Please provide languages via the :languages prop'\n : 'No languages available.'\n }}\n </div>\n </template>\n </Multiselect>\n <!-- Only show skeleton loader when actually loading AND we have no languages yet -->\n <div v-else-if=\"isLoading && validLanguages.length === 0\" class=\"skeleton-loader\"></div>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport '@vueform/multiselect/themes/default.css'\nimport '../styles/teleport.css'\nimport { ref, shallowRef, computed, nextTick, useId } from 'vue'\nimport type { PropType } from 'vue'\nimport Multiselect from '@vueform/multiselect'\nimport Fuse from 'fuse.js'\nimport type { FuseResult } from 'fuse.js'\nimport { loadTransliterate } from '../utils/lazyTransliterate'\nimport type { Transliterate } from '../utils/lazyTransliterate'\nimport { applyLanguageAlias } from '../search-utils'\nimport { displayNative } from '../utils/displayNative'\nimport { flagImageUrl, isReadyFlagUrl } from '../utils/flagUrl'\nimport { THEME_COLORS, COLORS } from '../constants/colors'\nimport type { TranslatorLanguage } from '../types'\n\n// role=\"combobox\" supports neither aria-multiselectable nor aria-placeholder (see\n// axe-core's role table), but @vueform/multiselect emits both on the search input.\n// The `aria` prop is merged last, so an undefined value drops the attribute instead\n// of shipping invalid ARIA. The placeholder comes back as a native one below, which\n// screen readers announce and validators accept.\nconst COMBOBOX_ARIA = { 'aria-multiselectable': undefined, 'aria-placeholder': undefined }\n\n// Without an id, vueform hardcodes `multiselect-options` on the listbox and\n// `assist` on its description, so two pickers on one page (source + target) hand\n// assistive tech duplicate ids and the wrong `aria-controls` target. useId is\n// SSR-safe — a module counter would drift between the server process and the\n// client and break hydration.\nconst multiselectId = useId()\n\nconst getFlagCode = (lang: TranslatorLanguage): string => {\n const flagCode = lang.flag_country || lang.code\n return flagCode\n}\n\n// Local flags are downloaded during build for offline support and faster\n// loading; on a miss the onerror handler falls back to the CDN.\nconst getFlagImageUrl = (lang: TranslatorLanguage): string => flagImageUrl(getFlagCode(lang))\n\nconst props = defineProps({\n modelValue: {\n type: Object as PropType<TranslatorLanguage | null | undefined>,\n default: undefined\n },\n languages: {\n type: Array as PropType<TranslatorLanguage[]>,\n default: () => []\n },\n placeholder: {\n type: String,\n default: 'Select language'\n },\n disabled: {\n type: Boolean,\n default: false\n },\n isDark: {\n type: Boolean,\n default: false\n }\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\nconst multiselectRef = ref<{ setPointerFirst?: () => void } | null>(null)\n\nconst isLoading = computed(() => {\n return props.languages.length <= 0\n})\nconst searchQuery = ref('')\nconst transliterate = shallowRef<Transliterate | null>(null)\n\nconst searchInputAttrs = computed(() => ({ placeholder: props.placeholder }))\n\nconst validLanguages = computed(() => {\n return props.languages.filter((lang) => lang && lang.code && lang.name && lang.native_name)\n})\n\nconst fuse = computed(() => {\n if (!validLanguages.value.length) return null\n\n return new Fuse(validLanguages.value, {\n keys: ['name', 'native_name', 'search_alias', 'code'],\n includeScore: true,\n threshold: 0.4, // Trim fuzzy noise; short abbreviations (e.g. \"сша\") no longer rank below it\n ignoreLocation: true, // Match anywhere in the field, not just near position 0\n isCaseSensitive: false,\n minMatchCharLength: 1 // Allow single character matches\n })\n})\n\nconst multiselectKey = computed(() => {\n // Create a key that changes when the language data structure changes\n const hasApiData = props.languages.length > 0 && props.languages[0].flag_country !== null\n return hasApiData ? 'api-data' : 'fallback-data'\n})\n\nconst selectedLanguageKey = computed(() => {\n if (!props.modelValue || !props.modelValue.code) return 'no-selection'\n const key = `${props.modelValue.code}-${props.modelValue.flag_country || 'fallback'}`\n return key\n})\n\nconst selectedLanguage = computed({\n get: () => props.modelValue || null,\n set: (value) => {\n if (value && value.code) {\n emit('update:modelValue', value)\n }\n }\n})\n\nconst filteredLanguages = computed(() => {\n const query = searchQuery.value.trim()\n\n let results: TranslatorLanguage[]\n if (!query) {\n // When no search query, return all valid languages (bypass Fuse.js)\n results = validLanguages.value\n } else {\n // Only use Fuse.js when there's an actual search query\n if (!fuse.value) return []\n\n // Get the most likely alias or the original term\n const aliasResult = applyLanguageAlias(query)\n const searchTerm = Array.isArray(aliasResult.primary)\n ? aliasResult.primary[0]\n : aliasResult.primary\n\n // Merge Fuse hits into `results`, skipping languages already present.\n const mergeByCode = (hits: FuseResult<TranslatorLanguage>[]) => {\n hits.forEach((hit) => {\n if (!results.some((r) => r.code === hit.item.code)) results.push(hit.item)\n })\n }\n\n // Always use Fuse.js for the main search (both original query and alias result)\n results = fuse.value.search(searchTerm).map((result) => result.item)\n\n // If the search term is different from query (alias was applied), also search the original query\n if (searchTerm !== query) {\n mergeByCode(fuse.value.search(query))\n }\n\n // Transliterate the query to Latin so a foreign-script keyboard still matches the\n // Latin `name`/`native_name` when no localized `search_alias` exists (e.g. English\n // interface). Helps cognates: Cyrillic \"браз\" → \"braz\" → Brazilian.\n const latinQuery = transliterate.value ? transliterate.value(query) : ''\n if (latinQuery && latinQuery.toLowerCase() !== query.toLowerCase()) {\n mergeByCode(fuse.value.search(latinQuery))\n }\n\n // Add suggested languages to expand the results\n if (aliasResult.suggestions.length > 0) {\n aliasResult.suggestions.forEach((suggestion) => {\n const suggestedLang = validLanguages.value.find((lang) => {\n const langName = lang.name.toLowerCase()\n const suggestion_lower = suggestion.toLowerCase()\n\n // Try multiple matching strategies\n return (\n langName === suggestion_lower || // Exact match\n langName.includes(suggestion_lower) || // Contains match\n lang.code.toLowerCase() === suggestion_lower || // Code match\n langName.startsWith(suggestion_lower) || // Starts with match\n // Handle common variations\n (suggestion_lower === 'tatar' && langName.includes('tatar')) ||\n (suggestion_lower === 'belarusian' &&\n (langName.includes('belarus') || langName.includes('belarusian'))) ||\n (suggestion_lower === 'moldovan' &&\n (langName.includes('moldov') || langName.includes('moldova')))\n )\n })\n if (suggestedLang && !results.some((r) => r.code === suggestedLang.code)) {\n results.push(suggestedLang)\n }\n })\n }\n\n // Special handling for very short queries (e.g. \"се\")\n if (query.length === 2) {\n const lowerQuery = query.toLowerCase()\n const manualResults = validLanguages.value.filter(\n (lang) =>\n lang.name.toLowerCase().startsWith(lowerQuery) ||\n lang.native_name.toLowerCase().startsWith(lowerQuery)\n )\n manualResults.forEach((lang) => {\n if (!results.some((r) => r.code === lang.code)) {\n results.push(lang)\n }\n })\n }\n }\n\n // Remove the currently selected language from the options\n const filtered = results.filter(\n (lang) => !props.modelValue || !props.modelValue.code || lang.code !== props.modelValue.code\n )\n\n return filtered\n})\n\nfunction handleSearch(query: string) {\n searchQuery.value = query\n if (!query.trim()) return\n\n // Typing is the first moment the charmap can matter; the results recompute on\n // their own once the ref lands, so the first keystrokes stay Latin-only.\n if (!transliterate.value) {\n void loadTransliterate().then((fn) => {\n transliterate.value = fn\n })\n }\n\n // We feed our own fuzzy-filtered list (`filter-results=false`), so vueform's search\n // watcher runs setPointerFirst against the OLD options before our list re-renders —\n // leaving a STALE internal pointer that Enter/arrows then act on (random selection).\n // Re-point to the first option once the fresh list has rendered, and reset the scroll\n // so the best match is at the top. This drives vueform's real pointer (which owns the\n // `is-pointed` highlight and keyboard selection), not just the visual class.\n nextTick(() => {\n multiselectRef.value?.setPointerFirst?.()\n const dropdown = document.querySelector('.multiselect-dropdown')\n if (dropdown) dropdown.scrollTop = 0\n })\n}\n\n// Enter/Tab selects the currently highlighted option. `data-lang-code` lives on our inner\n// slot div, not vueform's outer `.multiselect-option` wrapper — read it from the pointed\n// row (falling back to the first) so selection matches what the user sees highlighted.\nfunction handleKeydown(event: Event) {\n const keyboardEvent = event as KeyboardEvent\n if (keyboardEvent.key !== 'Tab' && keyboardEvent.key !== 'Enter') return\n if (!searchQuery.value.trim()) return\n\n const dropdown = document.querySelector('.multiselect-dropdown')\n const pointed =\n dropdown?.querySelector('.multiselect-option.is-pointed [data-lang-code]') ||\n dropdown?.querySelector('.multiselect-option [data-lang-code]')\n const langCode = pointed?.getAttribute('data-lang-code')\n if (!langCode) return\n\n const language = validLanguages.value.find((lang) => lang.code === langCode)\n if (language) {\n selectedLanguage.value = language\n searchQuery.value = ''\n keyboardEvent.preventDefault()\n }\n}\n\nconst onFlagError = (event: Event) => {\n const target = event.target as HTMLImageElement\n const currentSrc = target.src\n\n // A custom flag is final: its slug is a language code, so the CDN fallback\n // below would silently serve an unrelated country — \"cv\" is Chuvash to us\n // and Cape Verde to flagcdn, \"ba\" Bashkir and Bosnia. Wrong flag beats no\n // flag only if you never look at it. Hide instead.\n if (target.dataset.customFlag) {\n target.style.display = 'none'\n return\n }\n\n // If loading from local cache failed, try CDN\n if (currentSrc && currentSrc.includes('/flags/')) {\n const match = currentSrc.match(/\\/flags\\/([a-z]{2})\\.svg/)\n if (match) {\n const countryCode = match[1]\n // Fallback to CDN\n target.src = `https://flagcdn.com/${countryCode}.svg`\n // If CDN also fails, hide the image\n target.onerror = () => {\n target.style.display = 'none'\n }\n return\n }\n }\n\n // If CDN or any other source fails, hide the image\n target.style.display = 'none'\n}\n</script>\n\n<style scoped>\n.language-select {\n position: relative;\n min-height: 40px; /* To prevent layout shift while loading */\n}\n\n.skeleton-loader {\n width: 100%;\n height: 40px;\n background-color: #e2e8f0;\n border-radius: 4px;\n animation: pulse 1.5s infinite ease-in-out;\n}\n\n.is-dark .skeleton-loader {\n background-color: #374151;\n}\n\n@keyframes pulse {\n 0%,\n 100% {\n opacity: 1;\n }\n 50% {\n opacity: 0.5;\n }\n}\n\n:deep(.multiselect) {\n --ms-font-size: 1rem;\n --ms-line-height: 1.5;\n --ms-bg: v-bind('THEME_COLORS.light.background');\n --ms-bg-disabled: v-bind('THEME_COLORS.light.backgroundDisabled');\n --ms-border-color: v-bind('THEME_COLORS.light.border');\n --ms-border-width: 1px;\n --ms-radius: 4px;\n --ms-py: 0.3rem;\n --ms-px: 0.875rem;\n --ms-ring-width: 3px;\n --ms-ring-color: v-bind('THEME_COLORS.light.ring');\n --ms-placeholder-color: v-bind('THEME_COLORS.light.placeholder');\n --ms-color: #1f2937; /* Explicit text color for light theme */\n\n /* Options */\n --ms-option-font-size: 1rem;\n --ms-option-line-height: 1;\n --ms-option-py: 0.2rem;\n --ms-option-px: 0.5rem;\n --ms-option-bg-pointed: v-bind('THEME_COLORS.light.optionPointed');\n --ms-option-color-pointed: v-bind('THEME_COLORS.light.optionPointedText');\n --ms-option-bg-selected: v-bind('THEME_COLORS.light.optionSelected');\n --ms-option-color-selected: v-bind('THEME_COLORS.light.optionSelectedText');\n --ms-option-bg-disabled: #fff;\n --ms-option-color-disabled: #d1d5db;\n\n /* Dropdown */\n --ms-dropdown-bg: v-bind('THEME_COLORS.light.dropdownBackground');\n --ms-dropdown-border-color: v-bind('THEME_COLORS.light.dropdownBorder');\n --ms-dropdown-color: v-bind('THEME_COLORS.light.dropdownText');\n\n /* Others */\n --ms-tag-bg: v-bind('THEME_COLORS.light.tagBackground');\n}\n\n.is-dark :deep(.multiselect) {\n --ms-bg: v-bind('THEME_COLORS.dark.background');\n --ms-bg-disabled: v-bind('THEME_COLORS.dark.backgroundDisabled');\n --ms-border-color: v-bind('THEME_COLORS.dark.border');\n --ms-placeholder-color: v-bind('THEME_COLORS.dark.placeholder');\n --ms-ring-color: v-bind('THEME_COLORS.dark.ring');\n --ms-color: v-bind('THEME_COLORS.dark.dropdownText'); /* typed search text; base is dark #1f2937 → invisible on dark bg */\n\n /* Options */\n --ms-option-bg-pointed: v-bind('THEME_COLORS.dark.optionPointed');\n --ms-option-color-pointed: v-bind('THEME_COLORS.dark.optionPointedText');\n --ms-option-bg-selected: v-bind('THEME_COLORS.dark.optionSelected');\n --ms-option-color-selected: v-bind('THEME_COLORS.dark.optionSelectedText');\n\n /* Dropdown */\n --ms-dropdown-bg: v-bind('THEME_COLORS.dark.dropdownBackground');\n --ms-dropdown-border-color: v-bind('THEME_COLORS.dark.dropdownBorder');\n --ms-dropdown-color: v-bind('THEME_COLORS.dark.dropdownText');\n}\n\n:deep(.lang-flag) {\n width: 20px;\n height: 15px;\n border: 1px solid v-bind('COLORS.border.flag');\n border-radius: 2px;\n object-fit: cover;\n flex-shrink: 0;\n display: block;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n\n.is-dark :deep(.lang-flag) {\n border-color: v-bind('COLORS.border.dark');\n}\n\n:deep(.multiselect-single-label),\n:deep(.multiselect-option) {\n display: flex !important;\n align-items: center !important;\n min-width: 0 !important;\n width: 100% !important;\n padding: 4px 8px !important;\n gap: 8px !important;\n white-space: nowrap !important;\n overflow: hidden !important;\n flex-wrap: nowrap !important;\n}\n\n:deep(.multiselect-option) {\n text-align: left !important;\n transition: background-color 0.15s ease !important;\n white-space: nowrap !important;\n overflow: hidden !important;\n flex-wrap: nowrap !important;\n}\n\n:deep(.language-text) {\n display: inline-block !important;\n min-width: 0 !important;\n white-space: nowrap !important;\n overflow: hidden !important;\n text-overflow: ellipsis !important;\n flex: 1 !important;\n line-height: 1.4 !important;\n color: v-bind('THEME_COLORS.light.dropdownText') !important;\n word-break: keep-all !important;\n word-wrap: normal !important;\n word-spacing: normal !important;\n max-width: 100% !important;\n}\n\n.is-dark :deep(.language-text) {\n color: v-bind('THEME_COLORS.dark.dropdownText') !important;\n}\n\n:deep(.native-name) {\n color: v-bind('COLORS.text.secondary');\n margin-left: 0.5rem;\n font-size: 0.9em;\n white-space: nowrap !important;\n display: inline !important;\n}\n\n.is-dark :deep(.native-name) {\n color: v-bind('COLORS.neutral.gray400');\n}\n\n:deep(.multiselect) {\n width: 100%;\n min-width: 200px;\n color: v-bind('THEME_COLORS.light.dropdownText');\n position: relative;\n z-index: 1;\n}\n\n.is-dark :deep(.multiselect) {\n color: v-bind('THEME_COLORS.dark.dropdownText');\n}\n\n/* Lift the open select above sibling selects (each is its own z-index:1 stacking context) */\n:deep(.multiselect.is-active) {\n z-index: 2;\n}\n\n:deep(.multiselect-dropdown) {\n padding: 2px 0 !important;\n z-index: 1000 !important;\n}\n\n:deep(.multiselect-dropdown .multiselect-option) {\n padding: 4px 8px !important;\n margin: 0 !important;\n white-space: nowrap !important;\n overflow: hidden !important;\n flex-wrap: nowrap !important;\n}\n\n:deep(.multiselect-dropdown .language-text) {\n white-space: nowrap !important;\n overflow: hidden !important;\n text-overflow: ellipsis !important;\n word-break: keep-all !important;\n word-wrap: normal !important;\n word-spacing: normal !important;\n max-width: 100% !important;\n}\n\n:deep(.multiselect-no-results),\n:deep(.multiselect-no-options) {\n padding: 0.5rem 0.875rem;\n color: v-bind('COLORS.text.secondary');\n}\n\n.is-dark :deep(.multiselect-no-results),\n.is-dark :deep(.multiselect-no-options) {\n color: v-bind('COLORS.neutral.gray400');\n}\n</style>\n","<template>\n <div class=\"interface-language-select-wrapper\">\n <LanguageSelect\n :key=\"`interface-lang-${effectiveLanguages.length}-${effectiveLanguages[0]?.code || 'empty'}`\"\n :model-value=\"currentLanguageObject\"\n :languages=\"effectiveLanguages\"\n :placeholder=\"props.placeholder\"\n :disabled=\"props.disabled || isChangingLanguage\"\n :is-dark=\"props.isDark\"\n @update:model-value=\"handleLanguageChange\"\n />\n\n <!-- Loading overlay -->\n <div\n v-if=\"isChangingLanguage\"\n class=\"language-change-loader\"\n :class=\"{ 'is-dark': props.isDark }\"\n >\n <div class=\"loader-spinner\"></div>\n <span class=\"loader-text\">{{ lr('Changing language...', 'ui') }}</span>\n </div>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, watch, onMounted, ref } from 'vue'\nimport LanguageSelect from './LanguageSelect.vue'\nimport { useLangie } from '../useLangie'\nimport type { TranslatorLanguage } from '../types'\nimport { getCountryCode } from '../utils/getCountryCode'\n\n// Loading state for language change\nconst isChangingLanguage = ref(false)\n\nconst props = defineProps({\n placeholder: {\n type: String,\n default: 'Select interface language'\n },\n disabled: {\n type: Boolean,\n default: false\n },\n isDark: {\n type: Boolean,\n default: false\n },\n translatorHost: {\n type: String,\n default: ''\n },\n apiKey: {\n type: String,\n default: ''\n },\n languages: {\n type: Array as () => TranslatorLanguage[],\n default: () => []\n }\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\n// Use the same SDK instance as the main app\nconst { availableLanguages, currentLanguage, setLanguage, fetchLanguages, lr } = useLangie()\n\n// Use provided languages if available, otherwise use SDK's languages\nconst effectiveLanguages = computed(() => {\n const languages =\n props.languages && props.languages.length > 0 ? props.languages : availableLanguages.value\n\n // Ensure all languages have required fields to prevent jumping\n const processed = languages.map((lang) => ({\n ...lang,\n native_name: lang.native_name || lang.name,\n flag_country: lang.flag_country || lang.code\n }))\n\n // Important: Keep API order (by popularity from language_popularity table)\n // Do NOT sort alphabetically - multiselect may reorder, but we preserve source order\n return processed\n})\n\nconst currentLanguageObject = computed(() => {\n if (!currentLanguage.value) return null\n return effectiveLanguages.value.find((lang) => lang.code === currentLanguage.value) || null\n})\n\n// Function to detect browser language from available languages\nfunction detectBrowserLanguage(languages: TranslatorLanguage[]): string | null {\n if (languages.length === 0) return null\n\n // Get browser languages in order of preference\n const browserLanguages = navigator.languages || [navigator.language || 'en']\n\n // Try to find exact match first\n for (const browserLang of browserLanguages) {\n const langCode = browserLang.toLowerCase().split('-')[0] // Extract language code (e.g., 'en' from 'en-US')\n const exactMatch = languages.find((lang) => lang.code.toLowerCase() === langCode)\n if (exactMatch) {\n return exactMatch.code\n }\n }\n\n // If no exact match, try with full locale (e.g., 'en-US')\n for (const browserLang of browserLanguages) {\n const fullLangCode = browserLang.toLowerCase()\n const localeMatch = languages.find((lang) => lang.code.toLowerCase() === fullLangCode)\n if (localeMatch) {\n return localeMatch.code\n }\n }\n\n return null\n}\n\nasync function handleLanguageChange(selectedLanguage: TranslatorLanguage | null) {\n if (selectedLanguage) {\n // Start loading\n isChangingLanguage.value = true\n\n try {\n setLanguage(selectedLanguage.code)\n // Save to localStorage when user manually changes language\n localStorage.setItem('interface_language', selectedLanguage.code)\n emit('update:modelValue', selectedLanguage)\n } finally {\n // End loading\n isChangingLanguage.value = false\n }\n }\n}\n\n// Watch for current language changes to save to localStorage\nwatch(currentLanguage, (newLangCode) => {\n if (newLangCode) {\n localStorage.setItem('interface_language', newLangCode)\n }\n})\n\n// Watch for changes in effective languages to set browser language\nwatch(\n () => effectiveLanguages.value,\n (newLanguages) => {\n if (newLanguages.length > 0 && !currentLanguage.value) {\n // Only set browser language if no language is currently selected\n const savedLanguageCode = localStorage.getItem('interface_language')\n\n if (savedLanguageCode) {\n // Check if saved language exists in the provided languages\n const savedLangExists = newLanguages.find((lang) => lang.code === savedLanguageCode)\n if (savedLangExists) {\n setLanguage(savedLanguageCode)\n return\n }\n }\n\n // If no saved language or saved language doesn't exist, detect browser language\n const browserLang = detectBrowserLanguage(newLanguages)\n if (browserLang) {\n setLanguage(browserLang)\n }\n }\n },\n { immediate: true }\n)\n\n// Load saved language from localStorage on initialization\nonMounted(async () => {\n // Only fetch languages if not provided via props\n if (!props.languages || props.languages.length === 0) {\n // Get country code from browser for better language ordering\n const countryCode = await getCountryCode()\n await fetchLanguages({ country: countryCode || undefined })\n }\n\n const savedLanguageCode = localStorage.getItem('interface_language')\n\n if (savedLanguageCode && savedLanguageCode !== currentLanguage.value) {\n // Check if saved language exists in current languages\n const currentLanguages = effectiveLanguages.value\n const savedLangExists = currentLanguages.find((lang) => lang.code === savedLanguageCode)\n\n if (savedLangExists) {\n setLanguage(savedLanguageCode)\n } else if (currentLanguages.length > 0) {\n // If saved language doesn't exist, try to detect browser language\n const browserLang = detectBrowserLanguage(currentLanguages)\n if (browserLang) {\n setLanguage(browserLang)\n }\n }\n } else if (!currentLanguage.value && effectiveLanguages.value.length > 0) {\n // If no saved language and no current language, detect browser language\n const browserLang = detectBrowserLanguage(effectiveLanguages.value)\n if (browserLang) {\n setLanguage(browserLang)\n }\n }\n})\n\n// Emit the current language when it changes\nwatch(\n currentLanguageObject,\n (newValue) => {\n if (newValue) {\n emit('update:modelValue', newValue)\n }\n },\n { immediate: true }\n)\n</script>\n\n<style scoped>\n.interface-language-select-wrapper {\n position: relative;\n display: inline-block;\n width: 100%;\n min-width: 200px;\n}\n\n/* Override styles for the language select component */\n.interface-language-select-wrapper :deep(.multiselect-option) {\n padding: 5px 10px !important;\n margin: 0;\n border-radius: 0;\n}\n\n.interface-language-select-wrapper :deep(.multiselect-option > div) {\n padding: 0 !important;\n margin: 0;\n}\n\n.interface-language-select-wrapper :deep(.multiselect-dropdown) {\n padding: 2px 0 !important;\n}\n\n.language-change-loader {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(255, 255, 255, 0.95);\n border-radius: 6px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n z-index: 10;\n backdrop-filter: blur(2px);\n border: 1px solid rgba(0, 0, 0, 0.1);\n}\n\n.language-change-loader.is-dark {\n background: rgba(30, 30, 30, 0.95);\n border-color: rgba(255, 255, 255, 0.1);\n}\n\n.loader-spinner {\n width: 24px;\n height: 24px;\n border: 3px solid #e5e7eb;\n border-top: 3px solid #3b82f6;\n border-radius: 50%;\n animation: spin 1s linear infinite;\n margin-bottom: 8px;\n}\n\n.language-change-loader.is-dark .loader-spinner {\n border-color: #374151;\n border-top-color: #3b82f6;\n}\n\n.loader-text {\n font-size: 12px;\n color: #6b7280;\n font-weight: 500;\n text-align: center;\n}\n\n.language-change-loader.is-dark .loader-text {\n color: #9ca3af;\n}\n\n@keyframes spin {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n}\n</style>\n","<template>\n <transition name=\"fade\" mode=\"out-in\">\n <span :key=\"translated\">{{ translated }}</span>\n </transition>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, useSlots, watch } from 'vue'\nimport { ref, nextTick } from 'vue'\nimport { useLangie } from '../useLangie'\n\n// Define component name for better debugging\n// Component name for Vue devtools\n// Note: defineOptions requires Vue 3.3+, but we support Vue 3.2+\n// The component will work without explicit name\n\n// Detect if we're running in Nuxt\nconst isNuxt = computed(() => {\n if (typeof window !== 'undefined') {\n return !!(window as any).__NUXT__\n }\n if (typeof process !== 'undefined') {\n return !!(process.env as any).NUXT_SSR_BASE || !!(process.env as any).NUXT_PUBLIC_BASE_URL\n }\n return false\n})\n\nconst props = defineProps({\n // Message key (optional, otherwise slot content is used)\n msg: {\n type: String,\n default: undefined\n },\n // Translation context shorthand\n ctx: {\n type: String,\n required: false,\n default: 'ui'\n },\n // Original language shorthand\n orig: {\n type: String,\n required: false,\n default: undefined\n }\n})\n\nconst slots = useSlots()\nconst { lr, peekTranslation, currentLanguage, uiTranslations, translations, getLtDefaults } =\n useLangie()\n\nconst keyStr = computed(() => {\n if (props.msg) return props.msg\n const slotContent = slots.default\n ? slots\n .default()\n .map((n) => n.children)\n .join('')\n : ''\n return (slotContent || '').trim()\n})\n\n// Force component update when translations change\nconst forceUpdate = ref(0)\nwatch(\n [uiTranslations, translations],\n () => {\n // Small delay to ensure translations are cached before updating\n nextTick(() => {\n forceUpdate.value++\n })\n },\n { deep: true }\n)\n\nconst translated = computed(() => {\n // Get global defaults\n const globalDefaults = getLtDefaults()\n\n // Use props or fall back to global defaults\n const effectiveCtx = props.ctx ?? globalDefaults.ctx\n const effectiveOrig = props.orig ?? globalDefaults.orig\n\n // On the server emit a seeded translation when the host pre-populated one (no\n // English flash, no CLS); on a cold miss render the key (invisible reserver,\n // fade-in after mount). peekTranslation never queues — a doomed SSR request is\n // pointless. The client seeds the same map, so hydration matches on hits.\n if (isNuxt.value && typeof window === 'undefined') {\n return peekTranslation(keyStr.value, effectiveCtx)\n }\n\n // Force reactivity by depending on currentLanguage and translation caches\n void currentLanguage.value\n void forceUpdate.value // Force recomputation when translations change\n // Access properties to create reactive dependencies\n const cacheKey = `${keyStr.value}|${effectiveCtx}`\n const cache = effectiveCtx === 'ui' ? uiTranslations : translations\n void cache[cacheKey] // This creates a reactive dependency\n\n // For other frameworks, always translate\n const result = lr(keyStr.value, effectiveCtx, effectiveOrig)\n return result\n})\n</script>\n\n<style scoped>\n.fade-enter-active,\n.fade-leave-active {\n transition: opacity 0.25s ease;\n}\n.fade-enter-from,\n.fade-leave-to {\n opacity: 0;\n}\n</style>\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/styles/teleport.css","/builds/codelits/langie-sdk/src/components/LanguageSelect.vue","/builds/codelits/langie-sdk/src/components/InterfaceLanguageSelect.vue","/builds/codelits/langie-sdk/src/components/lt.vue"],"sourcesContent":["/* Teleport styles for multiselect components */\n/* These styles are used by components that are teleported outside the normal DOM flow */\n\n.multiselect-single-label,\n.multiselect-option {\n display: flex;\n align-items: center;\n}\n\n.lang-flag {\n width: 20px;\n height: 15px;\n margin-right: 10px;\n border: 1px solid var(--langie-flag-border, #eee);\n border-radius: 2px;\n object-fit: cover;\n}\n\n.is-dark .lang-flag {\n border-color: var(--langie-flag-border-dark, #4b5563);\n}\n\n.native-name {\n color: var(--langie-text-secondary, #6b7280);\n margin-left: 0.5rem;\n font-size: 0.9em;\n}\n\n.is-dark .native-name {\n color: var(--langie-text-secondary-dark, #9ca3af);\n}\n\n.multiselect-no-results,\n.multiselect-no-options {\n padding: 0.5rem 0.875rem;\n color: var(--langie-text-secondary, #6b7280);\n}\n\n.is-dark .multiselect-no-results,\n.is-dark .multiselect-no-options {\n color: var(--langie-text-secondary-dark, #9ca3af);\n}\n","<template>\n <div class=\"language-select\" :class=\"{ 'is-dark': isDark }\">\n <!-- Show multiselect when we have languages OR when not loading -->\n <Multiselect\n v-if=\"validLanguages.length > 0 || !isLoading\"\n ref=\"multiselectRef\"\n :key=\"multiselectKey\"\n v-model=\"selectedLanguage\"\n :options=\"filteredLanguages\"\n :searchable=\"true\"\n @search-change=\"handleSearch\"\n :can-clear=\"false\"\n :allow-empty=\"false\"\n :object=\"true\"\n :placeholder=\"validLanguages.length === 0 ? 'No languages available' : placeholder\"\n :disabled=\"props.disabled || validLanguages.length === 0\"\n :loading=\"isLoading\"\n track-by=\"code\"\n label=\"name\"\n value-prop=\"code\"\n :filter-results=\"false\"\n :id=\"multiselectId\"\n :aria=\"COMBOBOX_ARIA\"\n :attrs=\"searchInputAttrs\"\n @keydown=\"handleKeydown\"\n >\n <template #singlelabel=\"{ value }\">\n <div v-if=\"value\" class=\"multiselect-single-label\">\n <img\n v-if=\"value.code\"\n :key=\"selectedLanguageKey\"\n :src=\"getFlagImageUrl(value)\"\n class=\"lang-flag\"\n :alt=\"`${languageLabel(value).primary} flag`\"\n :data-custom-flag=\"isReadyFlagUrl(getFlagCode(value)) || undefined\"\n @error=\"onFlagError\"\n />\n <span class=\"language-text\"\n >{{ languageLabel(value).primary }}\n <span v-if=\"languageLabel(value).native\" class=\"native-name\"\n >({{ languageLabel(value).native }})</span\n ></span\n >\n </div>\n </template>\n <template #option=\"{ option }\">\n <div class=\"multiselect-option\" :data-lang-code=\"option.code\">\n <img\n :key=\"`${option.code}-${option.flag_country || 'fallback'}`\"\n :src=\"getFlagImageUrl(option)\"\n class=\"lang-flag\"\n :alt=\"`${languageLabel(option).primary} flag`\"\n :data-custom-flag=\"isReadyFlagUrl(getFlagCode(option)) || undefined\"\n @error=\"onFlagError\"\n />\n <span class=\"language-text\"\n >{{ languageLabel(option).primary }}\n <span v-if=\"languageLabel(option).native\" class=\"native-name\"\n >({{ languageLabel(option).native }})</span\n ></span\n >\n </div>\n </template>\n <template #noresults>\n <div class=\"multiselect-no-results\">No languages found.</div>\n </template>\n <template #nooptions>\n <div class=\"multiselect-no-options\">\n {{\n validLanguages.length === 0\n ? 'Please provide languages via the :languages prop'\n : 'No languages available.'\n }}\n </div>\n </template>\n </Multiselect>\n <!-- Only show skeleton loader when actually loading AND we have no languages yet -->\n <div v-else-if=\"isLoading && validLanguages.length === 0\" class=\"skeleton-loader\"></div>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport '@vueform/multiselect/themes/default.css'\nimport '../styles/teleport.css'\nimport { ref, shallowRef, computed, nextTick, useId } from 'vue'\nimport type { PropType } from 'vue'\nimport Multiselect from '@vueform/multiselect'\nimport Fuse from 'fuse.js'\nimport type { FuseResult } from 'fuse.js'\nimport { loadTransliterate } from '../utils/lazyTransliterate'\nimport type { Transliterate } from '../utils/lazyTransliterate'\nimport { applyLanguageAlias } from '../search-utils'\nimport { languageLabel } from '../utils/languageLabel'\nimport { flagImageUrl, isReadyFlagUrl } from '../utils/flagUrl'\nimport { THEME_COLORS, COLORS } from '../constants/colors'\nimport type { TranslatorLanguage } from '../types'\n\n// role=\"combobox\" supports neither aria-multiselectable nor aria-placeholder (see\n// axe-core's role table), but @vueform/multiselect emits both on the search input.\n// The `aria` prop is merged last, so an undefined value drops the attribute instead\n// of shipping invalid ARIA. The placeholder comes back as a native one below, which\n// screen readers announce and validators accept.\nconst COMBOBOX_ARIA = { 'aria-multiselectable': undefined, 'aria-placeholder': undefined }\n\n// Without an id, vueform hardcodes `multiselect-options` on the listbox and\n// `assist` on its description, so two pickers on one page (source + target) hand\n// assistive tech duplicate ids and the wrong `aria-controls` target. useId is\n// SSR-safe — a module counter would drift between the server process and the\n// client and break hydration.\nconst multiselectId = useId()\n\nconst getFlagCode = (lang: TranslatorLanguage): string => {\n const flagCode = lang.flag_country || lang.code\n return flagCode\n}\n\n// Local flags are downloaded during build for offline support and faster\n// loading; on a miss the onerror handler falls back to the CDN.\nconst getFlagImageUrl = (lang: TranslatorLanguage): string => flagImageUrl(getFlagCode(lang))\n\nconst props = defineProps({\n modelValue: {\n type: Object as PropType<TranslatorLanguage | null | undefined>,\n default: undefined\n },\n languages: {\n type: Array as PropType<TranslatorLanguage[]>,\n default: () => []\n },\n placeholder: {\n type: String,\n default: 'Select language'\n },\n disabled: {\n type: Boolean,\n default: false\n },\n isDark: {\n type: Boolean,\n default: false\n }\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\nconst multiselectRef = ref<{ setPointerFirst?: () => void } | null>(null)\n\nconst isLoading = computed(() => {\n return props.languages.length <= 0\n})\nconst searchQuery = ref('')\nconst transliterate = shallowRef<Transliterate | null>(null)\n\nconst searchInputAttrs = computed(() => ({ placeholder: props.placeholder }))\n\nconst validLanguages = computed(() => {\n return props.languages.filter((lang) => lang && lang.code && lang.name && lang.native_name)\n})\n\nconst fuse = computed(() => {\n if (!validLanguages.value.length) return null\n\n return new Fuse(validLanguages.value, {\n keys: ['name', 'native_name', 'search_alias', 'code'],\n includeScore: true,\n threshold: 0.4, // Trim fuzzy noise; short abbreviations (e.g. \"сша\") no longer rank below it\n ignoreLocation: true, // Match anywhere in the field, not just near position 0\n isCaseSensitive: false,\n minMatchCharLength: 1 // Allow single character matches\n })\n})\n\nconst multiselectKey = computed(() => {\n // Create a key that changes when the language data structure changes\n const hasApiData = props.languages.length > 0 && props.languages[0].flag_country !== null\n return hasApiData ? 'api-data' : 'fallback-data'\n})\n\nconst selectedLanguageKey = computed(() => {\n if (!props.modelValue || !props.modelValue.code) return 'no-selection'\n const key = `${props.modelValue.code}-${props.modelValue.flag_country || 'fallback'}`\n return key\n})\n\nconst selectedLanguage = computed({\n get: () => props.modelValue || null,\n set: (value) => {\n if (value && value.code) {\n emit('update:modelValue', value)\n }\n }\n})\n\nconst filteredLanguages = computed(() => {\n const query = searchQuery.value.trim()\n\n let results: TranslatorLanguage[]\n if (!query) {\n // When no search query, return all valid languages (bypass Fuse.js)\n results = validLanguages.value\n } else {\n // Only use Fuse.js when there's an actual search query\n if (!fuse.value) return []\n\n // Get the most likely alias or the original term\n const aliasResult = applyLanguageAlias(query)\n const searchTerm = Array.isArray(aliasResult.primary)\n ? aliasResult.primary[0]\n : aliasResult.primary\n\n // Merge Fuse hits into `results`, skipping languages already present.\n const mergeByCode = (hits: FuseResult<TranslatorLanguage>[]) => {\n hits.forEach((hit) => {\n if (!results.some((r) => r.code === hit.item.code)) results.push(hit.item)\n })\n }\n\n // Always use Fuse.js for the main search (both original query and alias result)\n results = fuse.value.search(searchTerm).map((result) => result.item)\n\n // If the search term is different from query (alias was applied), also search the original query\n if (searchTerm !== query) {\n mergeByCode(fuse.value.search(query))\n }\n\n // Transliterate the query to Latin so a foreign-script keyboard still matches the\n // Latin `name`/`native_name` when no localized `search_alias` exists (e.g. English\n // interface). Helps cognates: Cyrillic \"браз\" → \"braz\" → Brazilian.\n const latinQuery = transliterate.value ? transliterate.value(query) : ''\n if (latinQuery && latinQuery.toLowerCase() !== query.toLowerCase()) {\n mergeByCode(fuse.value.search(latinQuery))\n }\n\n // Add suggested languages to expand the results\n if (aliasResult.suggestions.length > 0) {\n aliasResult.suggestions.forEach((suggestion) => {\n const suggestedLang = validLanguages.value.find((lang) => {\n const langName = lang.name.toLowerCase()\n const suggestion_lower = suggestion.toLowerCase()\n\n // Try multiple matching strategies\n return (\n langName === suggestion_lower || // Exact match\n langName.includes(suggestion_lower) || // Contains match\n lang.code.toLowerCase() === suggestion_lower || // Code match\n langName.startsWith(suggestion_lower) || // Starts with match\n // Handle common variations\n (suggestion_lower === 'tatar' && langName.includes('tatar')) ||\n (suggestion_lower === 'belarusian' &&\n (langName.includes('belarus') || langName.includes('belarusian'))) ||\n (suggestion_lower === 'moldovan' &&\n (langName.includes('moldov') || langName.includes('moldova')))\n )\n })\n if (suggestedLang && !results.some((r) => r.code === suggestedLang.code)) {\n results.push(suggestedLang)\n }\n })\n }\n\n // Special handling for very short queries (e.g. \"се\")\n if (query.length === 2) {\n const lowerQuery = query.toLowerCase()\n const manualResults = validLanguages.value.filter(\n (lang) =>\n lang.name.toLowerCase().startsWith(lowerQuery) ||\n lang.native_name.toLowerCase().startsWith(lowerQuery)\n )\n manualResults.forEach((lang) => {\n if (!results.some((r) => r.code === lang.code)) {\n results.push(lang)\n }\n })\n }\n }\n\n // Remove the currently selected language from the options\n const filtered = results.filter(\n (lang) => !props.modelValue || !props.modelValue.code || lang.code !== props.modelValue.code\n )\n\n return filtered\n})\n\nfunction handleSearch(query: string) {\n searchQuery.value = query\n if (!query.trim()) return\n\n // Typing is the first moment the charmap can matter; the results recompute on\n // their own once the ref lands, so the first keystrokes stay Latin-only.\n if (!transliterate.value) {\n void loadTransliterate().then((fn) => {\n transliterate.value = fn\n })\n }\n\n // We feed our own fuzzy-filtered list (`filter-results=false`), so vueform's search\n // watcher runs setPointerFirst against the OLD options before our list re-renders —\n // leaving a STALE internal pointer that Enter/arrows then act on (random selection).\n // Re-point to the first option once the fresh list has rendered, and reset the scroll\n // so the best match is at the top. This drives vueform's real pointer (which owns the\n // `is-pointed` highlight and keyboard selection), not just the visual class.\n nextTick(() => {\n multiselectRef.value?.setPointerFirst?.()\n const dropdown = document.querySelector('.multiselect-dropdown')\n if (dropdown) dropdown.scrollTop = 0\n })\n}\n\n// Enter/Tab selects the currently highlighted option. `data-lang-code` lives on our inner\n// slot div, not vueform's outer `.multiselect-option` wrapper — read it from the pointed\n// row (falling back to the first) so selection matches what the user sees highlighted.\nfunction handleKeydown(event: Event) {\n const keyboardEvent = event as KeyboardEvent\n if (keyboardEvent.key !== 'Tab' && keyboardEvent.key !== 'Enter') return\n if (!searchQuery.value.trim()) return\n\n const dropdown = document.querySelector('.multiselect-dropdown')\n const pointed =\n dropdown?.querySelector('.multiselect-option.is-pointed [data-lang-code]') ||\n dropdown?.querySelector('.multiselect-option [data-lang-code]')\n const langCode = pointed?.getAttribute('data-lang-code')\n if (!langCode) return\n\n const language = validLanguages.value.find((lang) => lang.code === langCode)\n if (language) {\n selectedLanguage.value = language\n searchQuery.value = ''\n keyboardEvent.preventDefault()\n }\n}\n\nconst onFlagError = (event: Event) => {\n const target = event.target as HTMLImageElement\n const currentSrc = target.src\n\n // A custom flag is final: its slug is a language code, so the CDN fallback\n // below would silently serve an unrelated country — \"cv\" is Chuvash to us\n // and Cape Verde to flagcdn, \"ba\" Bashkir and Bosnia. Wrong flag beats no\n // flag only if you never look at it. Hide instead.\n if (target.dataset.customFlag) {\n target.style.display = 'none'\n return\n }\n\n // If loading from local cache failed, try CDN\n if (currentSrc && currentSrc.includes('/flags/')) {\n const match = currentSrc.match(/\\/flags\\/([a-z]{2})\\.svg/)\n if (match) {\n const countryCode = match[1]\n // Fallback to CDN\n target.src = `https://flagcdn.com/${countryCode}.svg`\n // If CDN also fails, hide the image\n target.onerror = () => {\n target.style.display = 'none'\n }\n return\n }\n }\n\n // If CDN or any other source fails, hide the image\n target.style.display = 'none'\n}\n</script>\n\n<style scoped>\n.language-select {\n position: relative;\n min-height: 40px; /* To prevent layout shift while loading */\n}\n\n.skeleton-loader {\n width: 100%;\n height: 40px;\n background-color: #e2e8f0;\n border-radius: 4px;\n animation: pulse 1.5s infinite ease-in-out;\n}\n\n.is-dark .skeleton-loader {\n background-color: #374151;\n}\n\n@keyframes pulse {\n 0%,\n 100% {\n opacity: 1;\n }\n 50% {\n opacity: 0.5;\n }\n}\n\n:deep(.multiselect) {\n --ms-font-size: 1rem;\n --ms-line-height: 1.5;\n --ms-bg: v-bind('THEME_COLORS.light.background');\n --ms-bg-disabled: v-bind('THEME_COLORS.light.backgroundDisabled');\n --ms-border-color: v-bind('THEME_COLORS.light.border');\n --ms-border-width: 1px;\n --ms-radius: 4px;\n --ms-py: 0.3rem;\n --ms-px: 0.875rem;\n --ms-ring-width: 3px;\n --ms-ring-color: v-bind('THEME_COLORS.light.ring');\n --ms-placeholder-color: v-bind('THEME_COLORS.light.placeholder');\n --ms-color: #1f2937; /* Explicit text color for light theme */\n\n /* Options */\n --ms-option-font-size: 1rem;\n --ms-option-line-height: 1;\n --ms-option-py: 0.2rem;\n --ms-option-px: 0.5rem;\n --ms-option-bg-pointed: v-bind('THEME_COLORS.light.optionPointed');\n --ms-option-color-pointed: v-bind('THEME_COLORS.light.optionPointedText');\n --ms-option-bg-selected: v-bind('THEME_COLORS.light.optionSelected');\n --ms-option-color-selected: v-bind('THEME_COLORS.light.optionSelectedText');\n --ms-option-bg-disabled: #fff;\n --ms-option-color-disabled: #d1d5db;\n\n /* Dropdown */\n --ms-dropdown-bg: v-bind('THEME_COLORS.light.dropdownBackground');\n --ms-dropdown-border-color: v-bind('THEME_COLORS.light.dropdownBorder');\n --ms-dropdown-color: v-bind('THEME_COLORS.light.dropdownText');\n\n /* Others */\n --ms-tag-bg: v-bind('THEME_COLORS.light.tagBackground');\n}\n\n.is-dark :deep(.multiselect) {\n --ms-bg: v-bind('THEME_COLORS.dark.background');\n --ms-bg-disabled: v-bind('THEME_COLORS.dark.backgroundDisabled');\n --ms-border-color: v-bind('THEME_COLORS.dark.border');\n --ms-placeholder-color: v-bind('THEME_COLORS.dark.placeholder');\n --ms-ring-color: v-bind('THEME_COLORS.dark.ring');\n --ms-color: v-bind('THEME_COLORS.dark.dropdownText'); /* typed search text; base is dark #1f2937 → invisible on dark bg */\n\n /* Options */\n --ms-option-bg-pointed: v-bind('THEME_COLORS.dark.optionPointed');\n --ms-option-color-pointed: v-bind('THEME_COLORS.dark.optionPointedText');\n --ms-option-bg-selected: v-bind('THEME_COLORS.dark.optionSelected');\n --ms-option-color-selected: v-bind('THEME_COLORS.dark.optionSelectedText');\n\n /* Dropdown */\n --ms-dropdown-bg: v-bind('THEME_COLORS.dark.dropdownBackground');\n --ms-dropdown-border-color: v-bind('THEME_COLORS.dark.dropdownBorder');\n --ms-dropdown-color: v-bind('THEME_COLORS.dark.dropdownText');\n}\n\n:deep(.lang-flag) {\n width: 20px;\n height: 15px;\n border: 1px solid v-bind('COLORS.border.flag');\n border-radius: 2px;\n object-fit: cover;\n flex-shrink: 0;\n display: block;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n\n.is-dark :deep(.lang-flag) {\n border-color: v-bind('COLORS.border.dark');\n}\n\n:deep(.multiselect-single-label),\n:deep(.multiselect-option) {\n display: flex !important;\n align-items: center !important;\n min-width: 0 !important;\n width: 100% !important;\n padding: 4px 8px !important;\n gap: 8px !important;\n white-space: nowrap !important;\n overflow: hidden !important;\n flex-wrap: nowrap !important;\n}\n\n:deep(.multiselect-option) {\n text-align: left !important;\n transition: background-color 0.15s ease !important;\n white-space: nowrap !important;\n overflow: hidden !important;\n flex-wrap: nowrap !important;\n}\n\n:deep(.language-text) {\n display: inline-block !important;\n min-width: 0 !important;\n white-space: nowrap !important;\n overflow: hidden !important;\n text-overflow: ellipsis !important;\n flex: 1 !important;\n line-height: 1.4 !important;\n color: v-bind('THEME_COLORS.light.dropdownText') !important;\n word-break: keep-all !important;\n word-wrap: normal !important;\n word-spacing: normal !important;\n max-width: 100% !important;\n}\n\n.is-dark :deep(.language-text) {\n color: v-bind('THEME_COLORS.dark.dropdownText') !important;\n}\n\n:deep(.native-name) {\n color: v-bind('COLORS.text.secondary');\n margin-left: 0.5rem;\n font-size: 0.9em;\n white-space: nowrap !important;\n display: inline !important;\n}\n\n.is-dark :deep(.native-name) {\n color: v-bind('COLORS.neutral.gray400');\n}\n\n:deep(.multiselect) {\n width: 100%;\n min-width: 200px;\n color: v-bind('THEME_COLORS.light.dropdownText');\n position: relative;\n z-index: 1;\n}\n\n.is-dark :deep(.multiselect) {\n color: v-bind('THEME_COLORS.dark.dropdownText');\n}\n\n/* Lift the open select above sibling selects (each is its own z-index:1 stacking context) */\n:deep(.multiselect.is-active) {\n z-index: 2;\n}\n\n:deep(.multiselect-dropdown) {\n padding: 2px 0 !important;\n z-index: 1000 !important;\n}\n\n:deep(.multiselect-dropdown .multiselect-option) {\n padding: 4px 8px !important;\n margin: 0 !important;\n white-space: nowrap !important;\n overflow: hidden !important;\n flex-wrap: nowrap !important;\n}\n\n:deep(.multiselect-dropdown .language-text) {\n white-space: nowrap !important;\n overflow: hidden !important;\n text-overflow: ellipsis !important;\n word-break: keep-all !important;\n word-wrap: normal !important;\n word-spacing: normal !important;\n max-width: 100% !important;\n}\n\n:deep(.multiselect-no-results),\n:deep(.multiselect-no-options) {\n padding: 0.5rem 0.875rem;\n color: v-bind('COLORS.text.secondary');\n}\n\n.is-dark :deep(.multiselect-no-results),\n.is-dark :deep(.multiselect-no-options) {\n color: v-bind('COLORS.neutral.gray400');\n}\n</style>\n","<template>\n <div class=\"interface-language-select-wrapper\">\n <LanguageSelect\n :key=\"`interface-lang-${effectiveLanguages.length}-${effectiveLanguages[0]?.code || 'empty'}`\"\n :model-value=\"currentLanguageObject\"\n :languages=\"effectiveLanguages\"\n :placeholder=\"props.placeholder\"\n :disabled=\"props.disabled || isChangingLanguage\"\n :is-dark=\"props.isDark\"\n @update:model-value=\"handleLanguageChange\"\n />\n\n <!-- Loading overlay -->\n <div\n v-if=\"isChangingLanguage\"\n class=\"language-change-loader\"\n :class=\"{ 'is-dark': props.isDark }\"\n >\n <div class=\"loader-spinner\"></div>\n <span class=\"loader-text\">{{ lr('Changing language...', 'ui') }}</span>\n </div>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, watch, onMounted, ref } from 'vue'\nimport LanguageSelect from './LanguageSelect.vue'\nimport { useLangie } from '../useLangie'\nimport type { TranslatorLanguage } from '../types'\nimport { getCountryCode } from '../utils/getCountryCode'\n\n// Loading state for language change\nconst isChangingLanguage = ref(false)\n\nconst props = defineProps({\n placeholder: {\n type: String,\n default: 'Select interface language'\n },\n disabled: {\n type: Boolean,\n default: false\n },\n isDark: {\n type: Boolean,\n default: false\n },\n translatorHost: {\n type: String,\n default: ''\n },\n apiKey: {\n type: String,\n default: ''\n },\n languages: {\n type: Array as () => TranslatorLanguage[],\n default: () => []\n }\n})\n\nconst emit = defineEmits(['update:modelValue'])\n\n// Use the same SDK instance as the main app\nconst { availableLanguages, currentLanguage, setLanguage, fetchLanguages, lr } = useLangie()\n\n// Use provided languages if available, otherwise use SDK's languages\nconst effectiveLanguages = computed(() => {\n const languages =\n props.languages && props.languages.length > 0 ? props.languages : availableLanguages.value\n\n // Ensure all languages have required fields to prevent jumping\n const processed = languages.map((lang) => ({\n ...lang,\n native_name: lang.native_name || lang.name,\n flag_country: lang.flag_country || lang.code\n }))\n\n // Important: Keep API order (by popularity from language_popularity table)\n // Do NOT sort alphabetically - multiselect may reorder, but we preserve source order\n return processed\n})\n\nconst currentLanguageObject = computed(() => {\n if (!currentLanguage.value) return null\n return effectiveLanguages.value.find((lang) => lang.code === currentLanguage.value) || null\n})\n\n// Function to detect browser language from available languages\nfunction detectBrowserLanguage(languages: TranslatorLanguage[]): string | null {\n if (languages.length === 0) return null\n\n // Get browser languages in order of preference\n const browserLanguages = navigator.languages || [navigator.language || 'en']\n\n // Try to find exact match first\n for (const browserLang of browserLanguages) {\n const langCode = browserLang.toLowerCase().split('-')[0] // Extract language code (e.g., 'en' from 'en-US')\n const exactMatch = languages.find((lang) => lang.code.toLowerCase() === langCode)\n if (exactMatch) {\n return exactMatch.code\n }\n }\n\n // If no exact match, try with full locale (e.g., 'en-US')\n for (const browserLang of browserLanguages) {\n const fullLangCode = browserLang.toLowerCase()\n const localeMatch = languages.find((lang) => lang.code.toLowerCase() === fullLangCode)\n if (localeMatch) {\n return localeMatch.code\n }\n }\n\n return null\n}\n\nasync function handleLanguageChange(selectedLanguage: TranslatorLanguage | null) {\n if (selectedLanguage) {\n // Start loading\n isChangingLanguage.value = true\n\n try {\n setLanguage(selectedLanguage.code)\n // Save to localStorage when user manually changes language\n localStorage.setItem('interface_language', selectedLanguage.code)\n emit('update:modelValue', selectedLanguage)\n } finally {\n // End loading\n isChangingLanguage.value = false\n }\n }\n}\n\n// Watch for current language changes to save to localStorage\nwatch(currentLanguage, (newLangCode) => {\n if (newLangCode) {\n localStorage.setItem('interface_language', newLangCode)\n }\n})\n\n// Watch for changes in effective languages to set browser language\nwatch(\n () => effectiveLanguages.value,\n (newLanguages) => {\n if (newLanguages.length > 0 && !currentLanguage.value) {\n // Only set browser language if no language is currently selected\n const savedLanguageCode = localStorage.getItem('interface_language')\n\n if (savedLanguageCode) {\n // Check if saved language exists in the provided languages\n const savedLangExists = newLanguages.find((lang) => lang.code === savedLanguageCode)\n if (savedLangExists) {\n setLanguage(savedLanguageCode)\n return\n }\n }\n\n // If no saved language or saved language doesn't exist, detect browser language\n const browserLang = detectBrowserLanguage(newLanguages)\n if (browserLang) {\n setLanguage(browserLang)\n }\n }\n },\n { immediate: true }\n)\n\n// Load saved language from localStorage on initialization\nonMounted(async () => {\n // Only fetch languages if not provided via props\n if (!props.languages || props.languages.length === 0) {\n // Get country code from browser for better language ordering\n const countryCode = await getCountryCode()\n await fetchLanguages({ country: countryCode || undefined })\n }\n\n const savedLanguageCode = localStorage.getItem('interface_language')\n\n if (savedLanguageCode && savedLanguageCode !== currentLanguage.value) {\n // Check if saved language exists in current languages\n const currentLanguages = effectiveLanguages.value\n const savedLangExists = currentLanguages.find((lang) => lang.code === savedLanguageCode)\n\n if (savedLangExists) {\n setLanguage(savedLanguageCode)\n } else if (currentLanguages.length > 0) {\n // If saved language doesn't exist, try to detect browser language\n const browserLang = detectBrowserLanguage(currentLanguages)\n if (browserLang) {\n setLanguage(browserLang)\n }\n }\n } else if (!currentLanguage.value && effectiveLanguages.value.length > 0) {\n // If no saved language and no current language, detect browser language\n const browserLang = detectBrowserLanguage(effectiveLanguages.value)\n if (browserLang) {\n setLanguage(browserLang)\n }\n }\n})\n\n// Emit the current language when it changes\nwatch(\n currentLanguageObject,\n (newValue) => {\n if (newValue) {\n emit('update:modelValue', newValue)\n }\n },\n { immediate: true }\n)\n</script>\n\n<style scoped>\n.interface-language-select-wrapper {\n position: relative;\n display: inline-block;\n width: 100%;\n min-width: 200px;\n}\n\n/* Override styles for the language select component */\n.interface-language-select-wrapper :deep(.multiselect-option) {\n padding: 5px 10px !important;\n margin: 0;\n border-radius: 0;\n}\n\n.interface-language-select-wrapper :deep(.multiselect-option > div) {\n padding: 0 !important;\n margin: 0;\n}\n\n.interface-language-select-wrapper :deep(.multiselect-dropdown) {\n padding: 2px 0 !important;\n}\n\n.language-change-loader {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(255, 255, 255, 0.95);\n border-radius: 6px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n z-index: 10;\n backdrop-filter: blur(2px);\n border: 1px solid rgba(0, 0, 0, 0.1);\n}\n\n.language-change-loader.is-dark {\n background: rgba(30, 30, 30, 0.95);\n border-color: rgba(255, 255, 255, 0.1);\n}\n\n.loader-spinner {\n width: 24px;\n height: 24px;\n border: 3px solid #e5e7eb;\n border-top: 3px solid #3b82f6;\n border-radius: 50%;\n animation: spin 1s linear infinite;\n margin-bottom: 8px;\n}\n\n.language-change-loader.is-dark .loader-spinner {\n border-color: #374151;\n border-top-color: #3b82f6;\n}\n\n.loader-text {\n font-size: 12px;\n color: #6b7280;\n font-weight: 500;\n text-align: center;\n}\n\n.language-change-loader.is-dark .loader-text {\n color: #9ca3af;\n}\n\n@keyframes spin {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n}\n</style>\n","<template>\n <transition name=\"fade\" mode=\"out-in\">\n <span :key=\"translated\">{{ translated }}</span>\n </transition>\n</template>\n\n<script lang=\"ts\" setup>\nimport { computed, useSlots, watch } from 'vue'\nimport { ref, nextTick } from 'vue'\nimport { useLangie } from '../useLangie'\n\n// Define component name for better debugging\n// Component name for Vue devtools\n// Note: defineOptions requires Vue 3.3+, but we support Vue 3.2+\n// The component will work without explicit name\n\n// Detect if we're running in Nuxt\nconst isNuxt = computed(() => {\n if (typeof window !== 'undefined') {\n return !!(window as any).__NUXT__\n }\n if (typeof process !== 'undefined') {\n return !!(process.env as any).NUXT_SSR_BASE || !!(process.env as any).NUXT_PUBLIC_BASE_URL\n }\n return false\n})\n\nconst props = defineProps({\n // Message key (optional, otherwise slot content is used)\n msg: {\n type: String,\n default: undefined\n },\n // Translation context shorthand\n ctx: {\n type: String,\n required: false,\n default: 'ui'\n },\n // Original language shorthand\n orig: {\n type: String,\n required: false,\n default: undefined\n }\n})\n\nconst slots = useSlots()\nconst { lr, peekTranslation, currentLanguage, uiTranslations, translations, getLtDefaults } =\n useLangie()\n\nconst keyStr = computed(() => {\n if (props.msg) return props.msg\n const slotContent = slots.default\n ? slots\n .default()\n .map((n) => n.children)\n .join('')\n : ''\n return (slotContent || '').trim()\n})\n\n// Force component update when translations change\nconst forceUpdate = ref(0)\nwatch(\n [uiTranslations, translations],\n () => {\n // Small delay to ensure translations are cached before updating\n nextTick(() => {\n forceUpdate.value++\n })\n },\n { deep: true }\n)\n\nconst translated = computed(() => {\n // Get global defaults\n const globalDefaults = getLtDefaults()\n\n // Use props or fall back to global defaults\n const effectiveCtx = props.ctx ?? globalDefaults.ctx\n const effectiveOrig = props.orig ?? globalDefaults.orig\n\n // On the server emit a seeded translation when the host pre-populated one (no\n // English flash, no CLS); on a cold miss render the key (invisible reserver,\n // fade-in after mount). peekTranslation never queues — a doomed SSR request is\n // pointless. The client seeds the same map, so hydration matches on hits.\n if (isNuxt.value && typeof window === 'undefined') {\n return peekTranslation(keyStr.value, effectiveCtx)\n }\n\n // Force reactivity by depending on currentLanguage and translation caches\n void currentLanguage.value\n void forceUpdate.value // Force recomputation when translations change\n // Access properties to create reactive dependencies\n const cacheKey = `${keyStr.value}|${effectiveCtx}`\n const cache = effectiveCtx === 'ui' ? uiTranslations : translations\n void cache[cacheKey] // This creates a reactive dependency\n\n // For other frameworks, always translate\n const result = lr(keyStr.value, effectiveCtx, effectiveOrig)\n return result\n})\n</script>\n\n<style scoped>\n.fade-enter-active,\n.fade-leave-active {\n transition: opacity 0.25s ease;\n}\n.fade-enter-from,\n.fade-leave-to {\n opacity: 0;\n}\n</style>\n"],"mappings":"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","names":[]}
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * langie v2.0.2
2
+ * langie v2.0.4
3
3
  * (c) 2026 nlit
4
4
  * @license Apache-2.0
5
5
  *
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  // src/useLangie.ts
11
- import { watch } from "vue";
11
+ import { watch as watch2 } from "vue";
12
12
 
13
13
  // src/constants.ts
14
14
  var DEFAULT_API_HOST = "https://api.langie.uk/v1";
@@ -221,7 +221,7 @@ async function fetchAvailableLanguages(options = {}) {
221
221
  }
222
222
 
223
223
  // src/composables/useLangie-core.ts
224
- import { ref, reactive } from "vue";
224
+ import { ref, reactive, watch } from "vue";
225
225
 
226
226
  // src/utils/debug.ts
227
227
  function devDebug(...args) {
@@ -561,6 +561,13 @@ function useLangieCore(options = {}) {
561
561
  }).catch(() => {
562
562
  });
563
563
  };
564
+ watch(
565
+ [() => availableLanguages.value.length, currentLanguage],
566
+ ([count, lang]) => {
567
+ if (count) applyLocalizedNames(lang);
568
+ },
569
+ { immediate: true }
570
+ );
564
571
  const setLanguage = (lang) => {
565
572
  if (lang && lang !== currentLanguage.value) {
566
573
  }
@@ -1079,7 +1086,7 @@ function createLangieInstance(options = {}) {
1079
1086
  };
1080
1087
  loadCachedTranslations();
1081
1088
  loadCachedLanguages();
1082
- watch(currentLanguage2, () => {
1089
+ watch2(currentLanguage2, () => {
1083
1090
  Object.keys(translations2).forEach((key) => delete translations2[key]);
1084
1091
  Object.keys(uiTranslations2).forEach((key) => delete uiTranslations2[key]);
1085
1092
  loadCachedTranslations();
@@ -1362,7 +1369,7 @@ function createLangieInstance(options = {}) {
1362
1369
  isLoading.value = false;
1363
1370
  }
1364
1371
  };
1365
- watch(currentLanguage2, () => {
1372
+ watch2(currentLanguage2, () => {
1366
1373
  recentlyQueued.clear();
1367
1374
  loadCachedTranslations();
1368
1375
  batching.cleanup();
@@ -1985,6 +1992,14 @@ function displayNative(name = "", native = "") {
1985
1992
  return name !== native && TRAILING_PAREN.test(name) && TRAILING_PAREN.test(native) ? native.replace(TRAILING_PAREN, "") : native;
1986
1993
  }
1987
1994
 
1995
+ // src/utils/languageLabel.ts
1996
+ function languageLabel(lang) {
1997
+ const primary = lang.search_alias || lang.name || "";
1998
+ const native = lang.native_name || "";
1999
+ if (!native || native === primary) return { primary, native: "" };
2000
+ return { primary, native: displayNative(primary, native) };
2001
+ }
2002
+
1988
2003
  // src/utils/flagUrl.ts
1989
2004
  var isReadyFlagUrl = (flagCode) => flagCode.startsWith("/") || /^https?:\/\//i.test(flagCode);
1990
2005
  var flagImageUrl = (flagCode) => isReadyFlagUrl(flagCode) ? flagCode : `/flags/${flagCode}.svg`;
@@ -2347,20 +2362,20 @@ var LanguageSelect_default = /* @__PURE__ */ _defineComponent({
2347
2362
  key: selectedLanguageKey.value,
2348
2363
  src: getFlagImageUrl(value),
2349
2364
  class: "lang-flag",
2350
- alt: `${value.native_name || value.name} flag`,
2365
+ alt: `${_unref(languageLabel)(value).primary} flag`,
2351
2366
  "data-custom-flag": _unref(isReadyFlagUrl)(getFlagCode(value)) || void 0,
2352
2367
  onError: onFlagError
2353
2368
  }, null, 40, _hoisted_2)) : _createCommentVNode("v-if", true),
2354
2369
  _createElementVNode("span", _hoisted_3, [
2355
2370
  _createTextVNode(
2356
- _toDisplayString(value.name) + " ",
2371
+ _toDisplayString(_unref(languageLabel)(value).primary) + " ",
2357
2372
  1
2358
2373
  /* TEXT */
2359
2374
  ),
2360
- value.native_name && value.native_name !== value.name ? (_openBlock(), _createElementBlock(
2375
+ _unref(languageLabel)(value).native ? (_openBlock(), _createElementBlock(
2361
2376
  "span",
2362
2377
  _hoisted_4,
2363
- "(" + _toDisplayString(_unref(displayNative)(value.name, value.native_name)) + ")",
2378
+ "(" + _toDisplayString(_unref(languageLabel)(value).native) + ")",
2364
2379
  1
2365
2380
  /* TEXT */
2366
2381
  )) : _createCommentVNode("v-if", true)
@@ -2376,20 +2391,20 @@ var LanguageSelect_default = /* @__PURE__ */ _defineComponent({
2376
2391
  key: `${option.code}-${option.flag_country || "fallback"}`,
2377
2392
  src: getFlagImageUrl(option),
2378
2393
  class: "lang-flag",
2379
- alt: `${option.name} flag`,
2394
+ alt: `${_unref(languageLabel)(option).primary} flag`,
2380
2395
  "data-custom-flag": _unref(isReadyFlagUrl)(getFlagCode(option)) || void 0,
2381
2396
  onError: onFlagError
2382
2397
  }, null, 40, _hoisted_6)),
2383
2398
  _createElementVNode("span", _hoisted_7, [
2384
2399
  _createTextVNode(
2385
- _toDisplayString(option.name) + " ",
2400
+ _toDisplayString(_unref(languageLabel)(option).primary) + " ",
2386
2401
  1
2387
2402
  /* TEXT */
2388
2403
  ),
2389
- option.native_name && option.native_name !== option.name ? (_openBlock(), _createElementBlock(
2404
+ _unref(languageLabel)(option).native ? (_openBlock(), _createElementBlock(
2390
2405
  "span",
2391
2406
  _hoisted_8,
2392
- "(" + _toDisplayString(_unref(displayNative)(option.name, option.native_name)) + ")",
2407
+ "(" + _toDisplayString(_unref(languageLabel)(option).native) + ")",
2393
2408
  1
2394
2409
  /* TEXT */
2395
2410
  )) : _createCommentVNode("v-if", true)
@@ -2447,7 +2462,7 @@ LanguageSelect_default.__scopeId = "data-v-5b09cc17";
2447
2462
  // vue-script:/builds/codelits/langie-sdk/src/components/InterfaceLanguageSelect.vue?type=script
2448
2463
  import { defineComponent as _defineComponent2 } from "vue";
2449
2464
  import { openBlock as _openBlock2, createBlock as _createBlock2, createCommentVNode as _createCommentVNode2, createElementVNode as _createElementVNode2, unref as _unref2, toDisplayString as _toDisplayString2, normalizeClass as _normalizeClass2, createElementBlock as _createElementBlock2 } from "vue";
2450
- import { computed as computed2, watch as watch2, onMounted, ref as ref3 } from "vue";
2465
+ import { computed as computed2, watch as watch3, onMounted, ref as ref3 } from "vue";
2451
2466
  var _hoisted_12 = { class: "interface-language-select-wrapper" };
2452
2467
  var _hoisted_22 = { class: "loader-text" };
2453
2468
  var InterfaceLanguageSelect_default = /* @__PURE__ */ _defineComponent2({
@@ -2528,12 +2543,12 @@ var InterfaceLanguageSelect_default = /* @__PURE__ */ _defineComponent2({
2528
2543
  }
2529
2544
  }
2530
2545
  }
2531
- watch2(currentLanguage2, (newLangCode) => {
2546
+ watch3(currentLanguage2, (newLangCode) => {
2532
2547
  if (newLangCode) {
2533
2548
  localStorage.setItem("interface_language", newLangCode);
2534
2549
  }
2535
2550
  });
2536
- watch2(
2551
+ watch3(
2537
2552
  () => effectiveLanguages.value,
2538
2553
  (newLanguages) => {
2539
2554
  if (newLanguages.length > 0 && !currentLanguage2.value) {
@@ -2577,7 +2592,7 @@ var InterfaceLanguageSelect_default = /* @__PURE__ */ _defineComponent2({
2577
2592
  }
2578
2593
  }
2579
2594
  });
2580
- watch2(
2595
+ watch3(
2581
2596
  currentLanguageObject,
2582
2597
  (newValue) => {
2583
2598
  if (newValue) {
@@ -2635,7 +2650,7 @@ InterfaceLanguageSelect_default.__scopeId = "data-v-258d337a";
2635
2650
  // vue-script:/builds/codelits/langie-sdk/src/components/lt.vue?type=script
2636
2651
  import { defineComponent as _defineComponent3 } from "vue";
2637
2652
  import { toDisplayString as _toDisplayString3, openBlock as _openBlock3, createElementBlock as _createElementBlock3, Transition as _Transition, withCtx as _withCtx2, createBlock as _createBlock3 } from "vue";
2638
- import { computed as computed3, useSlots, watch as watch3 } from "vue";
2653
+ import { computed as computed3, useSlots, watch as watch4 } from "vue";
2639
2654
  import { ref as ref4, nextTick as nextTick2 } from "vue";
2640
2655
  var lt_default = /* @__PURE__ */ _defineComponent3({
2641
2656
  __name: "lt",
@@ -2677,7 +2692,7 @@ var lt_default = /* @__PURE__ */ _defineComponent3({
2677
2692
  return (slotContent || "").trim();
2678
2693
  });
2679
2694
  const forceUpdate = ref4(0);
2680
- watch3(
2695
+ watch4(
2681
2696
  [uiTranslations2, translations2],
2682
2697
  () => {
2683
2698
  nextTick2(() => {