feat(module): support i18n in components (#2553)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Alex
2024-11-08 21:22:57 +05:00
committed by GitHub
parent 1e7638bd03
commit 26362408b1
30 changed files with 673 additions and 18 deletions

View File

@@ -74,6 +74,7 @@ const emits = defineEmits<AlertEmits>()
const slots = defineSlots<AlertSlots>()
const appConfig = useAppConfig()
const { t } = useLocale()
const multiline = computed(() => !!props.title && !!props.description)
@@ -123,7 +124,7 @@ const ui = computed(() => alert({
size="md"
color="neutral"
variant="link"
aria-label="Close"
:aria-label="t('ui.alert.close')"
v-bind="typeof close === 'object' ? close : undefined"
:class="ui.close({ class: props.ui?.close })"
@click="emits('update:open', false)"

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import type { ConfigProviderProps, TooltipProviderProps } from 'radix-vue'
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
import type { ToasterProps } from '../types'
import type { ToasterProps, Locale } from '../types'
export interface AppProps extends Omit<ConfigProviderProps, 'useId'> {
tooltip?: TooltipProviderProps
toaster?: ToasterProps | null
locale?: Locale
}
export interface AppSlots {
@@ -26,6 +27,7 @@ import { reactivePick } from '@vueuse/core'
import UToaster from './Toaster.vue'
import UModalProvider from './ModalProvider.vue'
import USlideoverProvider from './SlideoverProvider.vue'
import { localeContextInjectionKey } from '../composables/useLocale'
const props = defineProps<AppProps>()
defineSlots<AppSlots>()
@@ -33,6 +35,8 @@ defineSlots<AppSlots>()
const configProviderProps = useForwardProps(reactivePick(props, 'dir', 'scrollBody'))
const tooltipProps = toRef(() => props.tooltip)
const toasterProps = toRef(() => props.toaster)
provide(localeContextInjectionKey, computed(() => props.locale))
</script>
<template>

View File

@@ -134,6 +134,7 @@ const props = withDefaults(defineProps<CarouselProps<T>>(), {
defineSlots<CarouselSlots<T>>()
const appConfig = useAppConfig()
const { t } = useLocale()
const rootProps = useForwardProps(reactivePick(props, 'active', 'align', 'breakpoints', 'containScroll', 'dragFree', 'dragThreshold', 'duration', 'inViewThreshold', 'loop', 'skipSnaps', 'slidesToScroll', 'startIndex', 'watchDrag', 'watchResize', 'watchSlides', 'watchFocus'))
const ui = computed(() => carousel({
@@ -279,7 +280,7 @@ defineExpose({
size="md"
color="neutral"
variant="outline"
aria-label="Prev"
:aria-label="t('ui.carousel.prev')"
v-bind="typeof prev === 'object' ? prev : undefined"
:class="ui.prev({ class: props.ui?.prev })"
@click="scrollPrev"
@@ -290,7 +291,7 @@ defineExpose({
size="md"
color="neutral"
variant="outline"
aria-label="Next"
:aria-label="t('ui.carousel.next')"
v-bind="typeof next === 'object' ? next : undefined"
:class="ui.next({ class: props.ui?.next })"
@click="scrollNext"
@@ -300,7 +301,7 @@ defineExpose({
<div v-if="dots" :class="ui.dots({ class: props.ui?.dots })">
<template v-for="(_, index) in scrollSnaps" :key="index">
<button
:aria-label="`Go to slide ${index + 1}`"
:aria-label="t('ui.carousel.goto', { slide: index + 1 })"
:class="ui.dot({ class: props.ui?.dot, active: selectedIndex === index })"
@click="scrollTo(index)"
/>

View File

@@ -144,6 +144,7 @@ const slots = defineSlots<CommandPaletteSlots<G, T>>()
const searchTerm = defineModel<string>('searchTerm', { default: '' })
const appConfig = useAppConfig()
const { t } = useLocale()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'disabled', 'multiple', 'modelValue', 'defaultValue', 'selectedValue', 'resetSearchTermOnBlur'), emits)
const inputProps = useForwardProps(reactivePick(props, 'loading', 'loadingIcon', 'placeholder'))
@@ -245,7 +246,7 @@ const groups = computed(() => {
size="md"
color="neutral"
variant="ghost"
aria-label="Close"
:aria-label="t('ui.commandPalette.close')"
v-bind="typeof close === 'object' ? close : undefined"
:class="ui.close({ class: props.ui?.close })"
@click="emits('update:open', false)"
@@ -259,7 +260,7 @@ const groups = computed(() => {
<ComboboxContent :class="ui.content({ class: props.ui?.content })" :dismissable="false">
<ComboboxEmpty :class="ui.empty({ class: props.ui?.empty })">
<slot name="empty" :search-term="searchTerm">
{{ searchTerm ? `No results for ${searchTerm}` : 'No results' }}
{{ searchTerm ? t('ui.commandPalette.noMatch', { searchTerm }) : t('ui.commandPalette.noData') }}
</slot>
</ComboboxEmpty>

View File

@@ -141,6 +141,7 @@ import { get, escapeRegExp } from '../utils'
import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'
import UChip from './Chip.vue'
import { useLocale } from '../composables/useLocale'
defineOptions({ inheritAttrs: false })
@@ -157,6 +158,7 @@ const slots = defineSlots<InputMenuSlots<T>>()
const searchTerm = defineModel<string>('searchTerm', { default: '' })
const appConfig = useAppConfig()
const { t } = useLocale()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'selectedValue', 'open', 'defaultOpen', 'resetSearchTermOnBlur'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, position: 'popper' }) as ComboboxContentProps)
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
@@ -347,7 +349,7 @@ defineExpose({
<ComboboxContent :class="ui.content({ class: props.ui?.content })" v-bind="contentProps">
<ComboboxEmpty :class="ui.empty({ class: props.ui?.empty })">
<slot name="empty" :search-term="searchTerm">
{{ searchTerm ? `No results for ${searchTerm}` : 'No results' }}
{{ searchTerm ? t('ui.inputMenu.noMatch', { searchTerm }) : t('ui.inputMenu.noData') }}
</slot>
</ComboboxEmpty>

View File

@@ -103,6 +103,7 @@ const contentEvents = computed(() => {
})
const appConfig = useAppConfig()
const { t } = useLocale()
const ui = computed(() => modal({
transition: props.transition,
@@ -143,7 +144,7 @@ const ui = computed(() => modal({
size="md"
color="neutral"
variant="ghost"
aria-label="Close"
:aria-label="t('ui.modal.close')"
v-bind="typeof close === 'object' ? close : undefined"
:class="ui.close({ class: props.ui?.close })"
/>

View File

@@ -147,6 +147,8 @@ const slots = defineSlots<SelectMenuSlots<T>>()
const searchTerm = defineModel<string>('searchTerm', { default: '' })
const appConfig = useAppConfig()
const { t } = useLocale()
const rootProps = useForwardPropsEmits(reactivePick(props, 'modelValue', 'defaultValue', 'selectedValue', 'open', 'defaultOpen', 'resetSearchTermOnBlur'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, position: 'popper' }) as ComboboxContentProps)
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
@@ -284,7 +286,7 @@ function onUpdateOpen(value: boolean) {
<ComboboxEmpty :class="ui.empty({ class: props.ui?.empty })">
<slot name="empty" :search-term="searchTerm">
{{ searchTerm ? `No results for ${searchTerm}` : 'No results' }}
{{ searchTerm ? t('ui.selectMenu.noMatch', { searchTerm }) : t('ui.selectMenu.noData') }}
</slot>
</ComboboxEmpty>

View File

@@ -102,6 +102,7 @@ const contentEvents = computed(() => {
})
const appConfig = useAppConfig()
const { t } = useLocale()
const ui = computed(() => slideover({
transition: props.transition,
@@ -142,7 +143,7 @@ const ui = computed(() => slideover({
size="md"
color="neutral"
variant="ghost"
aria-label="Close"
:aria-label="t('ui.slideover.close')"
v-bind="typeof close === 'object' ? close : undefined"
:class="ui.close({ class: props.ui?.close })"
/>

View File

@@ -114,6 +114,7 @@ import { upperFirst } from 'scule'
const props = defineProps<TableProps<T>>()
defineSlots<TableSlots<T>>()
const { t } = useLocale()
const data = computed(() => props.data ?? [])
const columns = computed<TableColumn<T>[]>(() => props.columns ?? Object.keys(data.value[0] ?? {}).map((accessorKey: string) => ({ accessorKey, header: upperFirst(accessorKey) })))
@@ -231,7 +232,7 @@ defineExpose({
<tr v-else :class="ui.tr({ class: [props.ui?.tr] })">
<td :colspan="columns?.length" :class="ui.empty({ class: props.ui?.empty })">
<slot name="empty">
No results
{{ t('ui.table.noData') }}
</slot>
</td>
</tr>

View File

@@ -74,6 +74,7 @@ const emits = defineEmits<ToastEmits>()
const slots = defineSlots<ToastSlots>()
const appConfig = useAppConfig()
const { t } = useLocale()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultOpen', 'open', 'duration', 'type'), emits)
const multiline = computed(() => !!props.title && !!props.description)
@@ -151,7 +152,7 @@ defineExpose({
size="md"
color="neutral"
variant="link"
aria-label="Close"
:aria-label="t('ui.toast.close')"
v-bind="typeof close === 'object' ? close : undefined"
:class="ui.close({ class: props.ui?.close })"
@click.stop

View File

@@ -0,0 +1,8 @@
import type { Locale, LocalePair } from '../types/locale'
export function defineLocale(name: string, pair: LocalePair): Locale {
return {
name,
ui: pair
}
}

View File

@@ -0,0 +1,13 @@
import { computed, inject, ref } from 'vue'
import type { InjectionKey, Ref } from 'vue'
import type { Locale } from '../types/locale'
import { buildLocaleContext } from '../utils/locale'
import { en } from '../locale'
export const localeContextInjectionKey: InjectionKey<Ref<Locale | undefined>> = Symbol('nuxt-ui.locale-context')
export const useLocale = (localeOverrides?: Ref<Locale | undefined>) => {
const locale = localeOverrides || inject(localeContextInjectionKey, ref())!
return buildLocaleContext(computed(() => locale.value || en))
}

37
src/runtime/locale/de.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineLocale } from '../composables/defineLocale'
export default defineLocale('Deutsch', {
inputMenu: {
noMatch: 'Nichts gefunden',
noData: 'Keine Daten'
},
commandPalette: {
noMatch: 'Nichts gefunden',
noData: 'Keine Daten',
close: 'Schließen'
},
selectMenu: {
noMatch: 'Nichts gefunden',
noData: 'Keine Daten'
},
toast: {
close: 'Schließen'
},
carousel: {
prev: 'Weiter',
next: 'Zurück',
goto: 'Gehe zu {slide}'
},
modal: {
close: 'Schließen'
},
slideover: {
close: 'Schließen'
},
alert: {
close: 'Schließen'
},
table: {
noData: 'Keine Daten'
}
})

37
src/runtime/locale/en.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineLocale } from '../composables/defineLocale'
export default defineLocale('English', {
inputMenu: {
noMatch: 'No matching data',
noData: 'No data'
},
commandPalette: {
noMatch: 'No matching data',
noData: 'No data',
close: 'Close'
},
selectMenu: {
noMatch: 'No matching data',
noData: 'No data'
},
toast: {
close: 'Close'
},
carousel: {
prev: 'Prev',
next: 'Next',
goto: 'Go to slide {slide}'
},
modal: {
close: 'Close'
},
slideover: {
close: 'Close'
},
alert: {
close: 'Close'
},
table: {
noData: 'No data'
}
})

37
src/runtime/locale/fr.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineLocale } from '../composables/defineLocale'
export default defineLocale('Français', {
inputMenu: {
noMatch: 'Aucune donnée correspondante',
noData: 'Aucune donnée'
},
commandPalette: {
noMatch: 'Aucune donnée correspondante',
noData: 'Aucune donnée',
close: 'Fermer'
},
selectMenu: {
noMatch: 'Aucune donnée correspondante',
noData: 'Aucune donnée'
},
toast: {
close: 'Fermer'
},
carousel: {
prev: 'Précédent',
next: 'Suivant',
goto: 'Aller à {slide}'
},
modal: {
close: 'Fermer'
},
slideover: {
close: 'Fermer'
},
alert: {
close: 'Fermer'
},
table: {
noData: 'Aucune donnée'
}
})

View File

@@ -0,0 +1,4 @@
export { default as de } from './de'
export { default as en } from './en'
export { default as fr } from './fr'
export { default as ru } from './ru'

37
src/runtime/locale/ru.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineLocale } from '../composables/defineLocale'
export default defineLocale('Русский', {
inputMenu: {
noMatch: 'Совпадений не найдено',
noData: 'Нет данных'
},
commandPalette: {
noMatch: 'Совпадений не найдено',
noData: 'Нет данных',
close: 'Закрыть'
},
selectMenu: {
noMatch: 'Совпадений не найдено',
noData: 'Нет данных'
},
toast: {
close: 'Закрыть'
},
carousel: {
prev: 'Назад',
next: 'Далее',
goto: 'Перейти к {slide}'
},
modal: {
close: 'Закрыть'
},
slideover: {
close: 'Закрыть'
},
alert: {
close: 'Закрыть'
},
table: {
noData: 'Нет данных'
}
})

View File

@@ -42,3 +42,4 @@ export * from '../components/Toast.vue'
export * from '../components/Toaster.vue'
export * from '../components/Tooltip.vue'
export * from './form'
export * from './locale'

View File

@@ -0,0 +1,40 @@
export type LocalePair = {
inputMenu: {
noMatch: string
noData: string
}
commandPalette: {
noMatch: string
noData: string
close: string
}
selectMenu: {
noMatch: string
noData: string
}
toast: {
close: string
}
carousel: {
prev: string
next: string
goto: string
}
modal: {
close: string
}
slideover: {
close: string
}
alert: {
close: string
}
table: {
noData: string
}
}
export type Locale = {
name: string
ui: LocalePair
}

View File

@@ -0,0 +1,37 @@
import type { Ref } from 'vue'
import type { Locale } from '../types/locale'
import type { MaybeRef } from '@vueuse/core'
import { computed, isRef, ref, unref } from 'vue'
import { get } from './index'
export type TranslatorOption = Record<string, string | number>
export type Translator = (path: string, option?: TranslatorOption) => string
export type LocaleContext = {
locale: Ref<Locale>
lang: Ref<string>
t: Translator
}
export function buildTranslator(locale: MaybeRef<Locale>): Translator {
return (path, option) => translate(path, option, unref(locale))
}
export function translate(path: string, option: undefined | TranslatorOption, locale: Locale): string {
const prop: string = get(locale, path, path)
return prop.replace(
/\{(\w+)\}/g,
(_, key) => `${option?.[key] ?? `{${key}}`}`
)
}
export function buildLocaleContext(locale: MaybeRef<Locale>): LocaleContext {
const lang = computed(() => unref(locale).name)
const localeRef = isRef(locale) ? locale : ref(locale)
return {
lang,
locale: localeRef,
t: buildTranslator(locale)
}
}