Files
hedena_gold/apps/user-app/src/components/ui/ToastCard.vue
T
s.esmaeiliandClaude Sonnet 5 c05352d6fd 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>
2026-10-04 13:57:25 +03:30

326 lines
7.5 KiB
Vue

<!--
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>