mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
fix(frontend): complete i18n coverage and responsive layouts
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, ref, type App } from 'vue'
|
||||
|
||||
import Pagination from '../pagination.vue'
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('updates the summary and accessible page controls when the locale changes', async () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const updateCurrent = vi.fn()
|
||||
const app = createApp({
|
||||
render: () => h(Pagination, {
|
||||
current: 1,
|
||||
total: 1250,
|
||||
pageSize: 20,
|
||||
showPageSizeSelector: false,
|
||||
'onUpdate:current': updateCurrent,
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
expect(root.querySelector('[aria-live]')?.textContent).toContain('共 1,250 条')
|
||||
expect(root.querySelector('[aria-current="page"]')?.getAttribute('aria-label')).toBe('第 1 页')
|
||||
|
||||
setI18nLocale('en-US')
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('[aria-live]')?.textContent).toContain('Showing 1-20 of 1,250 items')
|
||||
expect(root.querySelector('[aria-current="page"]')?.getAttribute('aria-label')).toBe('Page 1')
|
||||
expect(root.querySelector('input')?.getAttribute('aria-label')).toBe('Go to page')
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[aria-label="Page 2"]')?.click()
|
||||
expect(updateCurrent).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('shows a zero-based empty range after the final record is removed', async () => {
|
||||
const total = ref(1)
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(Pagination, {
|
||||
current: 1,
|
||||
total: total.value,
|
||||
showPageSizeSelector: false,
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
total.value = 0
|
||||
setI18nLocale('en-US')
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('[aria-live]')?.textContent).toContain('Showing 0-0 of 0 items')
|
||||
expect(root.querySelectorAll('button')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, type App } from 'vue'
|
||||
|
||||
import Tabs from '../tabs.vue'
|
||||
import TabsList from '../tabs-list.vue'
|
||||
import TabsTrigger from '../tabs-trigger.vue'
|
||||
import { setI18nLocale, useI18n } from '@/i18n'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('TabsList', () => {
|
||||
it('repositions the indicator after translated labels change width', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
return { width: this.textContent === '个人设置' ? 80 : 140 } as DOMRect
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(4)
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const { t } = useI18n()
|
||||
return () => h(Tabs, { modelValue: 'settings' }, {
|
||||
default: () => h(TabsList, {}, {
|
||||
default: () => h(TabsTrigger, { value: 'settings' }, () => t('common.settings')),
|
||||
}),
|
||||
})
|
||||
},
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
await nextTick()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
const indicator = root.querySelector<HTMLElement>('.tabs-indicator')
|
||||
expect(indicator?.style.width).toBe('80px')
|
||||
expect(indicator?.style.transform).toBe('translateX(4px)')
|
||||
|
||||
setI18nLocale('en-US')
|
||||
await nextTick()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(indicator?.style.width).toBe('140px')
|
||||
})
|
||||
})
|
||||
@@ -31,7 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
const baseClass =
|
||||
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
|
||||
'inline-flex min-w-0 max-w-full items-center justify-center rounded-xl text-sm font-semibold leading-5 [overflow-wrap:anywhere] [&_svg]:shrink-0 transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-primary text-white hover:bg-primary/90',
|
||||
@@ -48,7 +48,7 @@ const buttonClass = computed(() => {
|
||||
default: 'h-11 px-5',
|
||||
sm: 'h-9 rounded-lg px-3',
|
||||
lg: 'h-12 rounded-xl px-8 text-base',
|
||||
icon: 'h-11 w-11 rounded-2xl',
|
||||
icon: 'h-11 w-11 shrink-0 rounded-2xl',
|
||||
}
|
||||
|
||||
return cn(
|
||||
|
||||
@@ -57,12 +57,12 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-balance text-base font-semibold leading-tight text-foreground sm:text-lg">
|
||||
<h3 class="break-words text-balance text-base font-semibold leading-tight text-foreground sm:text-lg">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-0.5 text-pretty text-xs leading-4 text-muted-foreground"
|
||||
class="mt-0.5 break-words text-pretty text-xs leading-4 text-muted-foreground"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
@@ -80,7 +80,7 @@
|
||||
<!-- Footer 区域:如果有 footer 插槽,自动添加样式 -->
|
||||
<div
|
||||
v-if="slots.footer"
|
||||
class="flex shrink-0 flex-col-reverse items-stretch gap-2 border-t border-border bg-background/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur-sm [&>button]:w-full sm:flex-row-reverse sm:items-center sm:gap-3 sm:bg-muted/10 sm:px-6 sm:py-4 sm:[&>button]:w-auto"
|
||||
class="flex shrink-0 flex-col-reverse items-stretch gap-2 border-t border-border bg-background/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur-sm [&>button]:min-h-min [&>button]:w-full [&>button]:whitespace-normal [&>button]:py-2 sm:flex-row-reverse sm:flex-wrap sm:items-center sm:gap-3 sm:bg-muted/10 sm:px-6 sm:py-4 sm:[&>button]:w-auto"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@ const maxWidthClass = computed(() => {
|
||||
})
|
||||
|
||||
const contentBodyClass = computed(() => [
|
||||
'min-h-0 overflow-y-auto overscroll-contain',
|
||||
'min-h-0 min-w-0 overflow-y-auto overscroll-contain',
|
||||
props.noPadding ? '' : 'px-4 py-3 sm:px-6',
|
||||
].filter(Boolean).join(' '))
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3">
|
||||
<div class="flex flex-col-reverse gap-3 border-t border-border bg-muted/10 px-4 py-4 [&>button]:min-h-min [&>button]:whitespace-normal [&>button]:py-2 sm:flex-row-reverse sm:flex-wrap sm:px-6">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -27,7 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const contentClass = computed(() =>
|
||||
cn(
|
||||
'z-[200] min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card p-1 text-foreground shadow-2xl backdrop-blur-xl',
|
||||
'z-[200] min-w-[8rem] max-w-[calc(100vw-1rem)] max-h-[var(--radix-dropdown-menu-content-available-height)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-card p-1 text-foreground shadow-2xl backdrop-blur-xl',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
props.class
|
||||
|
||||
@@ -16,7 +16,7 @@ defineEmits<{
|
||||
|
||||
const itemClass = computed(() =>
|
||||
cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-lg px-3 py-1.5 text-sm outline-none',
|
||||
'relative flex min-w-0 cursor-pointer select-none items-center whitespace-normal break-words rounded-lg px-3 py-1.5 text-sm leading-5 outline-none [&_svg]:shrink-0',
|
||||
'data-[highlighted]:bg-accent focus:bg-accent text-foreground',
|
||||
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
props.class
|
||||
|
||||
@@ -16,7 +16,7 @@ const props = defineProps<Props>()
|
||||
|
||||
const labelClass = computed(() =>
|
||||
cn(
|
||||
'text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
'text-[11px] font-semibold tracking-normal break-words text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
props.class
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 border-t border-border/60 px-4 sm:px-6 py-3 sm:py-4 bg-muted/20">
|
||||
<div class="flex min-w-0 flex-col gap-3 border-t border-border/60 bg-muted/20 px-4 py-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-4 sm:px-6 sm:py-4">
|
||||
<!-- 左侧:记录范围和每页数量 -->
|
||||
<div class="flex items-center justify-between sm:justify-start gap-3 text-sm text-muted-foreground">
|
||||
<span class="font-medium whitespace-nowrap">
|
||||
<div class="flex min-w-0 flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground sm:justify-start">
|
||||
<span
|
||||
class="min-w-0 break-words font-medium tabular-nums"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ rangeSummary }}
|
||||
</span>
|
||||
<Select
|
||||
@@ -10,7 +13,10 @@
|
||||
:model-value="String(pageSize)"
|
||||
@update:model-value="handlePageSizeChange"
|
||||
>
|
||||
<SelectTrigger class="w-[120px] h-8 sm:h-9 border-border/60 text-xs sm:text-sm">
|
||||
<SelectTrigger
|
||||
class="h-8 w-auto min-w-[120px] shrink-0 border-border/60 text-xs sm:h-9 sm:text-sm"
|
||||
:aria-label="t('pagination.pageSizeLabel')"
|
||||
>
|
||||
<span class="flex-1 text-center">
|
||||
<SelectValue />
|
||||
</span>
|
||||
@@ -31,8 +37,8 @@
|
||||
<div class="flex flex-wrap items-center justify-center gap-1.5 sm:gap-2 sm:ml-auto">
|
||||
<!-- 页码按钮(智能省略) -->
|
||||
<template
|
||||
v-for="page in pageNumbers"
|
||||
:key="page"
|
||||
v-for="(page, index) in pageNumbers"
|
||||
:key="`${page}-${index}`"
|
||||
>
|
||||
<Button
|
||||
v-if="typeof page === 'number'"
|
||||
@@ -40,9 +46,11 @@
|
||||
size="sm"
|
||||
class="h-9 min-w-[36px] px-2"
|
||||
:class="page === current ? 'shadow-sm' : ''"
|
||||
:aria-label="t('pagination.pageNumber', { page: formatNumber(page) })"
|
||||
:aria-current="page === current ? 'page' : undefined"
|
||||
@click="handlePageChange(page)"
|
||||
>
|
||||
{{ page }}
|
||||
{{ formatNumber(page) }}
|
||||
</Button>
|
||||
<span
|
||||
v-else
|
||||
@@ -55,18 +63,19 @@
|
||||
v-if="totalPages > 7"
|
||||
class="flex items-center gap-1.5 ml-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ jumpToLabel }}</span>
|
||||
<span class="hidden sm:inline">{{ t('pagination.goToPage') }}</span>
|
||||
<input
|
||||
v-model="jumpPageInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
:aria-label="t('pagination.jumpToPage')"
|
||||
class="w-12 h-9 px-2 text-center text-sm border border-border/60 rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60"
|
||||
@keydown.enter="handleJumpPage"
|
||||
@blur="handleJumpPage"
|
||||
@input="filterNumericInput"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ pageLabel }}</span>
|
||||
<span class="hidden sm:inline">{{ t('pagination.pageLabel') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,28 +111,29 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const jumpPageInput = ref('')
|
||||
const locale = useI18n().locale
|
||||
const { locale, t } = useI18n()
|
||||
const numberFormatter = computed(() => new Intl.NumberFormat(locale.value))
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return numberFormatter.value.format(value)
|
||||
}
|
||||
|
||||
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
|
||||
|
||||
const recordRange = computed(() => {
|
||||
const start = (props.current - 1) * props.pageSize + 1
|
||||
const start = props.total === 0 ? 0 : (props.current - 1) * props.pageSize + 1
|
||||
const end = Math.min(props.current * props.pageSize, props.total)
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const rangeSummary = computed(() => {
|
||||
if (locale.value === 'en-US') {
|
||||
return `Showing ${recordRange.value.start}-${recordRange.value.end} of ${props.total} items`
|
||||
}
|
||||
return `显示 ${recordRange.value.start}-${recordRange.value.end} 条,共 ${props.total} 条`
|
||||
})
|
||||
|
||||
const jumpToLabel = computed(() => locale.value === 'en-US' ? 'Go to' : '跳至')
|
||||
const pageLabel = computed(() => locale.value === 'en-US' ? 'page' : '页')
|
||||
const rangeSummary = computed(() => t('pagination.range', {
|
||||
start: formatNumber(recordRange.value.start),
|
||||
end: formatNumber(recordRange.value.end),
|
||||
total: formatNumber(props.total),
|
||||
}))
|
||||
|
||||
function pageSizeLabel(size: number): string {
|
||||
return locale.value === 'en-US' ? `${size} / page` : `${size} 条/页`
|
||||
return t('pagination.pageSize', { size: formatNumber(size) })
|
||||
}
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="searchPlaceholder"
|
||||
:placeholder="searchPlaceholder ?? t('common.searchPlaceholder')"
|
||||
:aria-label="searchPlaceholder ?? t('common.searchPlaceholder')"
|
||||
class="h-9 rounded-xl border-border/60 bg-background/80 pl-9 pr-3 text-sm"
|
||||
@keydown.stop
|
||||
/>
|
||||
@@ -34,7 +35,7 @@
|
||||
v-if="showEmptyState"
|
||||
class="px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
未找到匹配项
|
||||
{{ t('common.noSearchResults') }}
|
||||
</div>
|
||||
</SelectViewport>
|
||||
</SelectContentPrimitive>
|
||||
@@ -66,6 +67,7 @@ import {
|
||||
type RegisteredSelectItem,
|
||||
} from './select-search-context'
|
||||
import { matchesSearchQuery, preloadPinyin } from '@/utils/search'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface Props {
|
||||
class?: string
|
||||
@@ -90,9 +92,10 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
disablePortal: undefined,
|
||||
searchable: true,
|
||||
searchThreshold: 8,
|
||||
searchPlaceholder: '输入关键词搜索...',
|
||||
searchPlaceholder: undefined,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const isInsideDialog = inject(DIALOG_CONTEXT_KEY, false)
|
||||
const shouldDisablePortal = computed(
|
||||
() => props.disablePortal ?? isInsideDialog,
|
||||
@@ -177,7 +180,7 @@ watch(showSearchInput, async (visible) => {
|
||||
|
||||
const contentClass = computed(() =>
|
||||
cn(
|
||||
'z-[200] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
|
||||
'z-[200] max-h-96 min-w-[var(--radix-select-trigger-width,8rem)] max-w-[min(calc(100vw-1rem),var(--radix-select-content-available-width,100vw))] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
props.class,
|
||||
|
||||
@@ -101,7 +101,7 @@ onBeforeUnmount(() => {
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
<SelectItemText>
|
||||
<SelectItemText class="min-w-0 whitespace-normal break-words text-left">
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</SelectItemPrimitive>
|
||||
|
||||
@@ -13,7 +13,7 @@ const props = defineProps<Props>()
|
||||
|
||||
const triggerClass = computed(() =>
|
||||
cn(
|
||||
'flex h-11 w-full items-center justify-between rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50 text-foreground cursor-pointer backdrop-blur transition-all',
|
||||
'flex h-11 w-full min-w-0 items-center justify-between gap-2 rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-left text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50 text-foreground cursor-pointer backdrop-blur transition-all',
|
||||
props.class
|
||||
)
|
||||
)
|
||||
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
|
||||
:class="triggerClass"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="truncate">
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<slot />
|
||||
</span>
|
||||
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useSlots
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ListFilter } from 'lucide-vue-next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useI18n } from '@/i18n'
|
||||
import TableHead from './table-head.vue'
|
||||
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
@@ -29,7 +30,7 @@ const props = withDefaults(defineProps<{
|
||||
align: 'left',
|
||||
title: undefined,
|
||||
filterActive: false,
|
||||
filterTitle: '筛选',
|
||||
filterTitle: undefined,
|
||||
filterContentClass: undefined,
|
||||
})
|
||||
|
||||
@@ -42,6 +43,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
const attrs = useAttrs()
|
||||
const { t } = useI18n()
|
||||
const slots = useSlots()
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const filterTriggerRef = ref<HTMLButtonElement | null>(null)
|
||||
@@ -65,12 +67,12 @@ const ariaSort = computed(() => {
|
||||
return props.direction === 'asc' ? 'ascending' : 'descending'
|
||||
})
|
||||
const wrapperClass = computed(() => cn(
|
||||
'relative flex w-full items-center gap-1.5',
|
||||
'relative flex min-w-0 w-full items-center gap-1.5',
|
||||
props.align === 'center' && 'justify-center',
|
||||
props.align === 'right' && 'justify-end',
|
||||
))
|
||||
const labelClass = computed(() => cn(
|
||||
'inline-flex min-w-0 items-center gap-1.5 text-xs font-semibold text-muted-foreground',
|
||||
'inline-flex min-w-0 items-center gap-1.5 whitespace-normal break-words text-left text-xs font-semibold leading-4 text-muted-foreground',
|
||||
props.align === 'center' && 'justify-center',
|
||||
props.align === 'right' && 'justify-end',
|
||||
))
|
||||
@@ -89,7 +91,7 @@ const filterButtonClass = computed(() => cn(
|
||||
: 'text-muted-foreground/60 hover:bg-muted/50 hover:text-foreground',
|
||||
))
|
||||
const filterPanelClass = computed(() => cn(
|
||||
'fixed z-[1000] w-64 rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none',
|
||||
'fixed z-[1000] w-64 max-w-[calc(100vw-1rem)] rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none',
|
||||
props.filterContentClass,
|
||||
))
|
||||
|
||||
@@ -185,7 +187,8 @@ onBeforeUnmount(() => {
|
||||
ref="filterTriggerRef"
|
||||
type="button"
|
||||
:class="filterButtonClass"
|
||||
:title="filterTitle"
|
||||
:title="filterTitle ?? t('common.filter')"
|
||||
:aria-label="filterTitle ?? t('common.filter')"
|
||||
:aria-pressed="filterActive"
|
||||
@click.stop="toggleFilter"
|
||||
>
|
||||
@@ -210,7 +213,7 @@ onBeforeUnmount(() => {
|
||||
v-if="canSort"
|
||||
type="button"
|
||||
:class="buttonClass"
|
||||
:title="title || '排序'"
|
||||
:title="title || t('common.sort')"
|
||||
@click="handleSort"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:disabled="disabled"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="[
|
||||
modelValue ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
@@ -28,4 +28,4 @@ defineProps<{
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick, inject, type Ref } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface Props {
|
||||
class?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const indicatorStyle = ref<Record<string, string>>({
|
||||
@@ -82,11 +84,7 @@ const updateIndicator = () => {
|
||||
// 确保按钮已渲染
|
||||
if (buttonRect.width === 0) return
|
||||
|
||||
// 计算相对位置:累加前面所有按钮的宽度
|
||||
let offsetLeft = 0
|
||||
for (let i = 0; i < newIndex; i++) {
|
||||
offsetLeft += buttons[i].getBoundingClientRect().width
|
||||
}
|
||||
const offsetLeft = activeButton.offsetLeft
|
||||
|
||||
// 判断是否需要动画:
|
||||
// 1. 首次初始化不需要动画
|
||||
@@ -123,7 +121,7 @@ const scheduleIndicatorUpdate = () => {
|
||||
|
||||
// 监听 activeTab 变化
|
||||
watch(
|
||||
() => activeTab?.value,
|
||||
() => [activeTab?.value, locale.value],
|
||||
() => {
|
||||
nextTick(() => {
|
||||
scheduleIndicatorUpdate()
|
||||
@@ -166,9 +164,11 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.tabs-list {
|
||||
position: relative;
|
||||
height: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
border-radius: 0.5rem;
|
||||
background-color: hsl(var(--muted) / 0.3);
|
||||
padding: 0.25rem;
|
||||
@@ -176,6 +176,12 @@ onUnmounted(() => {
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
}
|
||||
|
||||
.tabs-list.grid :deep(button[data-value]) {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tabs-indicator {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
|
||||
@@ -32,7 +32,7 @@ const handleClick = () => {
|
||||
|
||||
const triggerClass = computed(() => {
|
||||
return cn(
|
||||
'relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'relative z-10 inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium [&_svg]:shrink-0 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
isActive.value
|
||||
? 'text-foreground font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
|
||||
Reference in New Issue
Block a user