refactor(ui): replace modal dialogs with toast/sheet components
Remove DialogVant/NotifyVant and add Toast and Sheet components with useToast, useReveal, and usePageTransition composables. Rework Navigation, RegisterView, and styles across both apps; rebuild dist assets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
148b99c4a6
commit
c05352d6fd
@@ -0,0 +1,325 @@
|
||||
<!--
|
||||
One toast: a glass card in the same family as the bottom nav — a square
|
||||
icon tile on the start side, title + message, and a thin countdown line.
|
||||
|
||||
The countdown is a CSS animation; its `animationend` is what closes the
|
||||
toast, so pausing it (finger held on the card) pauses the timer too, with
|
||||
no JS clock to keep in sync. Swipe sideways or up to throw it away.
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
class="toast"
|
||||
:class="[`toast--${item.type}`, { 'is-held': held, 'is-dragging': dragging }]"
|
||||
:style="dragStyle"
|
||||
role="status"
|
||||
@pointerdown="onDown"
|
||||
@pointermove="onMove"
|
||||
@pointerup="onUp"
|
||||
@pointercancel="onUp"
|
||||
>
|
||||
<span class="toast__tile" aria-hidden="true">
|
||||
<span class="toast__ripple" />
|
||||
<Icon :icon="item.icon" size="22" />
|
||||
</span>
|
||||
|
||||
<div class="toast__body">
|
||||
<div v-if="item.title" class="toast__title">{{ item.title }}</div>
|
||||
<div class="toast__message">{{ item.message }}</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="item.duration > 0"
|
||||
class="toast__progress"
|
||||
:style="{ animationDuration: `${item.duration}ms` }"
|
||||
@animationend="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import type { ToastItem } from '../../composables/useToast'
|
||||
|
||||
defineProps<{ item: ToastItem }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
// A drag past either threshold dismisses; anything shorter springs back.
|
||||
const SWIPE_X = 80
|
||||
const SWIPE_UP = 36
|
||||
|
||||
const held = ref(false)
|
||||
const dragging = ref(false)
|
||||
const dx = ref(0)
|
||||
const dy = ref(0)
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
const flung = ref(false)
|
||||
let downAt = 0
|
||||
|
||||
const dragStyle = computed(() => {
|
||||
if (flung.value) {
|
||||
return {
|
||||
transform: `translate(${dx.value}px, ${dy.value}px)`,
|
||||
opacity: '0',
|
||||
transition: 'transform 0.22s ease-in, opacity 0.22s ease-in',
|
||||
}
|
||||
}
|
||||
if (!dragging.value) return undefined
|
||||
// Upward drags follow the finger; downward ones are damped so the card
|
||||
// only stretches a little, like pulling on something anchored at the top.
|
||||
const y = dy.value < 0 ? dy.value : dy.value * 0.25
|
||||
const fade = 1 - Math.min(Math.abs(dx.value) / 220, 0.6)
|
||||
return {
|
||||
transform: `translate(${dx.value}px, ${y}px) rotate(${dx.value * 0.03}deg)`,
|
||||
opacity: String(fade),
|
||||
transition: 'none',
|
||||
}
|
||||
})
|
||||
|
||||
function onDown(e: PointerEvent) {
|
||||
held.value = true
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
downAt = Date.now()
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!held.value) return
|
||||
dx.value = e.clientX - startX
|
||||
dy.value = e.clientY - startY
|
||||
if (!dragging.value && Math.hypot(dx.value, dy.value) > 6) dragging.value = true
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
held.value = false
|
||||
if (!dragging.value) {
|
||||
// A quick tap closes it; a long hold was only pausing it to read.
|
||||
if (Date.now() - downAt < 250) emit('close')
|
||||
return
|
||||
}
|
||||
if (Math.abs(dx.value) > SWIPE_X || dy.value < -SWIPE_UP) {
|
||||
flung.value = true
|
||||
dx.value = Math.abs(dx.value) > SWIPE_X ? Math.sign(dx.value) * window.innerWidth : dx.value
|
||||
dy.value = dy.value < -SWIPE_UP ? -160 : dy.value
|
||||
dragging.value = false
|
||||
setTimeout(() => emit('close'), 200)
|
||||
return
|
||||
}
|
||||
dragging.value = false
|
||||
dx.value = 0
|
||||
dy.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.toast {
|
||||
--accent: @wine;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 10px 12px 14px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
transition: transform 0.45s cubic-bezier(0.2, 1.4, 0.4, 1), opacity 0.3s ease;
|
||||
|
||||
// Same glass recipe as the bottom nav, so the two read as one system.
|
||||
background:
|
||||
radial-gradient(120% 160% at 0% 0%, fade(@canvas, 60%) 0%, fade(@canvas, 0%) 55%),
|
||||
radial-gradient(80% 140% at 100% 100%, fade(@ink, 4%) 0%, fade(@ink, 0%) 60%),
|
||||
linear-gradient(160deg, fade(@canvas, 72%) 0%, fade(@canvas, 50%) 100%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(200%) brightness(1.05);
|
||||
backdrop-filter: blur(24px) saturate(200%) brightness(1.05);
|
||||
box-shadow:
|
||||
inset 0 1px 1px fade(@canvas, 90%),
|
||||
inset 0 0 0 0.5px fade(@canvas, 40%),
|
||||
0 18px 40px -16px rgba(20, 22, 26, 0.35),
|
||||
0 1px 3px rgba(20, 22, 26, 0.06);
|
||||
|
||||
// Edge light, masked to a 1px ring.
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
fade(@canvas, 100%) 0%,
|
||||
fade(@canvas, 30%) 40%,
|
||||
fade(@canvas, 10%) 70%,
|
||||
fade(@canvas, 70%) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.is-held:not(.is-dragging) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
--accent: @green;
|
||||
}
|
||||
.toast--error {
|
||||
--accent: @red;
|
||||
}
|
||||
.toast--warning {
|
||||
--accent: @secondary;
|
||||
}
|
||||
.toast--info {
|
||||
--accent: @wine;
|
||||
}
|
||||
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.toast {
|
||||
background: fade(@canvas, 96%);
|
||||
}
|
||||
}
|
||||
|
||||
// Square rounded tile — the same shape as the nav tiles — tinted by type.
|
||||
.toast__tile {
|
||||
position: relative;
|
||||
flex: 0 0 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
|
||||
:deep(svg) {
|
||||
position: relative;
|
||||
animation: toast-icon-pop 0.6s 0.12s cubic-bezier(0.2, 1.6, 0.4, 1) both;
|
||||
}
|
||||
}
|
||||
|
||||
// A ring that pulses out of the tile once as the toast lands.
|
||||
.toast__ripple {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
border: 2px solid var(--accent);
|
||||
opacity: 0;
|
||||
animation: toast-ripple 0.9s 0.2s ease-out both;
|
||||
}
|
||||
|
||||
.toast--error .toast__tile :deep(svg) {
|
||||
animation:
|
||||
toast-icon-pop 0.6s 0.12s cubic-bezier(0.2, 1.6, 0.4, 1) both,
|
||||
toast-shake 0.45s 0.55s ease-in-out;
|
||||
}
|
||||
|
||||
.toast__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.toast__title {
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.toast__message {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
color: @inkSoft;
|
||||
}
|
||||
|
||||
// Countdown line along the bottom, draining toward the start side.
|
||||
.toast__progress {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
left: 14px;
|
||||
bottom: 5px;
|
||||
height: 2.5px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0.55;
|
||||
transform-origin: right center;
|
||||
animation-name: toast-drain;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.toast.is-held .toast__progress {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes toast-drain {
|
||||
from {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-icon-pop {
|
||||
0% {
|
||||
transform: scale(0) rotate(-30deg);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-ripple {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
20%,
|
||||
60% {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
40%,
|
||||
80% {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast__tile :deep(svg),
|
||||
.toast__ripple {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<!--
|
||||
Renders the shared toast queue (see composables/useToast.ts). Mounted once
|
||||
in App.vue. Toasts drop in from the top with a spring, the stack slides to
|
||||
make room, and a closing toast shrinks up and fades out.
|
||||
-->
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-host">
|
||||
<TransitionGroup name="toast">
|
||||
<ToastCard v-for="item in toasts" :key="item.id" :item="item" @close="dismiss(item.id)" />
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToastCard from './ToastCard.vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: calc(12px + env(safe-area-inset-top, 0px)) 14px 0;
|
||||
direction: rtl;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
max-width: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter: drop from above, slightly small and blurred, then overshoot into place.
|
||||
.toast-host .toast.toast-enter-active {
|
||||
transition:
|
||||
transform 0.6s cubic-bezier(0.2, 1.35, 0.4, 1),
|
||||
opacity 0.35s ease,
|
||||
filter 0.4s ease;
|
||||
}
|
||||
.toast-host .toast.toast-enter-from {
|
||||
transform: translateY(-120%) scale(0.88);
|
||||
opacity: 0;
|
||||
filter: blur(6px);
|
||||
}
|
||||
|
||||
// Leave: taken out of flow so the stack can glide up behind it.
|
||||
.toast-host .toast.toast-leave-active {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
margin: 0 auto;
|
||||
transition:
|
||||
transform 0.32s cubic-bezier(0.5, 0, 0.75, 0),
|
||||
opacity 0.28s ease,
|
||||
filter 0.3s ease;
|
||||
}
|
||||
.toast-host .toast.toast-leave-to {
|
||||
transform: translateY(-40%) scale(0.9);
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.toast-host .toast.toast-move {
|
||||
transition: transform 0.45s cubic-bezier(0.2, 1.2, 0.4, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-host .toast.toast-enter-active,
|
||||
.toast-host .toast.toast-leave-active,
|
||||
.toast-host .toast.toast-move {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.toast-host .toast.toast-enter-from,
|
||||
.toast-host .toast.toast-leave-to {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user