✨ 优化 2026 前端无障碍与响应式体验
完善 2026 主题的移动端与桌面端交互体验,修复多项可访问性和信息展示问题。 - 允许浏览器缩放,补充焦点样式、按钮名称、表单标签和对话框键盘焦点管理\n- 支持 reduced-motion,扩大公共页、后台导航、记录列表和分页控件的触控区域\n- 优化系统设置分区导航、密码显示、字段语义和底部粘性保存栏\n- 压缩移动文件列表操作区,改善桌面表格滚动可达性和统计值截断\n- 优化仪表盘指标、趋势摘要、进度条语义及中英文文案
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user