原来的遮罩压得太重(浅色 slate-900/40、深色 black/60),打开抽屉后左边几乎 是一片灰,看不出底下是什么。改为浅色 slate-900/25、深色 black/40,并加 backdrop-blur-sm:背景仍然看得见轮廓,模糊把它推远,不和面板抢焦点。 补三条测试盯住这个观感:遮罩必须半透明、必须带模糊、必须铺满视口且在 面板之下。
133 lines
3.6 KiB
Vue
133 lines
3.6 KiB
Vue
<script setup lang="ts">
|
||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||
|
||
const props = defineProps<{ open: boolean; title?: string }>()
|
||
|
||
const emit = defineEmits<{ close: [] }>()
|
||
|
||
const panelRef = ref<HTMLElement | null>(null)
|
||
let previouslyFocused: HTMLElement | null = null
|
||
|
||
const FOCUSABLE_SELECTOR = [
|
||
'a[href]',
|
||
'button:not([disabled])',
|
||
'input:not([disabled])',
|
||
'select:not([disabled])',
|
||
'textarea:not([disabled])',
|
||
'[tabindex]:not([tabindex="-1"])',
|
||
].join(', ')
|
||
|
||
function focusableElements(): HTMLElement[] {
|
||
const root = panelRef.value
|
||
if (!root) return []
|
||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
|
||
}
|
||
|
||
/** ESC 关闭 + Tab 焦点陷阱 */
|
||
function onDocumentKeydown(event: KeyboardEvent): void {
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault()
|
||
emit('close')
|
||
return
|
||
}
|
||
|
||
if (event.key !== 'Tab') return
|
||
|
||
const items = focusableElements()
|
||
if (items.length === 0) {
|
||
event.preventDefault()
|
||
panelRef.value?.focus()
|
||
return
|
||
}
|
||
|
||
const first = items[0]
|
||
const last = items[items.length - 1]
|
||
const active = document.activeElement as HTMLElement | null
|
||
const inside = active !== null && items.includes(active)
|
||
|
||
if (event.shiftKey) {
|
||
if (!inside || active === first) {
|
||
event.preventDefault()
|
||
last?.focus()
|
||
}
|
||
} else if (!inside || active === last) {
|
||
event.preventDefault()
|
||
first?.focus()
|
||
}
|
||
}
|
||
|
||
function stopListening(): void {
|
||
document.removeEventListener('keydown', onDocumentKeydown, true)
|
||
}
|
||
|
||
watch(
|
||
() => props.open,
|
||
(isOpen) => {
|
||
if (isOpen) {
|
||
previouslyFocused = document.activeElement as HTMLElement | null
|
||
document.addEventListener('keydown', onDocumentKeydown, true)
|
||
void nextTick(() => {
|
||
const target = focusableElements()[0] ?? panelRef.value
|
||
target?.focus()
|
||
})
|
||
} else {
|
||
stopListening()
|
||
previouslyFocused?.focus?.()
|
||
previouslyFocused = null
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
onBeforeUnmount(stopListening)
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport to="body">
|
||
<div v-if="open" class="fixed inset-0 z-40">
|
||
<!--
|
||
遮罩:压暗 + 轻微模糊。刻意压得比较轻,让底下的看板仍然看得见轮廓,
|
||
只把注意力让给抽屉;模糊负责把背景「推远」,避免和面板内容抢焦点。
|
||
-->
|
||
<div
|
||
class="absolute inset-0 bg-slate-900/25 backdrop-blur-sm dark:bg-black/40"
|
||
@click="emit('close')"
|
||
/>
|
||
|
||
<aside
|
||
ref="panelRef"
|
||
class="absolute inset-y-0 right-0 flex w-full max-w-md flex-col bg-white shadow-panel outline-none dark:bg-zinc-900"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:aria-label="title"
|
||
tabindex="-1"
|
||
>
|
||
<header
|
||
class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-zinc-800"
|
||
>
|
||
<h2 class="text-base font-medium text-slate-800 dark:text-zinc-100">{{ title }}</h2>
|
||
<button
|
||
type="button"
|
||
class="rounded px-2 py-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
||
aria-label="关闭"
|
||
@click="emit('close')"
|
||
>
|
||
✕
|
||
</button>
|
||
</header>
|
||
|
||
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||
<slot />
|
||
</div>
|
||
|
||
<footer
|
||
v-if="$slots.footer"
|
||
class="border-t border-slate-200 px-4 py-3 dark:border-zinc-800"
|
||
>
|
||
<slot name="footer" />
|
||
</footer>
|
||
</aside>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|