feat(ui): 重做深色悬停与键盘焦点,补齐弹层出场动效
悬停:上一版靠「底色跳两档 + 四边边框一起提亮 + 外圈白光晕 + 白环」制造悬浮感, 五个变化同向叠加,结果是整张卡片均匀发亮、看着像换了张更浅的卡,光晕还把边缘糊住。 改用明暗方向表达厚度:新增受光层伪元素(顶部白 7% → 底部黑 14%,悬停淡入), 配一道锐利的接触阴影压实边界、一层远处柔化投影作高度暗示;底色与边框各只提一档。 过渡 150ms → 200ms ease-out。新增按下态:位移归零、阴影收紧、受光减弱,卡片「落回纸面」。 焦点:全局描边在深色下掺透明度柔化(sky-400 78%);卡片被 :focus-visible 时与悬停 一致地抬起并受光,焦点环退为最后一道提示,不再是一圈生硬方框。顺带删掉 :focus-visible 里的 border-radius: 4px —— 对带 rounded-* 的元素无效(layer 优先级), 对没圆角的元素反而会让聚焦瞬间跳一下。 动效:新增 pop-in(160ms) / sheet-in(260ms) / scrim-in(200ms) 三组关键帧, 下拉从锚点上方落入、抽屉自右侧滑入、遮罩淡入,组件上一律配 motion-reduce:animate-none。 只做入场不做退场:关闭是用户主动取消,瞬时消失更利落;做退场要引入 Vue Transition, 会牵扯 SideDrawer 的焦点与卸载时序,收益不抵风险。 实现要点: - Tailwind v4 会把 @theme 的阴影值内联进 --tw-shadow,覆盖 CSS 变量对已生成的工具类 无效,深色阴影因此直接重写 box-shadow,且规则不放进 @layer 以压过原子类; 选择器要按 hover: 变体转义后的类名书写(.hover\:shadow-card-hover),否则静默失效 - 焦点态位移不能只改 --tw-translate-y:translate 属性声明只存在于 :hover 那条原子类 规则里,焦点态不经过 :hover,必须直接写 translate - 受光层要画在内容之下,因此 TaskCard 的内容容器补了 relative(同为定位元素时按 DOM 顺序绘制,伪元素在前) 验证:vue-tsc -b 与 eslint src 干净,738 例全量单测通过;Playwright 读计算样式确认 动画时序(下拉 @30ms opacity .909、抽屉 translateX 214.77px、遮罩 opacity .419), 并用垂直亮度剖面确认卡片上亮下暗(内部亮度 33 → 20)。
This commit is contained in:
@@ -236,7 +236,7 @@ function onDueChange(value: string | null): void {
|
||||
|
||||
<div
|
||||
v-if="dueOpen"
|
||||
class="absolute top-full left-0 z-30 mt-1.5 w-56 rounded-lg border border-slate-200 bg-white p-2 shadow-pop shadow-pop-dark dark:border-zinc-700 dark:bg-zinc-900"
|
||||
class="absolute top-full left-0 z-30 mt-1.5 w-56 animate-pop-in rounded-lg border border-slate-200 bg-white p-2 shadow-pop shadow-pop-dark motion-reduce:animate-none dark:border-zinc-700 dark:bg-zinc-900"
|
||||
>
|
||||
<DatePicker
|
||||
:model-value="batchDue"
|
||||
|
||||
@@ -102,13 +102,13 @@ onBeforeUnmount(stopListening)
|
||||
只把注意力让给抽屉;模糊负责把背景「推远」,避免和面板内容抢焦点。
|
||||
-->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/25 backdrop-blur-sm dark:bg-black/40"
|
||||
class="absolute inset-0 animate-scrim-in bg-slate-900/25 backdrop-blur-sm motion-reduce:animate-none 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"
|
||||
class="absolute inset-y-0 right-0 flex w-full max-w-md animate-sheet-in flex-col bg-white shadow-panel shadow-panel-dark outline-none motion-reduce:animate-none dark:border-l dark:border-zinc-800 dark:bg-zinc-900"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
|
||||
@@ -86,7 +86,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div
|
||||
v-if="open"
|
||||
class="absolute top-full left-0 z-20 mt-1.5 w-56 rounded-lg border border-slate-200 bg-white p-1.5 shadow-pop shadow-pop-dark dark:border-zinc-700 dark:bg-zinc-900"
|
||||
class="absolute top-full left-0 z-20 mt-1.5 w-56 animate-pop-in rounded-lg border border-slate-200 bg-white p-1.5 shadow-pop shadow-pop-dark motion-reduce:animate-none dark:border-zinc-700 dark:bg-zinc-900"
|
||||
>
|
||||
<p v-if="tags.length === 0" class="px-2 py-3 text-center text-xs text-slate-400 dark:text-zinc-500">
|
||||
还没有任何标签
|
||||
|
||||
@@ -169,7 +169,7 @@ function onBlur(): void {
|
||||
id="tag-suggestions"
|
||||
role="listbox"
|
||||
aria-label="已有标签"
|
||||
class="absolute top-full left-0 z-20 mt-1 max-h-44 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white p-1 shadow-pop shadow-pop-dark dark:border-zinc-700 dark:bg-zinc-900"
|
||||
class="absolute top-full left-0 z-20 mt-1 max-h-44 w-full animate-pop-in overflow-y-auto rounded-lg border border-slate-200 bg-white p-1 shadow-pop shadow-pop-dark motion-reduce:animate-none dark:border-zinc-700 dark:bg-zinc-900"
|
||||
>
|
||||
<li
|
||||
v-for="(tag, index) in candidates"
|
||||
|
||||
@@ -98,7 +98,7 @@ const cardClass = computed(() => {
|
||||
}
|
||||
return isDone.value
|
||||
? 'border-slate-200 bg-white opacity-60 dark:border-zinc-800 dark:bg-zinc-900'
|
||||
: 'border-slate-200 bg-white hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
||||
: 'border-slate-200 bg-white hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700 dark:hover:bg-zinc-800/25'
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -130,7 +130,7 @@ const priorityTextClass = computed(() => {
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border shadow-card transition duration-150"
|
||||
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border shadow-card transition duration-200 ease-out"
|
||||
:class="cardClass"
|
||||
:data-task-id="task.id"
|
||||
:role="selectable ? 'checkbox' : undefined"
|
||||
@@ -143,7 +143,7 @@ const priorityTextClass = computed(() => {
|
||||
>
|
||||
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
|
||||
|
||||
<div class="py-2 pr-2.5 pl-3.5">
|
||||
<div class="relative py-2 pr-2.5 pl-3.5">
|
||||
<div class="flex items-start gap-2">
|
||||
<!--
|
||||
多选态下用一枚纯装饰的勾选框:真正的可交互元素是整张卡片,
|
||||
|
||||
+148
-20
@@ -15,6 +15,45 @@
|
||||
--shadow-card-hover: 0 6px 16px rgb(15 23 42 / 0.09), 0 1px 3px rgb(15 23 42 / 0.05);
|
||||
--shadow-panel: 0 16px 40px rgb(15 23 42 / 0.12), 0 2px 8px rgb(15 23 42 / 0.06);
|
||||
--shadow-pop: 0 8px 24px rgb(15 23 42 / 0.12), 0 1px 3px rgb(15 23 42 / 0.06);
|
||||
|
||||
/*
|
||||
* 出场动效。原则是「短、只做一次、不挡路」:
|
||||
* 下拉从锚点上方几像素落下,抽屉从右侧滑入,遮罩单独淡入。
|
||||
* 时长压在 160~260ms —— 再长就会觉得界面迟钝。
|
||||
* 组件上一律配 motion-reduce:animate-none,尊重系统的减少动效设置。
|
||||
*/
|
||||
--animate-pop-in: pop-in 160ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
--animate-sheet-in: sheet-in 260ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
--animate-scrim-in: scrim-in 200ms ease-out;
|
||||
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sheet-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scrim-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -50,11 +89,19 @@
|
||||
color: var(--color-zinc-100);
|
||||
}
|
||||
|
||||
/* 统一的键盘焦点样式,鼠标点击不显示 */
|
||||
/*
|
||||
* 统一的键盘焦点样式,鼠标点击不显示。
|
||||
* 不写 border-radius:outline 会自动跟随元素自身的圆角,早先那行
|
||||
* `border-radius: 4px` 反而会让没有圆角类的元素在聚焦瞬间跳一下。
|
||||
*/
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-sky-500);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 深色底上纯 sky-500 有点扎眼,掺一点透明度把它压柔 */
|
||||
html.dark :focus-visible {
|
||||
outline-color: color-mix(in oklab, var(--color-sky-400) 78%, transparent);
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -83,15 +130,19 @@
|
||||
*/
|
||||
|
||||
/*
|
||||
* 深色模式下的卡片阴影
|
||||
* 深色模式下的卡片高度(悬浮)
|
||||
*
|
||||
* 上面 @theme 里的阴影是「低透明度黑色」,落在近黑的 zinc-950 背景上几乎没有
|
||||
* 对比度 —— 浅色模式下鼠标悬停能明显看到卡片被抬起来,深色模式下就基本没反应。
|
||||
* 浅色靠「黑色投影落在浅灰上」表达高度;深色里同一个投影落在 zinc-950 上
|
||||
* 没有对比度。只把参数调大也不行 —— 之前那版同时做了「底色跳两档 + 四边边框
|
||||
* 一起提亮 + 外圈白光晕」,五个变化一起发生,结果是整张卡片均匀发亮,
|
||||
* 看着像换了张更浅的卡,而不是同一张卡被抬起来,光晕还让边缘发糊。
|
||||
*
|
||||
* 深色不能照搬同一套参数,改用三件事来还原同样的「悬浮感」:
|
||||
* 1. 外扩暗晕:把卡片四周压得更暗,卡片本身因此显得更亮更高;
|
||||
* 2. 顶部内高光:模拟顶面受光,这是深色下唯一能画出「厚度」的手段;
|
||||
* 3. 悬停时同步提亮卡片底色(见 TaskCard.vue 的 dark:hover:bg-*)。
|
||||
* 现在只保留能表达「厚度」的三件事,并且让它们方向一致 —— 上亮、下暗:
|
||||
* 1. 受光渐变(见下面的 ::before):顶部白、底部黑,眼睛据此读出顶面朝上;
|
||||
* 2. 锐利的接触阴影:卡片边缘与列背景之间一道很窄的暗隙,先把边界压实;
|
||||
* 3. 远处柔化投影:透明度低、扩散大,只提供高度暗示,不参与轮廓。
|
||||
* 底色只提一档、边框只提一档、位移仍在 2px —— 让「抬起」由明暗关系表达,
|
||||
* 而不是靠整体变亮。
|
||||
*
|
||||
* 注意两点:
|
||||
* 1. Tailwind v4 会把 @theme 里的阴影值内联进 --tw-shadow,改 CSS 变量对
|
||||
@@ -104,45 +155,122 @@
|
||||
html.dark .shadow-card {
|
||||
box-shadow:
|
||||
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
|
||||
var(--tw-ring-shadow), 0 1px 2px rgb(0 0 0 / 0.55), inset 0 1px 0 rgb(255 255 255 / 0.03);
|
||||
var(--tw-ring-shadow), 0 1px 2px rgb(0 0 0 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.035);
|
||||
}
|
||||
|
||||
/*
|
||||
* 悬停:暗晕压四周 + 一圈极淡的白光晕 + 顶部内高光。
|
||||
* 白光晕是深色下唯一能真正"画出来"的扩散效果,黑色投影只负责把近处压暗。
|
||||
* 受光层:顶部白 → 中部透明 → 底部黑。
|
||||
*
|
||||
* 用 background-image 做不了过渡,所以挂一层伪元素淡入。它必须画在卡片内容
|
||||
* 之下,因此 TaskCard.vue 里的内容容器带了 relative(同为定位元素时按 DOM
|
||||
* 顺序绘制,伪元素在前,内容在后)。
|
||||
*/
|
||||
html.dark .hover\:shadow-card-hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(255 255 255 / 0.07) 0%,
|
||||
rgb(255 255 255 / 0.02) 32%,
|
||||
rgb(0 0 0 / 0.14) 100%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
|
||||
html.dark .hover\:shadow-card-hover:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
html.dark .hover\:shadow-card-hover:hover {
|
||||
box-shadow:
|
||||
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
|
||||
var(--tw-ring-shadow), 0 14px 30px -10px rgb(0 0 0 / 0.9), 0 0 22px -4px rgb(255 255 255 / 0.07),
|
||||
0 0 0 1px rgb(255 255 255 / 0.08), inset 0 1px 0 rgb(255 255 255 / 0.12);
|
||||
var(--tw-ring-shadow), 0 1px 1px rgb(0 0 0 / 0.45), 0 2px 4px -1px rgb(0 0 0 / 0.45),
|
||||
0 14px 30px -16px rgb(0 0 0 / 0.9), inset 0 1px 0 rgb(255 255 255 / 0.09);
|
||||
}
|
||||
|
||||
/* 深色下 1px 的位移在暗底上几乎看不出来,补偿到 2px */
|
||||
/* 深色下 1px 的位移在暗底上几乎看不出来,补偿到 2px。
|
||||
* 位置必须在 :active 之前 —— 两者特异性相同,靠顺序决定胜负。 */
|
||||
html.dark .hover\:-translate-y-px:hover {
|
||||
--tw-translate-y: -2px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 按下:落回纸面 —— 位移归零、阴影收紧回静止态、受光减弱。
|
||||
* 必须写在 :hover 与上面的位移补偿之后:三者特异性相同,靠顺序决定胜负。
|
||||
*/
|
||||
html.dark .hover\:shadow-card-hover:active {
|
||||
--tw-translate-y: 0px;
|
||||
box-shadow:
|
||||
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
|
||||
var(--tw-ring-shadow), 0 1px 2px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.05);
|
||||
}
|
||||
|
||||
html.dark .hover\:shadow-card-hover:active::before {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 浅色同步补一个「按下」,两侧手感一致(浅色不需要受光层) */
|
||||
.hover\:shadow-card-hover:active {
|
||||
--tw-translate-y: 0px;
|
||||
box-shadow:
|
||||
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
|
||||
var(--tw-ring-shadow), 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08);
|
||||
}
|
||||
|
||||
/*
|
||||
* 键盘焦点:卡片被聚焦时和悬停一样抬起、受光,焦点环只是最后一道提示。
|
||||
* 只有一圈描边的话,观感是「外面套了个方框」,和这套用明暗表达高度的语言不搭。
|
||||
*
|
||||
* 这里直接写 translate 属性而不是改 --tw-translate-y:后者只有在 :hover 规则
|
||||
* 声明过 `translate: var(...)` 时才起作用,焦点状态并不经过 :hover。
|
||||
*/
|
||||
.hover\:shadow-card-hover:focus-visible {
|
||||
translate: 0 -1px;
|
||||
box-shadow:
|
||||
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
|
||||
var(--tw-ring-shadow), 0 6px 16px rgb(15 23 42 / 0.09), 0 1px 3px rgb(15 23 42 / 0.05);
|
||||
}
|
||||
|
||||
html.dark .hover\:shadow-card-hover:focus-visible {
|
||||
translate: 0 -2px;
|
||||
box-shadow:
|
||||
var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
|
||||
var(--tw-ring-shadow), 0 1px 1px rgb(0 0 0 / 0.45), 0 2px 4px -1px rgb(0 0 0 / 0.45),
|
||||
0 14px 30px -16px rgb(0 0 0 / 0.9), inset 0 1px 0 rgb(255 255 255 / 0.09);
|
||||
}
|
||||
|
||||
html.dark .hover\:shadow-card-hover:focus-visible::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
* 深色模式下的弹层阴影(下拉 / 抽屉)
|
||||
*
|
||||
* 和卡片同样的道理:shadow-pop / shadow-panel 在深色下是「黑色投在黑底上」。
|
||||
* 但这里不能用 `html.dark .shadow-pop` 统一覆盖 —— ToastHost / PwaUpdatePrompt
|
||||
* 在深色下仍然是浅色底(dark:bg-zinc-100),它们需要保留原来的黑色投影,
|
||||
* 否则亮块上套一圈白光晕会很脏。
|
||||
* 给亮块套深色阴影会把它们压得发灰。
|
||||
*
|
||||
* 所以改成显式标记:深色底面的弹层多挂一个 shadow-pop-dark / shadow-panel-dark,
|
||||
* 只有挂了标记的元素才会被下面的规则接管。类名带 html.dark 前缀 + 未分层,
|
||||
* 特异性 (0,2,1) 高于原子类的 (0,1,0),不受 CSS 生成顺序影响。
|
||||
*
|
||||
* 和卡片用同一套语言:接触阴影压实边界,远处柔化投影提供高度,不加白色光晕
|
||||
* —— 光晕在深色里会雾化边缘,和卡片那版踩过同一个坑。
|
||||
*/
|
||||
html.dark .shadow-pop-dark {
|
||||
box-shadow:
|
||||
0 12px 28px -8px rgb(0 0 0 / 0.85), 0 2px 8px -2px rgb(0 0 0 / 0.6),
|
||||
0 0 22px -6px rgb(255 255 255 / 0.06);
|
||||
0 1px 1px rgb(0 0 0 / 0.45), 0 4px 10px -3px rgb(0 0 0 / 0.5),
|
||||
0 16px 34px -16px rgb(0 0 0 / 0.9);
|
||||
}
|
||||
|
||||
/* 抽屉从右侧滑入,主投影朝左;其余方向补一圈淡光晕把边缘从暗背景里勾出来 */
|
||||
/* 抽屉从右侧滑入,主投影朝左,三层由近到远 */
|
||||
html.dark .shadow-panel-dark {
|
||||
box-shadow:
|
||||
-16px 0 48px -12px rgb(0 0 0 / 0.9), 0 0 30px -10px rgb(255 255 255 / 0.05);
|
||||
-1px 0 1px rgb(0 0 0 / 0.4), -10px 0 30px -12px rgb(0 0 0 / 0.7),
|
||||
-34px 0 70px -30px rgb(0 0 0 / 0.85);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user