Files
FileCodeBoxFronted2026/src/components/common/SideDrawer.vue
T
orion b60c003ec2 优化 2026 前端无障碍与响应式体验
完善 2026 主题的移动端与桌面端交互体验,修复多项可访问性和信息展示问题。

- 允许浏览器缩放,补充焦点样式、按钮名称、表单标签和对话框键盘焦点管理\n- 支持 reduced-motion,扩大公共页、后台导航、记录列表和分页控件的触控区域\n- 优化系统设置分区导航、密码显示、字段语义和底部粘性保存栏\n- 压缩移动文件列表操作区,改善桌面表格滚动可达性和统计值截断\n- 优化仪表盘指标、趋势摘要、进度条语义及中英文文案
2026-07-21 11:05:23 +08:00

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>