b60c003ec2
完善 2026 主题的移动端与桌面端交互体验,修复多项可访问性和信息展示问题。 - 允许浏览器缩放,补充焦点样式、按钮名称、表单标签和对话框键盘焦点管理\n- 支持 reduced-motion,扩大公共页、后台导航、记录列表和分页控件的触控区域\n- 优化系统设置分区导航、密码显示、字段语义和底部粘性保存栏\n- 压缩移动文件列表操作区,改善桌面表格滚动可达性和统计值截断\n- 优化仪表盘指标、趋势摘要、进度条语义及中英文文案
125 lines
3.8 KiB
Vue
125 lines
3.8 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<transition name="drawer">
|
|
<div v-if="visible" class="fixed inset-0 z-50">
|
|
<button
|
|
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"
|
|
>
|
|
<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>
|
|
</transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { inject, nextTick, ref, watch } from 'vue'
|
|
import { XIcon } from 'lucide-vue-next'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
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: 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 (prefers-reduced-motion: reduce) {
|
|
.drawer-enter-active,
|
|
.drawer-leave-active,
|
|
.drawer-enter-active aside,
|
|
.drawer-leave-active aside {
|
|
transition-duration: 0.01ms;
|
|
}
|
|
}
|
|
</style>
|