Files
Kanban/src/components/SideDrawer.vue
T
chenshoulin 69a868a7b1
CI / verify (push) Successful in 1m40s
CI / 部署到服务器 (push) Successful in 27s
fix(ui): 深色模式下补齐卡片与弹层的层次感
- 深色里沿用浅色那套低透明度黑色阴影没有对比度(黑投影落在 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 确认深浅两套阴影各自命中。
2026-10-23 08:16:07 +08:00

145 lines
4.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>