fix: animate quick entry drawer
This commit is contained in:
parent
d7bd667ea2
commit
9b70e4fec4
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { currencies, currencyMinorUnits, type CurrencyCode, type EntryType } from "@cents/domain";
|
import { currencies, currencyMinorUnits, type CurrencyCode, type EntryType } from "@cents/domain";
|
||||||
import { BookOpen, CalendarDays, Check, ChevronDown, Delete as BackspaceIcon, X } from "@lucide/vue";
|
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 { entryTypes, type Category } from "../data/categories";
|
||||||
import CategoryCoordinateSelector from "./CategoryCoordinateSelector.vue";
|
import CategoryCoordinateSelector from "./CategoryCoordinateSelector.vue";
|
||||||
import CurrencyPicker from "./CurrencyPicker.vue";
|
import CurrencyPicker from "./CurrencyPicker.vue";
|
||||||
|
|
@ -40,6 +40,14 @@ const selectorOpen = ref(false);
|
||||||
const noteEditing = ref(false);
|
const noteEditing = ref(false);
|
||||||
const ledgerPickerOpen = ref(false);
|
const ledgerPickerOpen = ref(false);
|
||||||
const currencyPickerOpen = 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 selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]);
|
||||||
|
|
||||||
const typeOption = computed(() => entryTypes.find((item) => item.id === entryType.value)!);
|
const typeOption = computed(() => entryTypes.find((item) => item.id === entryType.value)!);
|
||||||
|
|
@ -194,6 +202,61 @@ function finishNoteEditing(event: KeyboardEvent) {
|
||||||
(event.currentTarget as HTMLInputElement).blur();
|
(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() {
|
function openLedgerPicker() {
|
||||||
confirmCategory();
|
confirmCategory();
|
||||||
noteEditing.value = false;
|
noteEditing.value = false;
|
||||||
|
|
@ -234,10 +297,24 @@ function saveEntry() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="drawer-scrim" :class="{ 'category-mode': selectorOpen }" @click="emit('close')"></div>
|
<div class="drawer-scrim" :class="{ 'category-mode': selectorOpen, 'drawer-entered': drawerEntered, 'drawer-closing': drawerClosing }" @click="requestClose"></div>
|
||||||
<section class="quick-drawer" aria-label="快速记账">
|
<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 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 }">
|
<header class="drawer-header" :class="{ 'long-amount': amountNeedsFullRow }">
|
||||||
<div class="amount-block">
|
<div class="amount-block">
|
||||||
|
|
|
||||||
|
|
@ -791,8 +791,12 @@ input:focus-visible {
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: rgba(18, 35, 32, 0.36);
|
background: rgba(18, 35, 32, 0.36);
|
||||||
backdrop-filter: blur(1px);
|
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 {
|
.drawer-scrim.category-mode {
|
||||||
background: rgba(18, 35, 32, 0.42);
|
background: rgba(18, 35, 32, 0.42);
|
||||||
backdrop-filter: blur(7px);
|
backdrop-filter: blur(7px);
|
||||||
|
|
@ -809,16 +813,25 @@ input:focus-visible {
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
box-shadow: 0 -16px 36px rgba(22, 45, 40, 0.22);
|
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 {
|
.drawer-handle {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
margin: 8px auto 0;
|
margin: 8px auto 0;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: #b9cac5;
|
background: #b9cac5;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer-handle:active { cursor: grabbing; }
|
||||||
|
|
||||||
.drawer-header {
|
.drawer-header {
|
||||||
min-height: 76px;
|
min-height: 76px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -65,4 +65,24 @@ test.describe("快速记账:新增账目", () => {
|
||||||
await expect(drawer.getByRole("button", { name: "完成记账" })).toBeVisible();
|
await expect(drawer.getByRole("button", { name: "完成记账" })).toBeVisible();
|
||||||
await expect(note).toHaveValue("回车备注");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue