优化 2026 前端无障碍与响应式体验

完善 2026 主题的移动端与桌面端交互体验,修复多项可访问性和信息展示问题。

- 允许浏览器缩放,补充焦点样式、按钮名称、表单标签和对话框键盘焦点管理\n- 支持 reduced-motion,扩大公共页、后台导航、记录列表和分页控件的触控区域\n- 优化系统设置分区导航、密码显示、字段语义和底部粘性保存栏\n- 压缩移动文件列表操作区,改善桌面表格滚动可达性和统计值截断\n- 优化仪表盘指标、趋势摘要、进度条语义及中英文文案
This commit is contained in:
2026-07-21 11:03:13 +08:00
parent 2f020f5d42
commit b60c003ec2
29 changed files with 638 additions and 312 deletions
+102 -41
View File
@@ -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>