feat(ui): add internal UI primitives

This commit is contained in:
digua committed 2026-08-11 11:44:20 +08:00
1 parent 502b781024
commit fc373bccf2
20 files changed
+637 -21

No files matched your search

+4 -4
View File
@@ -14,7 +14,7 @@ import { reportError } from '@/services/log-report'
import { useLayoutStore } from '@/stores/layout'
import { useSessionStore } from '@/stores/session'
import { useSettingsStore } from '@/stores/settings'
import { StartupLoading } from '@/components/UI'
import { StartupLoading, UiButton, UiIcon } from '@/components/UI'
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities'
import WebSettingsModal from './components/settings/WebSettingsModal.vue'
@@ -91,12 +91,12 @@ onMounted(() => {
<template v-if="!isInitialized">
<div class="flex h-full w-full items-center justify-center">
<div v-if="initError" class="flex flex-col items-center justify-center gap-3 px-6 text-center">
<UIcon name="i-heroicons-exclamation-triangle" class="h-8 w-8 text-red-500" />
<UiIcon name="i-heroicons-exclamation-triangle" class="h-8 w-8 text-red-500" />
<p class="text-sm text-gray-700 dark:text-gray-300">{{ t('common.initFailed') }}</p>
<p class="max-w-sm text-xs text-gray-500">{{ initError }}</p>
<UButton size="sm" color="primary" variant="soft" @click="initializeApp">
<UiButton size="sm" variant="soft" @click="initializeApp">
{{ t('common.retry') }}
</UButton>
</UiButton>
</div>
<StartupLoading v-else />
</div>
+4 -4
View File
@@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import TitleBar from '@/components/common/TitleBar.vue'
import Sidebar from '@/components/common/Sidebar.vue'
import { StartupLoading } from '@/components/UI'
import { StartupLoading, UiButton, UiIcon } from '@/components/UI'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
import { useSettingsStore } from '@/stores/settings'
@@ -421,12 +421,12 @@ onUnmounted(() => {
v-if="startupPresentation.showError"
class="flex flex-col items-center justify-center gap-3 text-center"
>
<UIcon name="i-heroicons-exclamation-triangle" class="h-8 w-8 text-red-500" />
<UiIcon name="i-heroicons-exclamation-triangle" class="h-8 w-8 text-red-500" />
<p class="text-sm text-gray-700 dark:text-gray-300">{{ t('common.initFailed') }}</p>
<p class="max-w-sm text-xs text-gray-500">{{ initError }}</p>
<UButton size="sm" color="primary" variant="soft" @click="initializeApp">
<UiButton size="sm" variant="soft" @click="initializeApp">
{{ t('common.retry') }}
</UButton>
</UiButton>
</div>
<StartupLoading
v-else
+2 -1
View File
@@ -7,6 +7,7 @@
import { computed, ref } from 'vue'
import { useInsightViewLoading } from './insight-view-loading'
import LoadingDots from './LoadingDots.vue'
import UiIcon from './primitives/UiIcon.vue'
const props = withDefaults(
defineProps<{
@@ -47,7 +48,7 @@ const usePageIndicator = computed(() => props.variant === 'page' || props.varian
<div v-if="!isCoordinatedPageLoading" :class="containerClass" role="status" aria-live="polite">
<div class="flex flex-col items-center justify-center text-center">
<LoadingDots v-if="usePageIndicator" />
<UIcon v-else name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
<UiIcon v-else name="i-heroicons-arrow-path" size="xl" class="animate-spin text-pink-500" />
<p v-if="text" class="mt-2 text-sm text-gray-500">{{ text }}</p>
</div>
</div>
+2 -1
View File
@@ -4,6 +4,7 @@
* 基于 ThemeCard 的组合封装,用于展示单个统计指标
*/
import ThemeCard from './ThemeCard.vue'
import UiIcon from './primitives/UiIcon.vue'
defineProps<{
/** 指标标签 */
@@ -56,7 +57,7 @@ const iconBgMap: Record<string, string> = {
class="ml-4 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl"
:class="iconBgMap[iconBg || 'gray']"
>
<UIcon v-if="icon.startsWith('i-')" :name="icon" class="h-5 w-5" />
<UiIcon v-if="icon.startsWith('i-')" :name="icon" size="lg" />
<span v-else class="text-xl">{{ icon }}</span>
</div>
</div>
+1
View File
@@ -19,3 +19,4 @@ export { default as FileDropZone } from './FileDropZone.vue'
export { default as DatePicker } from './DatePicker.vue'
export { default as TopNSelect } from './TopNSelect.vue'
export { default as ScrollableChart } from './ScrollableChart.vue'
export * from './primitives'
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toNuxtUiColor } from './nuxt-ui-adapter'
import { usePrimitiveAttrs } from './primitive-contracts'
import type { UiBadgeVariant, UiSize, UiTone } from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
tone?: UiTone
variant?: UiBadgeVariant
size?: UiSize
label?: string | number
icon?: string
trailingIcon?: string
}>(),
{
tone: 'neutral',
variant: 'soft',
size: 'md',
label: undefined,
icon: undefined,
trailingIcon: undefined,
}
)
const color = computed(() => toNuxtUiColor(props.tone))
const primitiveAttrs = usePrimitiveAttrs()
</script>
<template>
<UBadge
v-bind="primitiveAttrs"
:color="color"
:variant="variant"
:size="size"
:label="label"
:icon="icon"
:trailing-icon="trailingIcon"
>
<template v-if="$slots.leading" #leading>
<slot name="leading" />
</template>
<template v-if="$slots.default" #default>
<slot />
</template>
<template v-if="$slots.trailing" #trailing>
<slot name="trailing" />
</template>
</UBadge>
</template>
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import { after, before, describe, it } from 'node:test'
import { renderToString } from '@vue/server-renderer'
import vue from '@vitejs/plugin-vue'
import { createSSRApp, defineComponent, h, type Component } from 'vue'
import { createServer, type ViteDevServer } from 'vite'
let server: ViteDevServer
let UiButton: Component
const UButtonStub = defineComponent({
inheritAttrs: false,
props: {
label: String,
loading: Boolean,
disabled: Boolean,
},
setup(props, { attrs, slots }) {
return () =>
h(
'button',
{
...attrs,
disabled: props.disabled,
'data-loading': props.loading ? 'true' : undefined,
},
slots.default?.() ?? props.label
)
},
})
before(async () => {
server = await createServer({
configFile: false,
appType: 'custom',
server: { middlewareMode: true },
optimizeDeps: { noDiscovery: true },
plugins: [vue()],
})
UiButton = (await server.ssrLoadModule('/src/components/UI/primitives/UiButton.vue')).default as Component
})
after(async () => {
await server.close()
})
async function renderButton(props: Record<string, unknown>, text?: string): Promise<string> {
const app = createSSRApp({
render: () => h(UiButton, props, text === undefined ? undefined : { default: () => text }),
})
app.component('UButton', UButtonStub)
return renderToString(app)
}
describe('UiButton rendered contract', () => {
it('renders its explicit accessible label and strips vendor escape hatches', async () => {
const html = await renderButton({
icon: 'i-heroicons-x-mark',
accessibleLabel: 'Close',
ui: { base: 'vendor-override' },
portal: false,
})
assert.match(html, /aria-label="Close"/)
assert.doesNotMatch(html, /vendor-override|portal=/)
})
it('rejects icon-only buttons without an explicit accessible label', async () => {
await assert.rejects(() => renderButton({ icon: 'i-heroicons-x-mark' }), /visible text or an accessible label/)
await assert.rejects(
() => renderButton({ icon: 'i-heroicons-x-mark', 'aria-label': 'Bypassed label' }),
/visible text or an accessible label/
)
})
it('renders visible slot text as the button name', async () => {
const html = await renderButton({}, 'Retry')
assert.match(html, /Retry/)
})
it('keeps disabled and loading buttons non-interactive', async () => {
const disabledHtml = await renderButton({ label: 'Disabled', disabled: true })
const loadingHtml = await renderButton({ label: 'Loading', loading: true })
assert.match(disabledHtml, /disabled/)
assert.match(loadingHtml, /disabled/)
assert.match(loadingHtml, /aria-busy="true"/)
assert.match(loadingHtml, /data-loading="true"/)
})
})
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue'
import { toNuxtUiColor } from './nuxt-ui-adapter'
import { assertButtonAccessibleName, usePrimitiveAttrs } from './primitive-contracts'
import type { UiButtonVariant, UiSize, UiTone } from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
tone?: UiTone
variant?: UiButtonVariant
size?: UiSize
type?: 'button' | 'submit' | 'reset'
label?: string
accessibleLabel?: string
icon?: string
trailingIcon?: string
loading?: boolean
disabled?: boolean
block?: boolean
}>(),
{
tone: 'primary',
variant: 'solid',
size: 'md',
type: 'button',
label: undefined,
accessibleLabel: undefined,
icon: undefined,
trailingIcon: undefined,
loading: false,
disabled: false,
block: false,
}
)
const emit = defineEmits<{
click: [event: MouseEvent]
focus: [event: FocusEvent]
blur: [event: FocusEvent]
keydown: [event: KeyboardEvent]
keyup: [event: KeyboardEvent]
}>()
const slots = useSlots()
const primitiveAttrs = usePrimitiveAttrs(['aria-label', 'aria-busy'])
const color = computed(() => toNuxtUiColor(props.tone))
const resolvedAccessibleLabel = computed(() => {
assertButtonAccessibleName({
label: props.label,
accessibleLabel: props.accessibleLabel,
hasDefaultSlot: Boolean(slots.default),
})
return props.accessibleLabel
})
const isDisabled = computed(() => props.disabled || props.loading)
</script>
<template>
<UButton
v-bind="primitiveAttrs"
:type="type"
:label="label"
:aria-label="resolvedAccessibleLabel"
:aria-busy="loading ? 'true' : undefined"
:icon="icon"
:trailing-icon="trailingIcon"
:color="color"
:variant="variant"
:size="size"
:loading="loading"
:disabled="isDisabled"
:block="block"
@click="emit('click', $event)"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
@keydown="emit('keydown', $event)"
@keyup="emit('keyup', $event)"
>
<template v-if="$slots.leading" #leading>
<slot name="leading" />
</template>
<template v-if="$slots.default" #default>
<slot />
</template>
<template v-if="$slots.trailing" #trailing>
<slot name="trailing" />
</template>
</UButton>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePrimitiveAttrs } from './primitive-contracts'
import type { UiSize } from './types'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
name: string
size?: UiSize
label?: string
}>()
const sizeClass = computed(() => {
if (!props.size) return undefined
return {
xs: 'h-3 w-3',
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
xl: 'h-6 w-6',
}[props.size]
})
const primitiveAttrs = usePrimitiveAttrs(['aria-label', 'aria-hidden', 'role'])
</script>
<template>
<UIcon
v-bind="primitiveAttrs"
:name="name"
:class="sizeClass"
:role="label ? 'img' : undefined"
:aria-label="label"
:aria-hidden="label ? undefined : 'true'"
/>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import UiButton from './UiButton.vue'
import { usePrimitiveAttrs } from './primitive-contracts'
import type { UiButtonVariant, UiSize, UiTone } from './types'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
icon: string
label: string
tone?: UiTone
variant?: UiButtonVariant
size?: UiSize
loading?: boolean
disabled?: boolean
}>(),
{
tone: 'neutral',
variant: 'ghost',
size: 'sm',
loading: false,
disabled: false,
}
)
const emit = defineEmits<{
click: [event: MouseEvent]
focus: [event: FocusEvent]
blur: [event: FocusEvent]
keydown: [event: KeyboardEvent]
keyup: [event: KeyboardEvent]
}>()
const primitiveAttrs = usePrimitiveAttrs()
</script>
<template>
<UiButton
v-bind="primitiveAttrs"
:icon="icon"
:accessible-label="label"
:tone="tone"
:variant="variant"
:size="size"
:loading="loading"
:disabled="disabled"
@click="emit('click', $event)"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
@keydown="emit('keydown', $event)"
@keyup="emit('keyup', $event)"
/>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from 'vue'
import { assertProgressLabel, getUiProgressState, usePrimitiveAttrs } from './primitive-contracts'
import type { UiSize, UiTone } from './types'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
label: string
value?: number | null
max?: number
tone?: UiTone
size?: UiSize
showValue?: boolean
inverted?: boolean
statusText?: string
}>(),
{
value: null,
max: 100,
tone: 'primary',
size: 'md',
showValue: false,
inverted: false,
statusText: undefined,
}
)
assertProgressLabel(props.label)
const primitiveAttrs = usePrimitiveAttrs()
const progressState = computed(() => getUiProgressState(props.value, props.max))
const roundedPercentage = computed(() =>
progressState.value.percentage === null ? null : Math.round(progressState.value.percentage)
)
const valueText = computed(
() => props.statusText ?? (roundedPercentage.value === null ? undefined : `${roundedPercentage.value}%`)
)
const barStyle = computed(() =>
progressState.value.percentage === null ? undefined : { width: `${progressState.value.percentage}%` }
)
const sizeClass = computed(
() =>
({
xs: 'h-1',
sm: 'h-1.5',
md: 'h-2',
lg: 'h-2.5',
xl: 'h-3',
})[props.size]
)
const toneClass = computed(
() =>
({
primary: 'bg-primary-500',
neutral: 'bg-gray-500',
success: 'bg-green-500',
warning: 'bg-amber-500',
danger: 'bg-red-500',
info: 'bg-blue-500',
})[props.tone]
)
</script>
<template>
<div v-bind="primitiveAttrs" class="w-full">
<div
v-if="showValue && roundedPercentage !== null"
class="mb-1 text-right text-xs text-gray-500 dark:text-gray-400"
>
<slot name="status" :value="progressState.value" :max="max" :percentage="roundedPercentage">
{{ roundedPercentage }}%
</slot>
</div>
<div
role="progressbar"
:aria-label="label"
:aria-valuemin="0"
:aria-valuemax="max"
:aria-valuenow="progressState.value ?? undefined"
:aria-valuetext="valueText"
:aria-busy="progressState.value === null ? 'true' : undefined"
class="flex w-full overflow-hidden rounded-full bg-gray-200 dark:bg-white/10"
:class="[sizeClass, inverted ? 'justify-end' : undefined]"
>
<div
class="h-full rounded-full transition-[width] duration-200 motion-reduce:transition-none"
:class="[
toneClass,
progressState.percentage === null ? 'w-1/2 animate-pulse motion-reduce:animate-none' : undefined,
]"
:style="barStyle"
/>
</div>
</div>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { UiTooltipPlacement } from './types'
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
text: string
placement?: UiTooltipPlacement
delay?: number
disabled?: boolean
}>(),
{
placement: 'bottom',
delay: 300,
disabled: false,
}
)
</script>
<template>
<slot v-if="disabled || !text" />
<UTooltip v-else :text="text" :delay-duration="delay" :content="{ side: placement }">
<slot />
</UTooltip>
</template>
+8
View File
@@ -0,0 +1,8 @@
export { default as UiBadge } from './UiBadge.vue'
export { default as UiButton } from './UiButton.vue'
export { default as UiIcon } from './UiIcon.vue'
export { default as UiIconButton } from './UiIconButton.vue'
export { default as UiProgress } from './UiProgress.vue'
export { default as UiTooltip } from './UiTooltip.vue'
export type { UiBadgeVariant, UiButtonVariant, UiSize, UiTone, UiTooltipPlacement } from './types'
@@ -0,0 +1,16 @@
import type { UiTone } from './types'
type NuxtUiColor = 'primary' | 'neutral' | 'success' | 'warning' | 'error' | 'info'
const NUXT_UI_COLOR_BY_TONE: Record<UiTone, NuxtUiColor> = {
primary: 'primary',
neutral: 'neutral',
success: 'success',
warning: 'warning',
danger: 'error',
info: 'info',
}
export function toNuxtUiColor(tone: UiTone): NuxtUiColor {
return NUXT_UI_COLOR_BY_TONE[tone]
}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { toNuxtUiColor } from './nuxt-ui-adapter'
import {
assertButtonAccessibleName,
assertProgressLabel,
filterPrimitiveAttrs,
getUiProgressState,
} from './primitive-contracts'
import type { UiTone } from './types'
describe('UI primitive contracts', () => {
it('maps semantic tones to the current Nuxt UI adapter', () => {
const cases: Array<[UiTone, ReturnType<typeof toNuxtUiColor>]> = [
['primary', 'primary'],
['neutral', 'neutral'],
['success', 'success'],
['warning', 'warning'],
['danger', 'error'],
['info', 'info'],
]
for (const [tone, expected] of cases) {
assert.equal(toNuxtUiColor(tone), expected)
}
})
it('keeps stable DOM attributes without exposing vendor component props or listeners', () => {
const onClick = () => undefined
assert.deepEqual(
filterPrimitiveAttrs({
class: 'custom',
id: 'action',
'aria-label': 'Retry',
'data-test': 'retry',
ui: { base: 'vendor-override' },
portal: false,
onClick,
}),
{
class: 'custom',
id: 'action',
'aria-label': 'Retry',
'data-test': 'retry',
}
)
})
it('requires every button to have a visible or accessible name', () => {
assert.doesNotThrow(() => assertButtonAccessibleName({ label: 'Retry', hasDefaultSlot: false }))
assert.doesNotThrow(() => assertButtonAccessibleName({ accessibleLabel: 'Close', hasDefaultSlot: false }))
assert.doesNotThrow(() => assertButtonAccessibleName({ hasDefaultSlot: true }))
assert.throws(
() => assertButtonAccessibleName({ label: ' ', hasDefaultSlot: false }),
/visible text or an accessible label/
)
})
it('clamps determinate progress and preserves the indeterminate state', () => {
assert.deepEqual(getUiProgressState(null, 100), { value: null, percentage: null })
assert.deepEqual(getUiProgressState(-10, 100), { value: 0, percentage: 0 })
assert.deepEqual(getUiProgressState(25, 50), { value: 25, percentage: 50 })
assert.deepEqual(getUiProgressState(120, 100), { value: 100, percentage: 100 })
assert.throws(() => getUiProgressState(10, 0), /greater than zero/)
})
it('requires progress to expose a stable accessible name', () => {
assert.doesNotThrow(() => assertProgressLabel('Import progress'))
assert.throws(() => assertProgressLabel(' '), /non-empty accessible label/)
})
})
@@ -0,0 +1,67 @@
import { computed, useAttrs, type ComputedRef } from 'vue'
const SAFE_PRIMITIVE_ATTRIBUTES = new Set(['class', 'style', 'id', 'title', 'tabindex'])
export function filterPrimitiveAttrs(
attrs: Record<string, unknown>,
excludedAttributes: readonly string[] = []
): Record<string, unknown> {
const excluded = new Set(excludedAttributes)
return Object.fromEntries(
Object.entries(attrs).filter(
([name]) =>
!excluded.has(name) &&
(SAFE_PRIMITIVE_ATTRIBUTES.has(name) || name.startsWith('aria-') || name.startsWith('data-'))
)
)
}
export function usePrimitiveAttrs(excludedAttributes: readonly string[] = []): ComputedRef<Record<string, unknown>> {
const attrs = useAttrs()
return computed(() => filterPrimitiveAttrs(attrs, excludedAttributes))
}
interface ButtonAccessibleNameInput {
label?: string
accessibleLabel?: string
hasDefaultSlot: boolean
}
function hasText(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
export function assertButtonAccessibleName({
label,
accessibleLabel,
hasDefaultSlot,
}: ButtonAccessibleNameInput): void {
if (hasText(label) || hasText(accessibleLabel) || hasDefaultSlot) return
throw new Error('UiButton requires visible text or an accessible label. Use UiIconButton for icon-only actions.')
}
export interface UiProgressState {
value: number | null
percentage: number | null
}
export function getUiProgressState(value: number | null, max: number): UiProgressState {
if (!Number.isFinite(max) || max <= 0) {
throw new RangeError('UiProgress max must be a finite number greater than zero.')
}
if (value === null) return { value: null, percentage: null }
if (!Number.isFinite(value)) {
throw new RangeError('UiProgress value must be null or a finite number.')
}
const normalizedValue = Math.min(Math.max(value, 0), max)
return {
value: normalizedValue,
percentage: (normalizedValue / max) * 100,
}
}
export function assertProgressLabel(label: string): void {
if (hasText(label)) return
throw new Error('UiProgress requires a non-empty accessible label.')
}
+9
View File
@@ -0,0 +1,9 @@
export type UiTone = 'primary' | 'neutral' | 'success' | 'warning' | 'danger' | 'info'
export type UiSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type UiButtonVariant = 'solid' | 'soft' | 'outline' | 'ghost' | 'link'
export type UiBadgeVariant = 'solid' | 'soft' | 'outline' | 'subtle'
export type UiTooltipPlacement = 'top' | 'right' | 'bottom' | 'left'
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { UiIcon, UiIconButton } from '@/components/UI/primitives'
import { PageTabs } from '@/components/navigation'
export interface SettingsDialogTab {
@@ -42,20 +43,13 @@ const currentTab = computed({
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600 dark:bg-primary-500">
<UIcon name="i-heroicons-cog-6-tooth" class="h-4 w-4 text-white" />
<UiIcon name="i-heroicons-cog-6-tooth" size="md" class="text-white" />
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('settings.title') }}
</h2>
</div>
<UButton
icon="i-heroicons-x-mark"
color="neutral"
variant="ghost"
size="sm"
:aria-label="t('common.close')"
@click="emit('update:open', false)"
/>
<UiIconButton icon="i-heroicons-x-mark" :label="t('common.close')" @click="emit('update:open', false)" />
</div>
<PageTabs v-model="currentTab" class="mt-4 pb-3" :items="tabs" />
</div>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { UiIcon } from '@/components/UI/primitives'
import type { NavigationTabItem } from './types'
interface Props {
@@ -129,7 +130,7 @@ watch(
]"
@click="activeTab = tab.id"
>
<UIcon v-if="tab.icon" :name="tab.icon" :class="size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
<UiIcon v-if="tab.icon" :name="tab.icon" :size="size === 'sm' ? 'sm' : 'md'" />
{{ tab.label }}
</button>
<div class="absolute bg-primary-500 transition-all duration-300 ease-out" :style="indicatorStyle" />
+2 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { RouterLink, type RouteLocationRaw } from 'vue-router'
import { UiIcon } from '@/components/UI/primitives'
import type { NavigationTabItem } from './types'
interface PageTabItem extends NavigationTabItem {
@@ -48,7 +49,7 @@ function selectItem(item: PageTabItem): void {
:aria-pressed="item.to ? undefined : modelValue === item.id"
@click="selectItem(item)"
>
<UIcon v-if="item.icon" :name="item.icon" class="h-3.5 w-3.5" />
<UiIcon v-if="item.icon" :name="item.icon" size="sm" />
<span>{{ item.label }}</span>
</component>
</div>