- 深色里沿用浅色那套低透明度黑色阴影没有对比度(黑投影落在 zinc-950 上), 悬停和弹层都看不出「浮起来」,本次为深色单独配一套阴影与状态色 - 卡片:静止态改「近距投影 + 顶部内高光」;悬停改「外扩暗晕 + 极淡白光晕 + 内高光 12%」,同时底色提亮一档(zinc-900 → zinc-800)、边框提到 zinc-600, 位移补偿到 2px —— 暗底上 1px 的移动肉眼看不出来 - 弹层:新增 shadow-pop-dark / shadow-panel-dark 标记类,只挂给深色底面的 下拉与抽屉;toast 在深色下仍是亮色块(dark:bg-zinc-100),保留原黑色投影, 避免亮块套白光晕发脏 - 抽屉补 dark:border-l:它在深色下既无边框又无可视阴影,与背景糊成一片 - 实现说明:Tailwind v4 会把 @theme 的阴影值内联进 --tw-shadow,覆盖 CSS 变量 对已生成的工具类无效,因此直接重写 box-shadow;规则不放进 @layer 以便稳定 压过原子类,且选择器需按 hover: 变体转义后的类名书写 验证:vue-tsc -b 与 eslint src 干净;TaskCard / SideDrawer / TagFilter / TagInput / BatchSelectionBar 共 114 例单测通过;另用 Playwright 采样像素亮度 与 getComputedStyle 确认深浅两套阴影各自命中。
145 lines
4.1 KiB
Vue
145 lines
4.1 KiB
Vue
<script setup lang="ts">
|
||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||
|
||
const props = defineProps<{
|
||
open: boolean
|
||
title?: string
|
||
/**
|
||
* 打开时优先聚焦的元素(CSS 选择器)。
|
||
* 默认聚焦面板内第一个可聚焦元素,也就是常驻在头部的「关闭」按钮;
|
||
* 需要「一打开就能输入」的面板(如批量添加)可用它把焦点放到正文控件上。
|
||
*/
|
||
initialFocus?: 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 preferred = props.initialFocus
|
||
? (panelRef.value?.querySelector<HTMLElement>(props.initialFocus) ?? null)
|
||
: null
|
||
const target = preferred ?? 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 shadow-panel-dark outline-none dark:border-l dark:border-zinc-800 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>
|