✨ 优化 2026 前端无障碍与响应式体验
完善 2026 主题的移动端与桌面端交互体验,修复多项可访问性和信息展示问题。 - 允许浏览器缩放,补充焦点样式、按钮名称、表单标签和对话框键盘焦点管理\n- 支持 reduced-motion,扩大公共页、后台导航、记录列表和分页控件的触控区域\n- 优化系统设置分区导航、密码显示、字段语义和底部粘性保存栏\n- 压缩移动文件列表操作区,改善桌面表格滚动可达性和统计值截断\n- 优化仪表盘指标、趋势摘要、进度条语义及中英文文案
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
<div
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
:role="alert.type === 'error' ? 'alert' : 'status'"
|
||||
:aria-live="alert.type === 'error' ? 'assertive' : 'polite'"
|
||||
:class="[
|
||||
'w-full rounded-[30px] shadow-xl overflow-hidden',
|
||||
'bg-gradient-to-r',
|
||||
@@ -23,8 +25,9 @@
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
type="button"
|
||||
@click="removeAlert(alert.id)"
|
||||
class="inline-flex text-white hover:text-gray-200 focus:outline-none transition-colors duration-200"
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-lg text-white transition-colors duration-200 hover:bg-white/10 hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-white"
|
||||
>
|
||||
<span class="sr-only">{{ t('common.close') }}</span>
|
||||
<X class="h-5 w-5" />
|
||||
|
||||
@@ -6,24 +6,20 @@
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
@click="handleBackdropClick"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
@click.stop="handleBackdropClick"
|
||||
></div>
|
||||
|
||||
<!-- 模态框容器 -->
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" aria-hidden="true"></div>
|
||||
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
ref="modalRef"
|
||||
class="relative flex max-h-[calc(100vh-2rem)] transform flex-col overflow-hidden rounded-[30px] shadow-xl transition-all"
|
||||
:class="[
|
||||
sizeClasses,
|
||||
isDarkMode ? 'bg-gray-800' : 'bg-white'
|
||||
]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title || t('common.dialog')"
|
||||
tabindex="-1"
|
||||
class="relative flex max-h-[calc(100dvh-2rem)] transform flex-col overflow-hidden rounded-[24px] shadow-xl transition-all sm:rounded-[30px]"
|
||||
:class="[sizeClasses, isDarkMode ? 'bg-gray-800' : 'bg-white']"
|
||||
@click.stop
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div
|
||||
v-if="$slots.header || title"
|
||||
class="flex shrink-0 items-center justify-between border-b px-4 py-3 sm:px-6 sm:py-4"
|
||||
@@ -36,27 +32,28 @@
|
||||
</slot>
|
||||
<button
|
||||
v-if="closable"
|
||||
@click="$emit('close')"
|
||||
type="button"
|
||||
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg transition-colors"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700'
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-100'
|
||||
? 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-800'
|
||||
]"
|
||||
:aria-label="t('common.close')"
|
||||
:title="t('common.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
<X class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6">
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6" tabindex="0">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 items-center justify-end space-x-3 border-t px-4 py-3 sm:px-6 sm:py-4"
|
||||
class="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t px-4 py-3 sm:px-6 sm:py-4"
|
||||
:class="[isDarkMode ? 'border-gray-700 bg-gray-900/50' : 'border-gray-200 bg-gray-50']"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
@@ -69,8 +66,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -86,48 +84,91 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
closeOnBackdrop: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { t } = useI18n()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const modalRef = ref<HTMLElement>()
|
||||
let returnFocusElement: HTMLElement | null = null
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
sm: 'max-w-md w-full',
|
||||
md: 'max-w-lg w-full',
|
||||
lg: 'max-w-2xl w-full',
|
||||
xl: 'max-w-4xl w-full'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
const sizeClasses = computed(() => ({
|
||||
sm: 'max-w-md w-full',
|
||||
md: 'max-w-lg w-full',
|
||||
lg: 'max-w-2xl w-full',
|
||||
xl: 'max-w-4xl w-full'
|
||||
})[props.size])
|
||||
|
||||
const focusableSelector =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (props.closeOnBackdrop) {
|
||||
if (props.closeOnBackdrop) emit('close')
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.closable) {
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab' || !modalRef.value) return
|
||||
const focusable = [...modalRef.value.querySelectorAll<HTMLElement>(focusableSelector)]
|
||||
if (!focusable.length) {
|
||||
event.preventDefault()
|
||||
modalRef.value.focus()
|
||||
return
|
||||
}
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (show) => {
|
||||
if (show) {
|
||||
returnFocusElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
await nextTick()
|
||||
const first = modalRef.value?.querySelector<HTMLElement>(focusableSelector)
|
||||
;(first || modalRef.value)?.focus()
|
||||
} else {
|
||||
await nextTick()
|
||||
returnFocusElement?.focus()
|
||||
returnFocusElement = null
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
transition: opacity 0.24s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .relative,
|
||||
.modal-leave-active .relative {
|
||||
transition: all 0.3s ease;
|
||||
transition: transform 0.24s ease, opacity 0.24s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .relative,
|
||||
.modal-leave-to .relative {
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
opacity: 0;
|
||||
transform: scale(0.97) translateY(-10px);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal-enter-active,
|
||||
.modal-leave-active,
|
||||
.modal-enter-active .relative,
|
||||
.modal-leave-active .relative {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,53 +1,46 @@
|
||||
<template>
|
||||
<div
|
||||
class="mt-4 flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
class="mt-4 flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6"
|
||||
:class="[isDarkMode ? 'border-[#31454d]' : 'border-[#c8d7da]']"
|
||||
>
|
||||
<div
|
||||
class="flex items-center text-sm"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
>
|
||||
显示第 {{ (currentPage - 1) * pageSize + 1 }} 到
|
||||
{{ Math.min(currentPage * pageSize, total) }} 条,共 {{ total }} 条
|
||||
<div class="text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
|
||||
{{ t('components.pagination.range', { start: rangeStart, end: rangeEnd, total }) }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
:value="pageSize"
|
||||
class="h-9 rounded-[30px] border px-3 text-sm shadow-sm transition-colors"
|
||||
class="min-h-11 rounded-[30px] border px-3 text-sm shadow-sm transition-colors sm:min-h-9"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'border-[#40545c] bg-[#263941] text-[#d8e3e5] hover:border-[#7199a8]'
|
||||
: 'border-[#c8d8dc] bg-[#edf4f5] text-[#415d66] hover:border-[#9db6bd]'
|
||||
]"
|
||||
:aria-label="t('components.pagination.pageSize')"
|
||||
@change="$emit('page-size-change', Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}/页</option>
|
||||
<option v-for="size in pageSizeOptions" :key="size" :value="size">
|
||||
{{ t('components.pagination.perPage', { size }) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
@click="$emit('page-change', currentPage - 1)"
|
||||
type="button"
|
||||
:disabled="currentPage === 1"
|
||||
class="inline-flex items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? currentPage === 1
|
||||
? 'cursor-not-allowed bg-[#223039] text-[#657a80]'
|
||||
: 'bg-[#263941] text-[#d8e3e5] hover:bg-[#2d4751]'
|
||||
: currentPage === 1
|
||||
? 'cursor-not-allowed bg-[#e8eeee] text-[#9aa9ad]'
|
||||
: 'bg-[#edf4f5] text-[#415d66] hover:bg-[#e2ecee]'
|
||||
]"
|
||||
class="inline-flex min-h-11 items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md sm:min-h-9"
|
||||
:class="buttonClass(currentPage === 1)"
|
||||
@click="$emit('page-change', currentPage - 1)"
|
||||
>
|
||||
<ChevronLeftIcon class="w-4 h-4" />
|
||||
上一页
|
||||
<ChevronLeftIcon class="h-4 w-4" aria-hidden="true" />
|
||||
{{ t('common.previous') }}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="pageNum in displayedPages" :key="pageNum">
|
||||
<button
|
||||
v-if="pageNum !== '...'"
|
||||
@click="$emit('page-change', pageNum as number)"
|
||||
class="inline-flex items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md"
|
||||
type="button"
|
||||
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md sm:min-h-9 sm:min-w-9"
|
||||
:class="[
|
||||
currentPage === pageNum
|
||||
? 'bg-[#5f8796] text-white shadow-[#9fb8bf]/35'
|
||||
@@ -55,39 +48,34 @@
|
||||
? 'bg-[#263941] text-[#d8e3e5] hover:bg-[#2d4751]'
|
||||
: 'bg-[#edf4f5] text-[#415d66] hover:bg-[#e2ecee]'
|
||||
]"
|
||||
:aria-label="t('components.pagination.goToPage', { page: pageNum })"
|
||||
:aria-current="currentPage === pageNum ? 'page' : undefined"
|
||||
@click="$emit('page-change', pageNum as number)"
|
||||
>
|
||||
{{ pageNum }}
|
||||
</button>
|
||||
<span v-else class="px-2" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
||||
...
|
||||
</span>
|
||||
<span v-else class="px-1" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">…</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="$emit('page-change', currentPage + 1)"
|
||||
type="button"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="inline-flex items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? currentPage >= totalPages
|
||||
? 'cursor-not-allowed bg-[#223039] text-[#657a80]'
|
||||
: 'bg-[#263941] text-[#d8e3e5] hover:bg-[#2d4751]'
|
||||
: currentPage >= totalPages
|
||||
? 'cursor-not-allowed bg-[#e8eeee] text-[#9aa9ad]'
|
||||
: 'bg-[#edf4f5] text-[#415d66] hover:bg-[#e2ecee]'
|
||||
]"
|
||||
class="inline-flex min-h-11 items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md sm:min-h-9"
|
||||
:class="buttonClass(currentPage >= totalPages)"
|
||||
@click="$emit('page-change', currentPage + 1)"
|
||||
>
|
||||
下一页
|
||||
<ChevronRightIcon class="w-4 h-4" />
|
||||
{{ t('common.next') }}
|
||||
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, computed } from 'vue'
|
||||
import { computed, inject, type Ref } from 'vue'
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
currentPage: number
|
||||
@@ -105,44 +93,33 @@ defineEmits<{
|
||||
'page-size-change': [size: number]
|
||||
}>()
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const isDarkMode = inject<Ref<boolean>>('isDarkMode', computed(() => false))
|
||||
const { t } = useI18n()
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||
const rangeStart = computed(() => (props.total ? (props.currentPage - 1) * props.pageSize + 1 : 0))
|
||||
const rangeEnd = computed(() => Math.min(props.currentPage * props.pageSize, props.total))
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
|
||||
|
||||
// 计算要显示的页码
|
||||
const displayedPages = computed(() => {
|
||||
const current = props.currentPage
|
||||
const total = totalPages.value
|
||||
const delta = 2 // 当前页码前后显示的页码数
|
||||
|
||||
const pages: (number | string)[] = []
|
||||
|
||||
// 始终显示第一页
|
||||
pages.push(1)
|
||||
|
||||
// 计算显示范围
|
||||
const left = Math.max(2, current - delta)
|
||||
const right = Math.min(total - 1, current + delta)
|
||||
|
||||
// 添加省略号和页码
|
||||
if (left > 2) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
for (let i = left; i <= right; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (right < total - 1) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
// 始终显示最后一页
|
||||
if (total > 1) {
|
||||
pages.push(total)
|
||||
}
|
||||
|
||||
const pages: (number | string)[] = [1]
|
||||
const left = Math.max(2, current - 2)
|
||||
const right = Math.min(total - 1, current + 2)
|
||||
if (left > 2) pages.push('...')
|
||||
for (let i = left; i <= right; i++) pages.push(i)
|
||||
if (right < total - 1) pages.push('...')
|
||||
if (total > 1) pages.push(total)
|
||||
return pages
|
||||
})
|
||||
|
||||
const buttonClass = (disabled: boolean) => {
|
||||
if (isDarkMode.value) {
|
||||
return disabled
|
||||
? 'cursor-not-allowed bg-[#223039] text-[#71878d]'
|
||||
: 'bg-[#263941] text-[#d8e3e5] hover:bg-[#2d4751]'
|
||||
}
|
||||
return disabled
|
||||
? 'cursor-not-allowed bg-[#e8eeee] text-[#7d8d91]'
|
||||
: 'bg-[#edf4f5] text-[#415d66] hover:bg-[#e2ecee]'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -18,16 +18,18 @@
|
||||
<slot name="toolbar"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-x-auto" tabindex="0" role="region" :aria-label="title">
|
||||
<table
|
||||
class="min-w-full divide-y"
|
||||
:class="[isDarkMode ? 'divide-[#31454d]' : 'divide-[#d7e3e5]']"
|
||||
class="divide-y"
|
||||
:class="[tableClass, isDarkMode ? 'divide-[#31454d]' : 'divide-[#d7e3e5]']"
|
||||
>
|
||||
<caption class="sr-only">{{ title }}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="header in headers"
|
||||
:key="header"
|
||||
scope="col"
|
||||
class="px-6 py-3.5 text-left text-xs font-medium uppercase tracking-wider"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||
>
|
||||
@@ -53,9 +55,12 @@ import { inject, useSlots } from 'vue'
|
||||
interface Props {
|
||||
title: string
|
||||
headers: string[]
|
||||
tableClass?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
withDefaults(defineProps<Props>(), {
|
||||
tableClass: 'min-w-full'
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<label :class="['text-sm font-medium', isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
<label for="send-expiration-value" :class="['text-sm font-medium', isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ t('send.expiration.label') }}
|
||||
</label>
|
||||
<div class="relative flex-grow group">
|
||||
@@ -14,6 +14,7 @@
|
||||
>
|
||||
<template v-if="expirationMethod !== 'forever'">
|
||||
<input
|
||||
id="send-expiration-value"
|
||||
:value="expirationValue"
|
||||
@input="updateValue"
|
||||
type="number"
|
||||
@@ -35,6 +36,7 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('send.expiration.increment')"
|
||||
@click="incrementValue(1)"
|
||||
class="flex-1 px-2 flex items-center justify-center transition-all duration-200"
|
||||
:class="[
|
||||
@@ -54,6 +56,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('send.expiration.decrement')"
|
||||
@click="incrementValue(-1)"
|
||||
class="flex-1 px-2 flex items-center justify-center transition-all duration-200"
|
||||
:class="[
|
||||
@@ -74,6 +77,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<select
|
||||
:aria-label="t('send.expiration.method')"
|
||||
:value="expirationMethod"
|
||||
@change="updateMethod"
|
||||
:class="[
|
||||
|
||||
@@ -29,8 +29,11 @@
|
||||
</div>
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('fileRecord.viewDetails')"
|
||||
:title="t('fileRecord.viewDetails')"
|
||||
@click="$emit('view-details', record)"
|
||||
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'hover:bg-indigo-400 text-indigo-400'
|
||||
@@ -40,8 +43,11 @@
|
||||
<EyeIcon class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('fileRecord.download')"
|
||||
:title="t('fileRecord.download')"
|
||||
@click="$emit('download-record', record)"
|
||||
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'hover:bg-green-400 text-green-400'
|
||||
@@ -51,8 +57,11 @@
|
||||
<DownloadIcon class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('fileRecord.deleteRecord')"
|
||||
:title="t('fileRecord.deleteRecord')"
|
||||
@click="$emit('delete-record', record.id)"
|
||||
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
|
||||
:class="[
|
||||
isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600'
|
||||
]"
|
||||
@@ -69,6 +78,7 @@
|
||||
import { inject } from 'vue'
|
||||
import { FileIcon, EyeIcon, DownloadIcon, TrashIcon } from 'lucide-vue-next'
|
||||
import type { ReceivedFileRecord } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
records: ReceivedFileRecord[]
|
||||
@@ -83,6 +93,7 @@ interface Emits {
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<template>
|
||||
<div ref="switcherRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleDropdown"
|
||||
:class="[
|
||||
'flex items-center space-x-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||
'flex min-h-11 items-center space-x-2 rounded-lg px-3 py-2 transition-all duration-200',
|
||||
isDarkMode
|
||||
? 'bg-gray-800/60 hover:bg-gray-700/80 text-gray-300 hover:text-white'
|
||||
: 'bg-gray-100 hover:bg-gray-200 text-gray-700 hover:text-gray-900'
|
||||
]"
|
||||
:aria-expanded="isDropdownOpen"
|
||||
aria-haspopup="listbox"
|
||||
:aria-label="t('common.language')"
|
||||
>
|
||||
<GlobeIcon class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">{{ currentLanguage.name }}</span>
|
||||
@@ -29,6 +33,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="isDropdownOpen"
|
||||
role="listbox"
|
||||
:class="[
|
||||
'absolute right-0 mt-2 w-32 rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 z-50',
|
||||
isDarkMode
|
||||
@@ -40,9 +45,12 @@
|
||||
<button
|
||||
v-for="language in availableLocales"
|
||||
:key="language.code"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="currentLocale === language.code"
|
||||
@click="switchLanguage(language.code)"
|
||||
:class="[
|
||||
'w-full text-left px-4 py-2 text-sm transition-colors duration-150',
|
||||
'min-h-11 w-full px-4 py-2 text-left text-sm transition-colors duration-150',
|
||||
currentLocale === language.code
|
||||
? isDarkMode
|
||||
? 'bg-indigo-600 text-white'
|
||||
@@ -66,7 +74,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { GlobeIcon, ChevronDownIcon } from 'lucide-vue-next'
|
||||
import { availableLocales, setLocale } from '@/i18n/index'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const { locale, t } = useI18n()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const isDropdownOpen = ref(false)
|
||||
const switcherRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="mb-6 text-center" v-if="linkText && linkTo">
|
||||
<router-link
|
||||
:to="linkTo"
|
||||
class="text-indigo-400 hover:text-indigo-300 transition duration-300"
|
||||
class="inline-flex min-h-11 items-center rounded-lg px-3 text-[#3f6a77] transition duration-200 hover:bg-[#dcebed] hover:text-[#315c69] dark:text-[#b8d4dc] dark:hover:bg-[#294047]"
|
||||
>
|
||||
{{ linkText }}
|
||||
</router-link>
|
||||
@@ -23,7 +23,9 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<router-link
|
||||
to="/login"
|
||||
class="text-sm hover:text-indigo-300 transition duration-300 flex items-center"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-lg text-sm transition duration-200 hover:bg-[#dcebed] dark:hover:bg-[#294047]"
|
||||
:aria-label="t('admin.login.title')"
|
||||
:title="t('admin.login.title')"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-indigo-400'
|
||||
@@ -33,8 +35,9 @@
|
||||
<UserIcon class="w-4 h-4" />
|
||||
</router-link>
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('toggle-drawer')"
|
||||
class="text-sm hover:text-indigo-300 transition duration-300 flex items-center"
|
||||
class="flex min-h-11 items-center rounded-lg px-2 text-sm transition duration-200 hover:bg-[#dcebed] dark:hover:bg-[#294047]"
|
||||
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
|
||||
>
|
||||
{{ drawerText }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<div class="flex justify-center mb-8">
|
||||
<div class="mb-5 flex justify-center" aria-hidden="true">
|
||||
<div
|
||||
class="rounded-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 p-1 animate-spin-slow"
|
||||
>
|
||||
@@ -9,17 +9,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('title-click')"
|
||||
class="text-3xl cursor-pointer font-extrabold text-center mb-6"
|
||||
class="mb-6 min-h-11 rounded-lg px-2 text-center text-3xl font-extrabold focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'text-transparent bg-clip-text bg-gradient-to-r from-indigo-300 via-purple-300 to-pink-300'
|
||||
: 'text-indigo-600'
|
||||
]"
|
||||
:aria-label="title"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -54,4 +56,10 @@ const isDarkMode = inject('isDarkMode')
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 3s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-spin-slow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,8 @@
|
||||
id="code"
|
||||
v-model="code"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="one-time-code"
|
||||
ref="codeInput"
|
||||
class="w-full px-4 py-3 rounded-lg placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition duration-300 pr-10"
|
||||
:class="[
|
||||
@@ -21,6 +23,7 @@
|
||||
]"
|
||||
:placeholder="t('retrieve.codeInput.placeholder')"
|
||||
required
|
||||
:aria-invalid="error"
|
||||
:readonly="inputStatus.readonly"
|
||||
maxlength="5"
|
||||
@focus="isInputFocused = true"
|
||||
@@ -42,8 +45,8 @@
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white font-bold py-3 px-4 rounded-lg hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-opacity-50 transition duration-300 transform hover:scale-105 hover:shadow-lg relative overflow-hidden group"
|
||||
:disabled="inputStatus.loading"
|
||||
class="group relative w-full overflow-hidden rounded-lg bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 px-4 py-3 font-bold text-white transition duration-300 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 motion-safe:hover:scale-[1.02] disabled:hover:scale-100"
|
||||
:disabled="inputStatus.loading || code.trim().length !== 5"
|
||||
>
|
||||
<span class="flex items-center justify-center relative z-10">
|
||||
<span>{{ inputStatus.loading ? t('common.loading') : t('retrieve.submit') }}</span>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
type="button"
|
||||
@click="selectType('file')"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg transition-colors duration-300',
|
||||
'min-h-11 px-4 py-2 rounded-lg transition-colors duration-300',
|
||||
selectedType === 'file'
|
||||
? 'bg-indigo-600 text-white'
|
||||
: isDarkMode
|
||||
@@ -18,7 +18,7 @@
|
||||
type="button"
|
||||
@click="selectType('text')"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg transition-colors duration-300',
|
||||
'min-h-11 px-4 py-2 rounded-lg transition-colors duration-300',
|
||||
selectedType === 'text'
|
||||
? 'bg-indigo-600 text-white'
|
||||
: isDarkMode
|
||||
|
||||
@@ -26,8 +26,11 @@
|
||||
</div>
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('common.copy')"
|
||||
:title="t('common.copy')"
|
||||
@click="$emit('copy-link', record)"
|
||||
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
|
||||
:class="[
|
||||
isDarkMode ? 'hover:bg-blue-400 text-blue-400' : 'hover:bg-blue-100 text-blue-600'
|
||||
]"
|
||||
@@ -35,8 +38,11 @@
|
||||
<ClipboardCopyIcon class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('fileRecord.viewDetails')"
|
||||
:title="t('fileRecord.viewDetails')"
|
||||
@click="$emit('view-details', record)"
|
||||
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'hover:bg-green-400 text-green-400'
|
||||
@@ -46,8 +52,11 @@
|
||||
<EyeIcon class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('fileRecord.deleteRecord')"
|
||||
:title="t('fileRecord.deleteRecord')"
|
||||
@click="$emit('delete-record', record.id)"
|
||||
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
|
||||
:class="[
|
||||
isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600'
|
||||
]"
|
||||
@@ -64,6 +73,7 @@
|
||||
import { inject } from 'vue'
|
||||
import { ClipboardCopyIcon, EyeIcon, FileIcon, TrashIcon } from 'lucide-vue-next'
|
||||
import type { SentFileRecord } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
records: SentFileRecord[]
|
||||
@@ -76,6 +86,7 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
<label :for="inputId" class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:id="inputId"
|
||||
type="number"
|
||||
:value="modelValue"
|
||||
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
|
||||
class="min-h-11 w-28 rounded-md border px-4 py-2.5 shadow-sm outline-none transition-all duration-200 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
|
||||
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
|
||||
? 'border-gray-600 bg-gray-700 text-white hover:border-gray-500'
|
||||
: 'border-gray-300 bg-white text-gray-900 hover:border-gray-400'
|
||||
]"
|
||||
@input="handleInput"
|
||||
/>
|
||||
@@ -21,19 +22,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: number
|
||||
suffix: string
|
||||
}>()
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
const props = defineProps<{ label: string; modelValue: number; suffix: string }>()
|
||||
const inputId = computed(() => `setting-number-${props.label.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, '-')}`)
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
@@ -41,10 +35,7 @@ const handleInput = (event: Event) => {
|
||||
emit('update:modelValue', 0)
|
||||
return
|
||||
}
|
||||
|
||||
const nextValue = input.valueAsNumber
|
||||
if (!Number.isNaN(nextValue)) {
|
||||
emit('update:modelValue', nextValue)
|
||||
}
|
||||
if (!Number.isNaN(nextValue)) emit('update:modelValue', nextValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
<span :id="labelId" class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="flex items-center">
|
||||
</span>
|
||||
<div class="flex min-h-11 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent shadow-sm transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:ring-offset-2"
|
||||
:class="[
|
||||
modelValue === 1
|
||||
? 'bg-[#6f9688]'
|
||||
: isDarkMode
|
||||
? 'bg-[#40545c]'
|
||||
: 'bg-[#cfdadc]'
|
||||
]"
|
||||
class="flex h-11 w-14 shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:ring-offset-2"
|
||||
role="switch"
|
||||
:aria-checked="modelValue === 1"
|
||||
:aria-labelledby="labelId"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
:class="[
|
||||
modelValue === 1 ? 'translate-x-5' : 'translate-x-0',
|
||||
isDarkMode && modelValue !== 1 ? 'bg-gray-100' : 'bg-white'
|
||||
]"
|
||||
/>
|
||||
class="relative inline-flex h-6 w-11 rounded-full border-2 border-transparent shadow-sm transition-colors duration-200"
|
||||
:class="modelValue === 1 ? 'bg-[#6f9688]' : isDarkMode ? 'bg-[#40545c]' : 'bg-[#cfdadc]'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200"
|
||||
:class="modelValue === 1 ? 'translate-x-5' : 'translate-x-0'"
|
||||
></span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
<span class="text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
|
||||
{{ modelValue === 1 ? enabledText : disabledText }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -34,18 +31,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
modelValue: number
|
||||
enabledText: string
|
||||
disabledText: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
const props = defineProps<{ label: string; modelValue: number; enabledText: string; disabledText: string }>()
|
||||
defineEmits<{ toggle: [] }>()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const labelId = computed(() => `setting-switch-${props.label.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, '-')}`)
|
||||
</script>
|
||||
|
||||
@@ -1,63 +1,124 @@
|
||||
<template>
|
||||
<transition name="drawer">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-y-0 right-0 w-full sm:w-120 bg-opacity-70 backdrop-filter backdrop-blur-xl shadow-2xl z-50 overflow-hidden flex flex-col"
|
||||
:class="[isDarkMode ? 'bg-gray-900' : 'bg-white']"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between items-center p-6 border-b"
|
||||
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
|
||||
>
|
||||
<h3 class="text-2xl font-bold" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<Teleport to="body">
|
||||
<transition name="drawer">
|
||||
<div v-if="visible" class="fixed inset-0 z-50">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="hover:text-white transition duration-300"
|
||||
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-800']"
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/45"
|
||||
:aria-label="t('common.close')"
|
||||
@click="emit('close')"
|
||||
></button>
|
||||
<aside
|
||||
ref="drawerRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
tabindex="-1"
|
||||
class="absolute inset-y-0 right-0 flex w-full flex-col overflow-hidden bg-opacity-95 shadow-2xl backdrop-blur-xl sm:w-[30rem]"
|
||||
:class="[isDarkMode ? 'bg-gray-900' : 'bg-white']"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<XIcon class="w-6 h-6" />
|
||||
</button>
|
||||
<div
|
||||
class="flex items-center justify-between border-b p-4 sm:p-6"
|
||||
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
|
||||
>
|
||||
<h3 class="text-xl font-bold sm:text-2xl" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-lg transition duration-200"
|
||||
:class="[isDarkMode ? 'text-gray-300 hover:bg-gray-800 hover:text-white' : 'text-gray-700 hover:bg-gray-100']"
|
||||
:aria-label="t('common.close')"
|
||||
:title="t('common.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<XIcon class="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" tabindex="0">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { inject, nextTick, ref, watch } from 'vue'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
title: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
close: []
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
const props = defineProps<{ visible: boolean; title: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { t } = useI18n()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const drawerRef = ref<HTMLElement>()
|
||||
let returnFocusElement: HTMLElement | null = null
|
||||
const focusableSelector =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab' || !drawerRef.value) return
|
||||
const focusable = [...drawerRef.value.querySelectorAll<HTMLElement>(focusableSelector)]
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (!first || !last) return
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (visible) => {
|
||||
if (visible) {
|
||||
returnFocusElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
await nextTick()
|
||||
const first = drawerRef.value?.querySelector<HTMLElement>(focusableSelector)
|
||||
;(first || drawerRef.value)?.focus()
|
||||
} else {
|
||||
await nextTick()
|
||||
returnFocusElement?.focus()
|
||||
returnFocusElement = null
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
transition: opacity 0.24s ease;
|
||||
}
|
||||
.drawer-enter-active aside,
|
||||
.drawer-leave-active aside {
|
||||
transition: transform 0.24s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.drawer-enter-from aside,
|
||||
.drawer-leave-to aside {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:w-120 {
|
||||
width: 30rem;
|
||||
/* 480px */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active,
|
||||
.drawer-enter-active aside,
|
||||
.drawer-leave-active aside {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs sm:text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
|
||||
<p class="line-clamp-2 text-xs leading-tight sm:text-sm" :title="title" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
|
||||
{{ title }}
|
||||
</p>
|
||||
<h3 class="mt-1 truncate text-xl font-bold tabular-nums sm:text-2xl" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
||||
<h3 class="mt-1 break-words text-xl font-bold leading-tight tabular-nums sm:text-2xl" :title="String(value)" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
||||
{{ value }}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -20,10 +20,10 @@
|
||||
class="shrink-0 rounded-full p-2.5 transition-transform duration-300 group-hover:scale-105 sm:p-3"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<component :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" :class="iconClass" />
|
||||
<component :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" :class="iconClass" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 line-clamp-1 text-xs sm:text-sm" :class="descriptionClass">
|
||||
<p class="mt-2 line-clamp-2 text-xs sm:text-sm" :class="descriptionClass">
|
||||
<slot name="description"></slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { SunIcon, MoonIcon } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const isDarkMode = inject('isDarkMode') as { value: boolean }
|
||||
const toggleTheme = inject('toggleTheme') as () => void
|
||||
const { t } = useI18n()
|
||||
|
||||
const toggleColorMode = () => {
|
||||
toggleTheme()
|
||||
@@ -12,8 +14,11 @@ const toggleColorMode = () => {
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleColorMode"
|
||||
class="rounded-full p-2 shadow-sm transition-all duration-500 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:ring-offset-2 transform hover:rotate-180"
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full shadow-sm transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:ring-offset-2 motion-safe:hover:rotate-180"
|
||||
:aria-label="isDarkMode ? t('common.switchToLight') : t('common.switchToDark')"
|
||||
:title="isDarkMode ? t('common.switchToLight') : t('common.switchToDark')"
|
||||
:class="
|
||||
isDarkMode
|
||||
? 'bg-[#263941] text-[#d8c98e]'
|
||||
|
||||
Reference in New Issue
Block a user