diff --git a/components/account/AccountAvatar.vue b/components/account/AccountAvatar.vue index 08c29fe4..f6310da9 100644 --- a/components/account/AccountAvatar.vue +++ b/components/account/AccountAvatar.vue @@ -12,7 +12,7 @@ const loaded = $ref(false) <img :key="account.avatar" :src="account.avatar" - :alt="account.username" + :alt="$t('account.avatar_description', [account.username])" loading="lazy" rounded-full :class="loaded ? 'bg-gray' : 'bg-gray:10'" diff --git a/components/account/AccountHeader.vue b/components/account/AccountHeader.vue index 6c0e35fd..d62c6750 100644 --- a/components/account/AccountHeader.vue +++ b/components/account/AccountHeader.vue @@ -6,6 +6,8 @@ const { account } = defineProps<{ command?: boolean }>() +const { t } = useI18n() + const createdAt = $(useFormattedDateTime(() => account.createdAt, { month: 'long', day: 'numeric', @@ -43,13 +45,16 @@ function getFieldNameIcon(fieldName: string) { if (fieldNameIcons[name]) return fieldNameIcons[name] } +function getFieldIconTitle(fieldName: string) { + return fieldName === 'Joined' ? t('account.joined') : fieldName +} function previewHeader() { openMediaPreview([{ id: `${account.acct}:header`, type: 'image', previewUrl: account.header, - description: `${account.username}'s profile header`, + description: t('account.profile_description', [account.username]), }]) } @@ -58,7 +63,7 @@ function previewAvatar() { id: `${account.acct}:avatar`, type: 'image', previewUrl: account.avatar, - description: `${account.username}'s avatar`, + description: t('account.avatar_description', [account.username]), }]) } @@ -86,7 +91,7 @@ watchEffect(() => { <template> <div flex flex-col> <button border="b base" z-1> - <img h-50 w-full object-cover :src="account.header" :alt="`${account.username}'s profile header`" @click="previewHeader"> + <img h-50 w-full object-cover :src="account.header" :alt="t('account.profile_description', [account.username])" @click="previewHeader"> </button> <div p4 mt--18 flex flex-col gap-4> <div relative> @@ -122,7 +127,7 @@ watchEffect(() => { </div> <div v-if="iconFields.length" flex="~ wrap gap-4"> <div v-for="field in iconFields" :key="field.name" flex="~ gap-1" items-center> - <div text-secondary :class="getFieldNameIcon(field.name)" :title="field.name" /> + <div text-secondary :class="getFieldNameIcon(field.name)" :title="getFieldIconTitle(field.name)" /> <ContentRich text-sm filter-saturate-0 :content="field.value" :emojis="account.emojis" /> </div> </div> diff --git a/components/account/AccountPostsFollowers.vue b/components/account/AccountPostsFollowers.vue index 046e1d1b..48371a81 100644 --- a/components/account/AccountPostsFollowers.vue +++ b/components/account/AccountPostsFollowers.vue @@ -1,31 +1,46 @@ <script setup lang="ts"> import type { Account } from 'masto' -defineProps<{ +const props = defineProps<{ account: Account }>() +const { formatHumanReadableNumber, formatNumber, forSR } = useHumanReadableNumber() + +const statusesCount = $computed(() => formatNumber(props.account.statusesCount)) +const followingCount = $computed(() => formatHumanReadableNumber(props.account.followingCount)) +const followingCountSR = $computed(() => forSR(props.account.followingCount)) +const followersCount = $computed(() => formatHumanReadableNumber(props.account.followersCount)) +const followersCountSR = $computed(() => forSR(props.account.followersCount)) </script> <template> <div flex gap-5> <NuxtLink :to="getAccountRoute(account)" text-secondary exact-active-class="text-primary"> <template #default="{ isExactActive }"> - <i18n-t keypath="account.posts_count"> - <span font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ formattedNumber(account.statusesCount) }}</span> + <i18n-t keypath="account.posts_count" :plural="account.statusesCount"> + <span font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ statusesCount }}</span> </i18n-t> </template> </NuxtLink> <NuxtLink :to="getAccountFollowingRoute(account)" text-secondary exact-active-class="text-primary"> <template #default="{ isExactActive }"> <i18n-t keypath="account.following_count"> - <span font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ humanReadableNumber(account.followingCount) }}</span> + <span v-if="followingCountSR"> + <span aria-hidden="true" font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ followingCount }}</span> + <span sr-only font-bold>{{ account.followingCount }}</span> + </span> + <span v-else font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ followingCount }}</span> </i18n-t> </template> </NuxtLink> <NuxtLink :to="getAccountFollowersRoute(account)" text-secondary exact-active-class="text-primary"> <template #default="{ isExactActive }"> - <i18n-t keypath="account.followers_count"> - <span font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ humanReadableNumber(account.followersCount) }}</span> + <i18n-t keypath="account.followers_count" :plural="account.followersCount"> + <span v-if="followersCountSR"> + <span aria-hidden="true" font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ followersCount }}</span> + <span sr-only font-bold>{{ account.followersCount }}</span> + </span> + <span v-else font-bold :class="isExactActive ? 'text-primary' : 'text-base'">{{ followersCount }}</span> </i18n-t> </template> </NuxtLink> diff --git a/components/nav/NavFooter.vue b/components/nav/NavFooter.vue index b9aaba31..ed0a89e2 100644 --- a/components/nav/NavFooter.vue +++ b/components/nav/NavFooter.vue @@ -1,21 +1,24 @@ <script setup lang="ts"> -const { t } = useI18n() const buildTime = import.meta.env.__BUILD_TIME__ as string -const buildTimeAgo = useTimeAgo(buildTime) +const buildTimeDate = new Date(buildTime) + +const timeAgoOptions = useTimeAgoOptions() + +const buildTimeAgo = useTimeAgo(buildTime, timeAgoOptions) </script> <template> <footer p4 text-sm text-secondary-light flex="~ col"> <div flex="~ gap2" items-center mb4> - <CommonTooltip :content="t('nav_footer.toggle_theme')"> - <button flex i-ri:sun-line dark:i-ri:moon-line text-lg :aria-label="t('nav_footer.toggle_theme')" @click="toggleDark()" /> + <CommonTooltip :content="$t('nav_footer.toggle_theme')"> + <button flex i-ri:sun-line dark:i-ri:moon-line text-lg :aria-label="$t('nav_footer.toggle_theme')" @click="toggleDark()" /> </CommonTooltip> - <CommonTooltip :content="t('nav_footer.zen_mode')"> + <CommonTooltip :content="$t('nav_footer.zen_mode')"> <button flex text-lg :class="isZenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'" - :aria-label="t('nav_footer.zen_mode')" + :aria-label="$t('nav_footer.zen_mode')" @click="toggleZenMode()" /> </CommonTooltip> @@ -30,7 +33,7 @@ const buildTimeAgo = useTimeAgo(buildTime) <div>{{ $t('app_desc_short') }}</div> <div> <i18n-t keypath="nav_footer.built_at"> - <time :datetime="buildTime" :title="buildTime">{{ buildTimeAgo }}</time> + <time :datetime="buildTime" :title="$d(buildTimeDate, 'long')">{{ buildTimeAgo }}</time> </i18n-t> · <a href="https://github.com/elk-zone/elk" target="_blank">GitHub</a> </div> </footer> diff --git a/components/notification/NotificationGroupedFollow.vue b/components/notification/NotificationGroupedFollow.vue index 84b4bccf..5e84d15c 100644 --- a/components/notification/NotificationGroupedFollow.vue +++ b/components/notification/NotificationGroupedFollow.vue @@ -5,15 +5,30 @@ const { items } = defineProps<{ items: GroupedNotifications }>() -const count = computed(() => items.items.length) +const { formatHumanReadableNumber, forSR } = useHumanReadableNumber() + +const count = $computed(() => items.items.length) +const addSR = $computed(() => forSR(count)) const isExpanded = ref(false) </script> <template> <article flex flex-col> <div flex ml-4 items-center> - <div i-ri:user-follow-fill mr-3 color-primary /> - {{ $t('notification.followed_you_count', [`${count}`]) }} + <div i-ri:user-follow-fill mr-3 color-primary aria-hidden="true" /> + <template v-if="addSR"> + <span + aria-hidden="true" + > + {{ $t('notification.followed_you_count', count, { named: { followers: formatHumanReadableNumber(count) } }) }} + </span> + <span sr-only> + {{ $t('notification.followed_you_count', count, { named: { followers: count } }) }} + </span> + </template> + <span v-else> + {{ $t('notification.followed_you_count', count, { named: { followers: count } }) }} + </span> </div> <div v-if="isExpanded"> <AccountCard diff --git a/components/status/StatusCard.vue b/components/status/StatusCard.vue index f348d0d9..36585e96 100644 --- a/components/status/StatusCard.vue +++ b/components/status/StatusCard.vue @@ -41,6 +41,7 @@ function go(evt: MouseEvent | KeyboardEvent) { } const createdAt = useFormattedDateTime(status.createdAt) +const timeAgoOptions = useTimeAgoOptions() const timeago = useTimeAgo(() => status.createdAt, timeAgoOptions) </script> diff --git a/components/status/StatusPoll.vue b/components/status/StatusPoll.vue index 710ec08e..3155131f 100644 --- a/components/status/StatusPoll.vue +++ b/components/status/StatusPoll.vue @@ -10,7 +10,8 @@ function toPercentage(num: number) { const percentage = 100 * num return `${percentage.toFixed(1).replace(/\.?0+$/, '')}%` } -const expiredTimeAgo = useTimeAgo(poll.expiresAt!) +const timeAgoOptions = useTimeAgoOptions() +const expiredTimeAgo = useTimeAgo(poll.expiresAt!, timeAgoOptions) const masto = useMasto() async function vote(e: Event) { diff --git a/components/status/edit/StatusEditHistory.vue b/components/status/edit/StatusEditHistory.vue index e2fdbfaa..31f94e8e 100644 --- a/components/status/edit/StatusEditHistory.vue +++ b/components/status/edit/StatusEditHistory.vue @@ -10,6 +10,7 @@ const { data: statusEdits } = useAsyncData(`status:history:${status.id}`, () => const showHistory = (edit: StatusEdit) => { openEditHistoryDialog(edit) } +const timeAgoOptions = useTimeAgoOptions() </script> <template> @@ -22,7 +23,7 @@ const showHistory = (edit: StatusEdit) => { > {{ getDisplayName(edit.account) }} <i18n-t :keypath="`status_history.${idx === statusEdits.length - 1 ? 'created' : 'edited'}`"> - {{ useTimeAgo(edit.createdAt, { showSecond: true }).value }} + {{ useTimeAgo(edit.createdAt, timeAgoOptions).value }} </i18n-t> </CommonDropdownItem> </template> diff --git a/components/timeline/TimelinePaginator.vue b/components/timeline/TimelinePaginator.vue index 99c5543a..4f601f3b 100644 --- a/components/timeline/TimelinePaginator.vue +++ b/components/timeline/TimelinePaginator.vue @@ -15,7 +15,7 @@ const virtualScroller = $(computedEager(() => useFeatureFlags().experimentalVirt <CommonPaginator v-bind="{ paginator, stream }" :virtual-scroller="virtualScroller"> <template #updater="{ number, update }"> <button py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="update"> - {{ $t('timeline.show_new_items', [number]) }} + {{ $t('timeline.show_new_items', number) }} </button> </template> <template #default="{ item, active }"> diff --git a/composables/numbers.ts b/composables/numbers.ts index a287576a..a5340f54 100644 --- a/composables/numbers.ts +++ b/composables/numbers.ts @@ -1,15 +1,44 @@ +import type { MaybeRef } from '@vueuse/shared' + const formatter = Intl.NumberFormat() -export const humanReadableNumber = (num: number) => { +export const humanReadableNumber = ( + num: number, + { k, m }: { k: string; m: string } = { k: 'K', m: 'M' }, + useFormatter: Intl.NumberFormat = formatter, +) => { if (num < 10000) - return formatter.format(num) + return useFormatter.format(num) if (num < 1000000) - return `${Math.floor(num / 1000)}K` + return `${Math.floor(num / 1000)}${k}` - return `${Math.floor(num / 1000000)}M` + return `${Math.floor(num / 1000000)}${m}` } -export const formattedNumber = (num: number) => { - return formatter.format(num) +export const formattedNumber = (num: number, useFormatter: Intl.NumberFormat = formatter) => { + return useFormatter.format(num) +} + +export const useHumanReadableNumber = () => { + const i18n = useI18n() + const numberFormatter = $computed(() => Intl.NumberFormat(i18n.locale.value)) + return { + formatHumanReadableNumber: (num: MaybeRef<number>) => { + return humanReadableNumber( + unref(num), + { k: i18n.t('common.kiloSuffix'), m: i18n.t('common.megaSuffix') }, + numberFormatter, + ) + }, + formatNumber: (num: MaybeRef<number>) => { + return formattedNumber( + unref(num), + numberFormatter, + ) + }, + forSR: (num: MaybeRef<number>) => { + return unref(num) > 10000 + }, + } } diff --git a/composables/time.ts b/composables/time.ts index ac6803dd..0a9cbc5e 100644 --- a/composables/time.ts +++ b/composables/time.ts @@ -12,34 +12,28 @@ export const useFormattedDateTime = ( }) } -export const timeAgoOptions: UseTimeAgoOptions<false> = { - showSecond: true, - messages: { - justNow: 'just now', - past: n => n, - future: n => n.match(/\d/) ? `in ${n}` : n, - month: (n, past) => n === 1 - ? past - ? 'last month' - : 'next month' - : `${n}m`, - year: (n, past) => n === 1 - ? past - ? 'last year' - : 'next year' - : `${n}y`, - day: (n, past) => n === 1 - ? past - ? 'yesterday' - : 'tomorrow' - : `${n}d`, - week: (n, past) => n === 1 - ? past - ? 'last week' - : 'next week' - : `${n} week${n > 1 ? 's' : ''}`, - hour: n => `${n}h`, - minute: n => `${n}min`, - second: n => `${n}s`, - }, +export const useTimeAgoOptions = (): UseTimeAgoOptions<false> => { + const { d, t } = useI18n() + + return { + showSecond: true, + updateInterval: 1_000, + messages: { + justNow: t('time_ago_options.just_now'), + // just return the value + past: n => n, + // just return the value + future: n => n, + second: (n, p) => t(`time_ago_options.${p ? 'past' : 'future'}_second`, n), + minute: (n, p) => t(`time_ago_options.${p ? 'past' : 'future'}_minute`, n), + hour: (n, p) => t(`time_ago_options.${p ? 'past' : 'future'}_hour`, n), + day: (n, p) => t(`time_ago_options.${p ? 'past' : 'future'}_day`, n), + week: (n, p) => t(`time_ago_options.${p ? 'past' : 'future'}_week`, n), + month: (n, p) => t(`time_ago_options.${p ? 'past' : 'future'}_month`, n), + year: (n, p) => t(`time_ago_options.${p ? 'past' : 'future'}_year`, n), + }, + fullDateFormatter(date) { + return d(date, 'long') + }, + } } diff --git a/locales/en-US.json b/locales/en-US.json index 500597c8..11d7f4e6 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -14,7 +14,11 @@ "mutuals": "Mutuals", "pinned": "Pinned", "posts_count": "{0} Posts", - "unfollow": "Unfollow" + "unfollow": "Unfollow", + "joined": "Joined", + "profile_description": "{0}'s profile header", + "avatar_description": "{0}'s avatar", + "profile_unavailable": "Profile unavailable" }, "action": { "bookmark": "Bookmark", @@ -41,7 +45,9 @@ "end_of_list": "End of the list", "error": "ERROR", "not_found": "404 Not Found", - "offline_desc": "Seems like you are offline. Please check your network connection." + "offline_desc": "Seems like you are offline. Please check your network connection.", + "kiloSuffix": "K", + "megaSuffix": "M" }, "conversation": { "with": "with" @@ -97,7 +103,7 @@ "notification": { "favourited_post": "favourited your post", "followed_you": "followed you", - "followed_you_count": "{0} people followed you", + "followed_you_count": "{n} people followed you", "missing_type": "MISSING notification.type:", "reblogged_post": "reblogged your post", "request_to_follow": "requested to follow you", @@ -135,20 +141,24 @@ "posts_with_replies": "Posts & Replies" }, "time_ago_options": { - "in": "in", "just_now": "just now", - "last_month": "last month", - "last_week": "last week", - "last_year": "last year", - "next_month": "next month", - "next_week": "next week", - "next_year": "next year", - "tomorrow": "tomorrow", - "week": "week", - "yesterday": "yesterday" + "past_second": "just now|{n} second ago|{n} seconds ago", + "future_second": "just now|in {n} second|in {n} seconds", + "past_minute": "0 minutes ago|1 minute ago|{n} minutes ago", + "future_minute": "in 0 minutes|in 1 minute|in {n} minutes", + "past_hour": "0 hours ago|1 hour ago|{n} hours ago", + "future_hour": "in 0 hours|in 1 hour|in {n} hours", + "past_day": "0 days ago|yesterday|{n} days ago", + "future_day": "in 0 days|tomorrow|in {n} days", + "past_week": "0 weeks ago|last week|{n} weeks ago", + "future_week": "in 0 weeks|next week|in {n} weeks", + "past_month": "0 months ago|last month|{n} months ago", + "future_month": "in 0 months|next month|in {n} months", + "past_year": "0 years ago|last year|{n} years ago", + "future_year": "in 0 years|next year|in {n} years" }, "timeline": { - "show_new_items": "Show {0} new items" + "show_new_items": "Show {n} new items" }, "title": { "federated_timeline": "Federated Timeline", diff --git a/locales/es-ES.json b/locales/es-ES.json index 67e87340..fe2a4a5e 100644 --- a/locales/es-ES.json +++ b/locales/es-ES.json @@ -6,15 +6,19 @@ "follow": "Seguir", "follow_back": "Seguir de vuelta", "follow_requested": "Enviado", - "followers_count": "{0} Seguidores", + "followers_count": "{0} Seguidores|{0} Seguidor|{0} Seguidores", "following": "Siguiendo", "following_count": "{0} Siguiendo", "follows_you": "Te sigue", "muted_users": "Usuarios silenciados", "mutuals": "Mutuales", "pinned": "Fijado", - "posts_count": "{0} publicaciones", - "unfollow": "Dejar de seguir" + "posts_count": "{0} publicaciones|{0} publicación|{0} publicaciones", + "unfollow": "Dejar de seguir", + "joined": "Se unió", + "profile_description": "encabezado del perfil de {0}", + "avatar_description": "avatar de {0}", + "profile_unavailable": "Perfil no disponible" }, "action": { "bookmark": "Añadir marcador", @@ -89,7 +93,7 @@ "notification": { "favourited_post": "marcó tu publicación como favorito", "followed_you": "te ha seguido", - "followed_you_count": "{0} personas te siguieron", + "followed_you_count": "{followers} personas te siguieron|{followers} persona te siguió|{followers} personas te siguieron", "missing_type": "MISSING notification.type:", "reblogged_post": "retooteó tu publicación", "request_to_follow": "ha solicitado seguirte", @@ -121,18 +125,26 @@ "posts_with_replies": "Publicaciones y respuestas" }, "time_ago_options": { - "last_month": "mes pasado", - "last_week": "semana pasada", - "last_year": "año pasado", - "next_month": "próximo mes", - "next_week": "próxima semana", - "next_year": "próximo año", + "just_now": "ahora mismo", + "past_second": "hace 0 segundos|hace {n} segundo|hace {n} segundos", + "future_second": "dentro de 0 segundos|dentro de {n} segundo|dentro de {n} segundos", + "past_minute": "hace 0 minutos|hace 1 minuto|hace {n} minutos", + "future_minute": "dentro de 0 minutos|dentro de 1 minuto|dentro de {n} minutos", + "past_hour": "hace 0 horas|hace 1 hora|hace {n} horas", + "future_hour": "dentro de 0 horas|dentro de 1 hora|dentro {n} horas", + "past_day": "hace 0 días|ayer|hace {n} días", + "future_day": "dentro de 0 días|mañana|dentro de {n} días", + "past_week": "hace 0 semanas|la semana pasada|hace {n} semanas", + "future_week": "dentro de 0 semanas|la próxima semana|dentro de {n} semanas", + "past_month": "hace 0 meses|el mes pasado|hace {n} meses", + "future_month": "dentro de 0 meses|el próximo mes|dentro de {n} meses", + "past_year": "hace 0 años|el año pasado|hace {n} años", + "future_year": "dentro de 0 años|el próximo año|dentro de {n} años", "tomorrow": "mañana", - "week": "semana", "yesterday": "ayer" }, "timeline": { - "show_new_items": "Mostrar {0} nuevas publicaciones" + "show_new_items": "Mostrar {n} nuevas publicaciones|Mostrar {n} nueva publicación|Mostrar {n} nuevas publicaciones" }, "title": { "federated_timeline": "Línea de tiempo federada", diff --git a/locales/ja-JP.json b/locales/ja-JP.json index bc869117..09c46eee 100644 --- a/locales/ja-JP.json +++ b/locales/ja-JP.json @@ -78,7 +78,7 @@ "posts_with_replies": "投稿と返信" }, "timeline": { - "show_new_items": "{0}件の新しい投稿" + "show_new_items": "{n}件の新しい投稿" }, "title": { "federated_timeline": "連合タイムライン", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 3536becd..289e622b 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -97,7 +97,7 @@ "notification": { "favourited_post": "点赞了你的帖文", "followed_you": "关注了你", - "followed_you_count": "{0} 人关注了你", + "followed_you_count": "{n} 人关注了你", "missing_type": "未知的通知类型:", "reblogged_post": "转发了你的帖文", "request_to_follow": "请求关注你", @@ -148,7 +148,7 @@ "yesterday": "昨天" }, "timeline": { - "show_new_items": "展示 {0} 条新帖文" + "show_new_items": "展示 {n} 条新帖文" }, "title": { "federated_timeline": "跨站时间线", diff --git a/nuxt.config.ts b/nuxt.config.ts index b0367607..60068fe6 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,5 @@ import Inspect from 'vite-plugin-inspect' import { isCI, isDevelopment } from 'std-env' - export default defineNuxtConfig({ ssr: false, modules: [ @@ -116,6 +115,64 @@ export default defineNuxtConfig({ defaultLocale: 'en-US', vueI18n: { fallbackLocale: 'en-US', + datetimeFormats: { + 'en-US': { + long: { + dateStyle: 'long', + timeStyle: 'medium', + /* + year: 'numeric', + month: 'short', + day: 'numeric', + weekday: 'short', + hour: 'numeric', + minute: 'numeric', +*/ + }, + }, + 'es-ES': { + long: { + dateStyle: 'long', + timeStyle: 'medium', + /* + year: 'numeric', + month: 'short', + day: 'numeric', + weekday: 'short', + hour: 'numeric', + minute: 'numeric', + hour12: false, +*/ + }, + }, + 'ja-JP': { + long: { + dateStyle: 'long', + timeStyle: 'medium', + /* + year: 'numeric', + month: 'short', + day: 'numeric', + weekday: 'short', + hour: 'numeric', + minute: 'numeric', + hour12: true, +*/ + }, + }, + 'zh-CN': { + long: { + dateStyle: 'long', + timeStyle: 'medium', + }, + }, + 'de-DE': { + long: { + dateStyle: 'long', + timeStyle: 'medium', + }, + }, + }, }, lazy: true, }, diff --git a/pages/@[account]/index/index.vue b/pages/@[account]/index/index.vue index 28ae0bb9..f1ef6ff3 100644 --- a/pages/@[account]/index/index.vue +++ b/pages/@[account]/index/index.vue @@ -44,7 +44,7 @@ const paginator = $computed(() => tabs.find(t => t.name === tab)!.paginator) <template> <div v-if="(account!.discoverable === false)" h-30 flex="~ center" text-secondary-light> - Profile unavailable + {{ $t('account.profile_unavailable') }} </div> <div v-else> <CommonTabs v-model="tab" :options="tabs" command />