diff --git a/.gitignore b/.gitignore index f433d067efa..d2a67de2be5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ __screenshots__/ .vercel .now + +# Superpowers +docs/superpowers/ diff --git a/packages/docs/src/pages/en/getting-started/upgrade-guide.md b/packages/docs/src/pages/en/getting-started/upgrade-guide.md index b9f5a13974a..06944530202 100644 --- a/packages/docs/src/pages/en/getting-started/upgrade-guide.md +++ b/packages/docs/src/pages/en/getting-started/upgrade-guide.md @@ -17,6 +17,32 @@ This page contains a detailed list of breaking changes and the steps required to +## Locale + +Vuetify's locale system is now powered by `@vuetify/v0` under the hood. The consumer-facing API (`useLocale`, `useRtl`, `VLocaleProvider`) is unchanged for the majority of users. If you only use `t()`, `n()`, `current`, `isRtl`, `rtlClasses`, or `decimalSeparator`, no changes are needed. + +### LocaleInstance + +Several properties have been removed from the `LocaleInstance` type: + +- `provide()` — use `VLocaleProvider` or the `provideLocale()` composable instead +- `name` — adapter identity is no longer exposed +- `messages` — messages are managed internally; register them via `createVuetify({ locale: { messages } })` +- `fallback` — configure at creation time via `createVuetify({ locale: { fallback: 'en' } })` + +```diff + const locale = useLocale() + +- const scoped = locale.provide({ locale: 'fr' }) +- console.log(locale.name) // 'vuetify' +- console.log(locale.messages.value) +- console.log(locale.fallback.value) +``` + +### vue-i18n adapter + +The vue-i18n adapter continues to work with the same import path and configuration. No changes required. + ## `useDisplay` - Returned Refs are now readonly. diff --git a/packages/vuetify/src/composables/locale.ts b/packages/vuetify/src/composables/locale.ts index 975747f08c8..6d001750ad2 100644 --- a/packages/vuetify/src/composables/locale.ts +++ b/packages/vuetify/src/composables/locale.ts @@ -1,9 +1,11 @@ // Utilities -import { computed, inject, provide, ref, toRef } from 'vue' -import { createVuetifyAdapter } from '@/locale/adapters/vuetify' +import { createLocale as createV0Locale, createRtl as createV0Rtl, isFunction } from '@vuetify/v0' +import { computed, inject, provide, ref, shallowRef, toRef, watch } from 'vue' +import en from '@/locale/en' // Types -import type { InjectionKey, Ref, ShallowRef } from 'vue' +import type { LocaleContext, RtlContext } from '@vuetify/v0' +import type { InjectionKey, Ref } from 'vue' export interface LocaleMessages { [key: string]: LocaleMessages | string @@ -11,61 +13,19 @@ export interface LocaleMessages { export interface LocaleOptions { decimalSeparator?: string - messages?: LocaleMessages + messages?: Record locale?: string fallback?: string adapter?: LocaleInstance } export interface LocaleInstance { - name: string - decimalSeparator: ShallowRef - messages: Ref current: Ref - fallback: Ref t: (key: string, ...params: unknown[]) => string - n: (value: number) => string - provide: (props: LocaleOptions) => LocaleInstance + n: (value: number, options?: Intl.NumberFormatOptions) => string + decimalSeparator: Ref } -export const LocaleSymbol: InjectionKey = Symbol.for('vuetify:locale') - -function isLocaleInstance (obj: any): obj is LocaleInstance { - return obj.name != null -} - -export function createLocale (options?: LocaleOptions & RtlOptions) { - const i18n = options?.adapter && isLocaleInstance(options?.adapter) ? options?.adapter : createVuetifyAdapter(options) - const rtl = createRtl(i18n, options) - - return { ...i18n, ...rtl } -} - -export function useLocale () { - const locale = inject(LocaleSymbol) - - if (!locale) throw new Error('[Vuetify] Could not find injected locale instance') - - return locale -} - -export function provideLocale (props: LocaleOptions & RtlProps) { - const locale = inject(LocaleSymbol) - - if (!locale) throw new Error('[Vuetify] Could not find injected locale instance') - - const i18n = locale.provide(props) - const rtl = provideRtl(i18n, locale.rtl, props) - - const data = { ...i18n, ...rtl } - - provide(LocaleSymbol, data) - - return data -} - -// RTL - export interface RtlOptions { rtl?: Record } @@ -80,8 +40,19 @@ export interface RtlInstance { rtlClasses: Ref } +/** @internal */ +export interface InternalLocaleData { + _messages: Record + _fallback: string +} + +export interface FullLocaleInstance extends LocaleInstance, RtlInstance, InternalLocaleData {} + +export const LocaleSymbol: InjectionKey = Symbol.for('vuetify:locale') export const RtlSymbol: InjectionKey = Symbol.for('vuetify:rtl') +const LANG_PREFIX = '$vuetify.' + function genDefaults () { return { af: false, @@ -129,18 +100,144 @@ function genDefaults () { } } -export function createRtl (i18n: LocaleInstance, options?: RtlOptions): RtlInstance { +function createLocaleInstance ( + v0Locale: LocaleContext, + options?: { decimalSeparator?: string } +): LocaleInstance { + const current = computed({ + get: () => String(v0Locale.selectedId.value ?? 'en'), + set: v => v0Locale.select(v), + }) + + function t (key: string, ...params: unknown[]): string { + const stripped = key.startsWith(LANG_PREFIX) ? key.slice(LANG_PREFIX.length) : key + return v0Locale.t(stripped, ...params) + } + + function n (value: number, options?: Intl.NumberFormatOptions): string { + if (options) { + return new Intl.NumberFormat([current.value], options).format(value) + } + return v0Locale.n(value) + } + + const decimalSeparator = toRef(() => { + if (options?.decimalSeparator) return options.decimalSeparator + const formatted = n(0.1) + return formatted.includes(',') ? ',' : '.' + }) + + return { + current, + t, + n, + decimalSeparator, + } +} + +function createRtlInstance ( + locale: LocaleInstance, + rtlMap: Ref>, + v0Rtl: RtlContext +): RtlInstance { + const isRtl = shallowRef(v0Rtl.isRtl.value) + + watch(() => locale.current.value, current => { + const value = rtlMap.value[current] ?? false + v0Rtl.isRtl.value = value + isRtl.value = value + }, { immediate: true }) + + return { + isRtl, + rtl: rtlMap, + rtlClasses: toRef(() => `v-locale--is-${isRtl.value ? 'rtl' : 'ltr'}`), + } +} + +export function createLocale (options?: LocaleOptions & RtlOptions) { + if (options?.adapter) { + const rtl = createRtlFromAdapter(options.adapter, options) + return { ...options.adapter, ...rtl } + } + + const messages = { en, ...options?.messages } + const defaultLocale = options?.locale ?? 'en' + const fallback = options?.fallback ?? 'en' + + const v0Locale = createV0Locale({ + default: defaultLocale, + fallback, + messages, + }) + + const v0Rtl = createV0Rtl({ + default: false, + target: null, + }) + + const rtlMap = ref>(options?.rtl ?? genDefaults()) + const locale = createLocaleInstance(v0Locale, options) + const rtl = createRtlInstance(locale, rtlMap, v0Rtl) + + return { ...locale, ...rtl, _messages: messages, _fallback: fallback } satisfies FullLocaleInstance +} + +function createRtlFromAdapter (adapter: LocaleInstance, options?: RtlOptions): RtlInstance { const rtl = ref>(options?.rtl ?? genDefaults()) - const isRtl = computed(() => rtl.value[i18n.current.value] ?? false) + const isRtl = computed(() => rtl.value[adapter.current.value] ?? false) return { isRtl, rtl, rtlClasses: toRef(() => `v-locale--is-${isRtl.value ? 'rtl' : 'ltr'}`), + } satisfies RtlInstance +} + +export function useLocale () { + const locale = inject(LocaleSymbol) + + if (!locale) throw new Error('[Vuetify] Could not find injected locale instance') + + return locale +} + +export function provideLocale (props: LocaleOptions & RtlProps) { + const parent = inject(LocaleSymbol) + + if (!parent) throw new Error('[Vuetify] Could not find injected locale instance') + + if ('provide' in parent && isFunction(parent.provide)) { + const i18n = parent.provide(props) + const rtl = provideRtl(i18n, parent.rtl, props) + const data = { ...i18n, ...rtl } + provide(LocaleSymbol, data) + return data } + + const parentData = parent + const parentMessages = parentData._messages ?? {} + const parentFallback = parentData._fallback ?? 'en' + const messages = props.messages + ? { ...parentMessages, [props.locale ?? parent.current.value]: props.messages } + : parentMessages + const fallback = props.fallback ?? parentFallback + + const v0Locale = createV0Locale({ + default: props.locale ?? parent.current.value, + fallback, + messages, + }) + + const locale = createLocaleInstance(v0Locale, props) + const rtl = provideRtl(locale, parent.rtl, props) + + const data = { ...locale, ...rtl, _messages: messages, _fallback: fallback } satisfies FullLocaleInstance + provide(LocaleSymbol, data) + return data } -export function provideRtl (locale: LocaleInstance, rtl: RtlInstance['rtl'], props: RtlProps): RtlInstance { +function provideRtl (locale: LocaleInstance, rtl: RtlInstance['rtl'], props: RtlProps): RtlInstance { const isRtl = computed(() => props.rtl ?? rtl.value[locale.current.value] ?? false) return { diff --git a/packages/vuetify/src/locale/adapters/vue-i18n.ts b/packages/vuetify/src/locale/adapters/vue-i18n.ts index 762515d04fc..f9d22fdc810 100644 --- a/packages/vuetify/src/locale/adapters/vue-i18n.ts +++ b/packages/vuetify/src/locale/adapters/vue-i18n.ts @@ -9,6 +9,13 @@ import type { Ref } from 'vue' import type { I18n, useI18n } from 'vue-i18n' import type { LocaleInstance, LocaleMessages, LocaleOptions } from '@/composables/locale' +export interface VueI18nLocaleInstance extends LocaleInstance { + name: string + fallback: Ref + messages: Ref + provide: (props: LocaleOptions) => VueI18nLocaleInstance +} + type VueI18nAdapterParams = { i18n: I18n useI18n: typeof useI18n @@ -38,7 +45,7 @@ function createProvideFunction (data: { messages: Ref useI18n: typeof useI18n }) { - return (props: LocaleOptions): LocaleInstance => { + return (props: LocaleOptions): VueI18nLocaleInstance => { const current = useProvided(props, 'locale', data.current) const fallback = useProvided(props, 'fallback', data.fallback) const messages = useProvided(props, 'messages', data.messages) @@ -69,7 +76,7 @@ function createProvideFunction (data: { } } -export function createVueI18nAdapter ({ i18n, useI18n }: VueI18nAdapterParams): LocaleInstance { +export function createVueI18nAdapter ({ i18n, useI18n }: VueI18nAdapterParams): VueI18nLocaleInstance { const current = i18n.global.locale const fallback = i18n.global.fallbackLocale as Ref const messages = i18n.global.messages diff --git a/packages/vuetify/src/locale/adapters/vuetify.ts b/packages/vuetify/src/locale/adapters/vuetify.ts deleted file mode 100644 index ab860444064..00000000000 --- a/packages/vuetify/src/locale/adapters/vuetify.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Composables -import { useProxiedModel } from '@/composables/proxiedModel' - -// Utilities -import { ref, shallowRef, toRef, watch } from 'vue' -import { consoleError, consoleWarn, getObjectValueByPath } from '@/util' - -// Locales -import en from '@/locale/en' - -// Types -import type { Ref } from 'vue' -import type { LocaleInstance, LocaleMessages, LocaleOptions } from '@/composables/locale' - -const LANG_PREFIX = '$vuetify.' - -const replace = (str: string, params: unknown[]) => { - return str.replace(/\{(\d+)\}/g, (match: string, index: string) => { - return String(params[Number(index)]) - }) -} - -const createTranslateFunction = ( - current: Ref, - fallback: Ref, - messages: Ref, -) => { - return (key: string, ...params: unknown[]) => { - if (!key.startsWith(LANG_PREFIX)) { - return replace(key, params) - } - - const shortKey = key.replace(LANG_PREFIX, '') - const currentLocale = current.value && messages.value[current.value] - const fallbackLocale = fallback.value && messages.value[fallback.value] - - let str: string = getObjectValueByPath(currentLocale, shortKey, null) - - if (!str) { - consoleWarn(`Translation key "${key}" not found in "${current.value}", trying fallback locale`) - str = getObjectValueByPath(fallbackLocale, shortKey, null) - } - - if (!str) { - consoleError(`Translation key "${key}" not found in fallback`) - str = key - } - - if (typeof str !== 'string') { - consoleError(`Translation key "${key}" has a non-string value`) - str = key - } - - return replace(str, params) - } -} - -function createNumberFunction (current: Ref, fallback: Ref) { - return (value: number, options?: Intl.NumberFormatOptions) => { - const numberFormat = new Intl.NumberFormat([current.value, fallback.value], options) - - return numberFormat.format(value) - } -} - -function inferDecimalSeparator (current: Ref, fallback: Ref) { - const format = createNumberFunction(current, fallback) - return format(0.1).includes(',') ? ',' : '.' -} - -function useProvided (props: any, prop: string, provided: Ref) { - const internal = useProxiedModel(props, prop, props[prop] ?? provided.value) - - // TODO: Remove when defaultValue works - internal.value = props[prop] ?? provided.value - - watch(provided, v => { - if (props[prop] == null) { - internal.value = provided.value - } - }) - - return internal as Ref -} - -function createProvideFunction (state: { current: Ref, fallback: Ref, messages: Ref }) { - return (props: LocaleOptions): LocaleInstance => { - const current = useProvided(props, 'locale', state.current) - const fallback = useProvided(props, 'fallback', state.fallback) - const messages = useProvided(props, 'messages', state.messages) - - return { - name: 'vuetify', - current, - fallback, - messages, - decimalSeparator: toRef(() => inferDecimalSeparator(current, fallback)), - t: createTranslateFunction(current, fallback, messages), - n: createNumberFunction(current, fallback), - provide: createProvideFunction({ current, fallback, messages }), - } - } -} - -export function createVuetifyAdapter (options?: LocaleOptions): LocaleInstance { - const current = shallowRef(options?.locale ?? 'en') - const fallback = shallowRef(options?.fallback ?? 'en') - const messages = ref({ en, ...options?.messages }) - - return { - name: 'vuetify', - current, - fallback, - messages, - decimalSeparator: toRef(() => options?.decimalSeparator ?? inferDecimalSeparator(current, fallback)), - t: createTranslateFunction(current, fallback, messages), - n: createNumberFunction(current, fallback), - provide: createProvideFunction({ current, fallback, messages }), - } -}