fix: animate quick entry drawer

This commit is contained in:
openclaw 2026-08-01 19:30:54 +08:00
parent d7bd667ea2
commit 9b70e4fec4
3 changed files with 114 additions and 4 deletions

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { currencies, currencyMinorUnits, type CurrencyCode, type EntryType } from "@cents/domain";
import { BookOpen, CalendarDays, Check, ChevronDown, Delete as BackspaceIcon, X } from "@lucide/vue";
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { entryTypes, type Category } from "../data/categories";
import CategoryCoordinateSelector from "./CategoryCoordinateSelector.vue";
import CurrencyPicker from "./CurrencyPicker.vue";
@ -40,6 +40,14 @@ const selectorOpen = ref(false);
const noteEditing = ref(false);
const ledgerPickerOpen = ref(false);
const currencyPickerOpen = ref(false);
const drawerDragging = ref(false);
const drawerDragOffset = ref(0);
const drawerClosing = ref(false);
const drawerEntered = ref(false);
const drawerPointerId = ref<number | null>(null);
let drawerDragStartY = 0;
let drawerCloseTimer: number | null = null;
let drawerEnterFrame: number | null = null;
const selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]);
const typeOption = computed(() => entryTypes.find((item) => item.id === entryType.value)!);
@ -194,6 +202,61 @@ function finishNoteEditing(event: KeyboardEvent) {
(event.currentTarget as HTMLInputElement).blur();
}
function startDrawerDrag(event: PointerEvent) {
if (drawerClosing.value || (event.pointerType === "mouse" && event.button !== 0)) return;
drawerPointerId.value = event.pointerId;
drawerDragStartY = event.clientY;
drawerDragOffset.value = 0;
drawerDragging.value = true;
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
}
function moveDrawerDrag(event: PointerEvent) {
if (drawerPointerId.value !== event.pointerId) return;
drawerDragOffset.value = Math.max(0, Math.min(window.innerHeight * 0.82, event.clientY - drawerDragStartY));
}
function finishDrawerDrag(event: PointerEvent) {
if (drawerPointerId.value !== event.pointerId) return;
drawerPointerId.value = null;
if (drawerDragOffset.value >= 56) {
requestClose();
return;
}
drawerDragging.value = false;
drawerDragOffset.value = 0;
}
function cancelDrawerDrag(event: PointerEvent) {
if (drawerPointerId.value !== event.pointerId) return;
drawerPointerId.value = null;
drawerDragging.value = false;
drawerDragOffset.value = 0;
}
function requestClose() {
if (drawerClosing.value) return;
drawerClosing.value = true;
drawerDragging.value = false;
drawerDragOffset.value = 0;
drawerCloseTimer = window.setTimeout(() => {
drawerCloseTimer = null;
emit("close");
}, 220);
}
onBeforeUnmount(() => {
if (drawerCloseTimer !== null) window.clearTimeout(drawerCloseTimer);
if (drawerEnterFrame !== null) window.cancelAnimationFrame(drawerEnterFrame);
});
onMounted(() => {
drawerEnterFrame = window.requestAnimationFrame(() => {
drawerEnterFrame = null;
drawerEntered.value = true;
});
});
function openLedgerPicker() {
confirmCategory();
noteEditing.value = false;
@ -234,10 +297,24 @@ function saveEntry() {
</script>
<template>
<div class="drawer-scrim" :class="{ 'category-mode': selectorOpen }" @click="emit('close')"></div>
<section class="quick-drawer" aria-label="快速记账">
<div class="drawer-scrim" :class="{ 'category-mode': selectorOpen, 'drawer-entered': drawerEntered, 'drawer-closing': drawerClosing }" @click="requestClose"></div>
<section
class="quick-drawer"
:class="{ 'drawer-entered': drawerEntered, 'drawer-dragging': drawerDragging, 'drawer-closing': drawerClosing }"
:style="drawerDragging ? { transform: `translateY(${drawerDragOffset}px)` } : undefined"
aria-label="快速记账"
>
<div v-if="selectorOpen" class="category-focus-scrim"></div>
<div class="drawer-handle"></div>
<div
class="drawer-handle"
role="button"
aria-label="向下拖动关闭快速记账"
title="向下拖动关闭"
@pointerdown="startDrawerDrag"
@pointermove="moveDrawerDrag"
@pointerup="finishDrawerDrag"
@pointercancel="cancelDrawerDrag"
></div>
<header class="drawer-header" :class="{ 'long-amount': amountNeedsFullRow }">
<div class="amount-block">

View File

@ -791,8 +791,12 @@ input:focus-visible {
z-index: 10;
background: rgba(18, 35, 32, 0.36);
backdrop-filter: blur(1px);
transition: opacity 220ms ease;
}
.drawer-scrim:not(.drawer-entered) { opacity: 0; }
.drawer-scrim.drawer-closing { opacity: 0; }
.drawer-scrim.category-mode {
background: rgba(18, 35, 32, 0.42);
backdrop-filter: blur(7px);
@ -809,16 +813,25 @@ input:focus-visible {
padding-bottom: env(safe-area-inset-bottom);
background: #ffffff;
box-shadow: 0 -16px 36px rgba(22, 45, 40, 0.22);
transition: transform 220ms ease;
}
.quick-drawer.drawer-dragging { transition: none; }
.quick-drawer:not(.drawer-entered):not(.drawer-closing) { transform: translateY(100%); }
.quick-drawer.drawer-closing { transform: translateY(100%); }
.drawer-handle {
width: 38px;
height: 4px;
margin: 8px auto 0;
border-radius: 2px;
background: #b9cac5;
cursor: grab;
touch-action: none;
}
.drawer-handle:active { cursor: grabbing; }
.drawer-header {
min-height: 76px;
display: flex;

View File

@ -65,4 +65,24 @@ test.describe("快速记账:新增账目", () => {
await expect(drawer.getByRole("button", { name: "完成记账" })).toBeVisible();
await expect(note).toHaveValue("回车备注");
});
test("06-点击遮罩或向下拖动把手都带动画关闭抽屉", async ({ page }) => {
await mockApi(page);
await (page);
await page.getByRole("button", { name: "记一笔" }).click();
const drawer = page.getByRole("region", { name: "快速记账" });
await expect(drawer).toHaveClass(/drawer-entered/);
const handle = page.getByRole("button", { name: "向下拖动关闭快速记账" });
const handleBox = await handle.boundingBox();
expect(handleBox).not.toBeNull();
const startY = handleBox!.y + handleBox!.height / 2;
await handle.dispatchEvent("pointerdown", { pointerId: 1, pointerType: "touch", isPrimary: true, button: 0, clientY: startY });
await handle.dispatchEvent("pointermove", { pointerId: 1, pointerType: "touch", isPrimary: true, buttons: 1, clientY: startY + 80 });
await handle.dispatchEvent("pointerup", { pointerId: 1, pointerType: "touch", isPrimary: true, button: 0, clientY: startY + 80 });
await expect(drawer).not.toBeVisible();
await page.getByRole("button", { name: "记一笔" }).click();
await page.locator(".drawer-scrim").click({ position: { x: 8, y: 8 } });
await expect(page.getByRole("region", { name: "快速记账" })).not.toBeVisible();
});
});