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:
s.esmaeili
2026-10-04 13:57:25 +03:30
co-authored by Claude Sonnet 5
parent 148b99c4a6
commit c05352d6fd
205 changed files with 3362 additions and 1701 deletions
@@ -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>