mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ui): add internal UI primitives
This commit is contained in:
1 parent
502b781024
commit
fc373bccf2
20 files changed
+637
-21
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
@@ -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"/)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.')
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user