Files
ui/src/runtime/components/Tabs.vue
J-Michalek 62ab01655c feat(Tabs): add badge on items (#4553)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2025-07-21 16:55:27 +02:00

173 lines
5.9 KiB
Vue

<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { TabsRootProps, TabsRootEmits } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/tabs'
import type { AvatarProps, BadgeProps } from '../types'
import type { DynamicSlots, ComponentConfig } from '../types/utils'
type Tabs = ComponentConfig<typeof theme, AppConfig, 'tabs'>
export interface TabsItem {
label?: string
/**
* @IconifyIcon
*/
icon?: string
avatar?: AvatarProps
/**
* Display a badge on the item.
* `{ size: 'sm', color: 'neutral', variant: 'outline' }`{lang="ts-type"}
*/
badge?: string | number | BadgeProps
slot?: string
content?: string
/** A unique value for the tab item. Defaults to the index. */
value?: string | number
disabled?: boolean
class?: any
ui?: Pick<Tabs['slots'], 'trigger' | 'leadingIcon' | 'leadingAvatar' | 'leadingAvatarSize' | 'label' | 'trailingBadge' | 'trailingBadgeSize' | 'content'>
[key: string]: any
}
export interface TabsProps<T extends TabsItem = TabsItem> extends Pick<TabsRootProps<string | number>, 'defaultValue' | 'modelValue' | 'activationMode' | 'unmountOnHide'> {
/**
* The element or component this component should render as.
* @defaultValue 'div'
*/
as?: any
items?: T[]
/**
* @defaultValue 'primary'
*/
color?: Tabs['variants']['color']
/**
* @defaultValue 'pill'
*/
variant?: Tabs['variants']['variant']
/**
* @defaultValue 'md'
*/
size?: Tabs['variants']['size']
/**
* The orientation of the tabs.
* @defaultValue 'horizontal'
*/
orientation?: TabsRootProps['orientation']
/**
* The content of the tabs, can be disabled to prevent rendering the content.
* @defaultValue true
*/
content?: boolean
/**
* The key used to get the label from the item.
* @defaultValue 'label'
*/
labelKey?: string
class?: any
ui?: Tabs['slots']
}
export interface TabsEmits extends TabsRootEmits<string | number> {}
type SlotProps<T extends TabsItem> = (props: { item: T, index: number }) => any
export type TabsSlots<T extends TabsItem = TabsItem> = {
'leading': SlotProps<T>
'default': SlotProps<T>
'trailing': SlotProps<T>
'content': SlotProps<T>
'list-leading': (props?: {}) => any
'list-trailing': (props?: {}) => any
} & DynamicSlots<T, undefined, { index: number }>
</script>
<script setup lang="ts" generic="T extends TabsItem">
import type { ComponentPublicInstance } from 'vue'
import { ref, computed } from 'vue'
import { TabsRoot, TabsList, TabsIndicator, TabsTrigger, TabsContent, useForwardPropsEmits } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { get } from '../utils'
import { tv } from '../utils/tv'
import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'
const props = withDefaults(defineProps<TabsProps<T>>(), {
content: true,
defaultValue: '0',
orientation: 'horizontal',
unmountOnHide: true,
labelKey: 'label'
})
const emits = defineEmits<TabsEmits>()
const slots = defineSlots<TabsSlots<T>>()
const appConfig = useAppConfig() as Tabs['AppConfig']
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'orientation', 'activationMode', 'unmountOnHide'), emits)
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.tabs || {}) })({
color: props.color,
variant: props.variant,
size: props.size,
orientation: props.orientation
}))
const triggersRef = ref<ComponentPublicInstance[]>([])
defineExpose({
triggersRef
})
</script>
<template>
<TabsRoot v-bind="rootProps" :class="ui.root({ class: [props.ui?.root, props.class] })">
<TabsList :class="ui.list({ class: props.ui?.list })">
<TabsIndicator :class="ui.indicator({ class: props.ui?.indicator })" />
<slot name="list-leading" />
<TabsTrigger
v-for="(item, index) of items"
:key="index"
:ref="el => (triggersRef[index] = el as ComponentPublicInstance)"
:value="item.value || String(index)"
:disabled="item.disabled"
:class="ui.trigger({ class: [props.ui?.trigger, item.ui?.trigger] })"
>
<slot name="leading" :item="item" :index="index">
<UIcon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon({ class: [props.ui?.leadingIcon, item.ui?.leadingIcon] })" />
<UAvatar v-else-if="item.avatar" :size="((item.ui?.leadingAvatarSize || props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.leadingAvatar({ class: [props.ui?.leadingAvatar, item.ui?.leadingAvatar] })" />
</slot>
<span v-if="get(item, props.labelKey as string) || !!slots.default" :class="ui.label({ class: [props.ui?.label, item.ui?.label] })">
<slot :item="item" :index="index">{{ get(item, props.labelKey as string) }}</slot>
</span>
<slot name="trailing" :item="item" :index="index">
<UBadge
v-if="item.badge"
color="neutral"
variant="outline"
:size="((item.ui?.trailingBadgeSize || props.ui?.trailingBadgeSize || ui.trailingBadgeSize()) as BadgeProps['size'])"
v-bind="(typeof item.badge === 'string' || typeof item.badge === 'number') ? { label: item.badge } : item.badge"
:class="ui.trailingBadge({ class: [props.ui?.trailingBadge, item.ui?.trailingBadge] })"
/>
</slot>
</TabsTrigger>
<slot name="list-trailing" />
</TabsList>
<template v-if="!!content">
<TabsContent v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :class="ui.content({ class: [props.ui?.content, item.ui?.content, item.class] })">
<slot :name="((item.slot || 'content') as keyof TabsSlots<T>)" :item="(item as Extract<T, { slot: string; }>)" :index="index">
{{ item.content }}
</slot>
</TabsContent>
</template>
</TabsRoot>
</template>