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>
488 lines
13 KiB
Vue
488 lines
13 KiB
Vue
<!--
|
||
Floating "island" action sheet: the app's confirm/notice modal.
|
||
|
||
Replaces DialogVant / DialogVantOneButton (centered Vant dialogs whose
|
||
content was poured into img/title/button slots). The sheet rises from the
|
||
bottom but stays inset from the screen edges with rounded corners all
|
||
round, the same island shape as the ant-Drawer `island-sheet` in
|
||
style.less, so the two kinds of sheet read as one family.
|
||
|
||
Structured props instead of slots for the buttons, so every call site gets
|
||
the same button styling without passing its own markup and color classes:
|
||
|
||
<IslandSheet
|
||
v-model:show="open"
|
||
art="logout"
|
||
title="..."
|
||
description="..."
|
||
confirm-text="بله"
|
||
cancel-text="بی خیال"
|
||
@confirm="doIt"
|
||
/>
|
||
|
||
- `art` picks an illustration from SheetArt, and with it the sheet's tone
|
||
(header wash + primary button); `tone` overrides it. An `#art` slot can
|
||
replace the illustration entirely.
|
||
- No `cancel-text` → a single-button sheet.
|
||
- `closeOnClickOverlay` (default true) also governs swipe-down-to-dismiss;
|
||
with it off the sheet can only be left through its own buttons, and a
|
||
swipe just rubber-bands. No close (×) button: cancel, the backdrop and
|
||
the swipe already cover it.
|
||
- Performance: every decorative animation is held paused until the slide-in
|
||
has finished (`is-ready`, set on Vant's `opened`), so the slide has the
|
||
frame budget to itself; the content arrives as one piece, not staggered,
|
||
and nothing uses backdrop-filter (blurring the page behind a fading
|
||
overlay recomputes every frame and was the main source of jank on phones).
|
||
- Cancel closes the sheet itself and emits `cancel`; confirm only emits
|
||
`confirm` — the caller decides whether it closes (e.g. a redirect).
|
||
-->
|
||
<template>
|
||
<Popup
|
||
:show="show"
|
||
position="bottom"
|
||
teleport="body"
|
||
transition="iSheet-slide"
|
||
transition-appear
|
||
class="iSheet"
|
||
overlay-class="iSheet__overlay"
|
||
:close-on-click-overlay="closeOnClickOverlay"
|
||
close-on-popstate
|
||
@update:show="emit('update:show', $event)"
|
||
@opened="ready = true"
|
||
@closed="onClosed"
|
||
@click="onGapClick"
|
||
>
|
||
<div
|
||
class="iSheet__card"
|
||
:class="[`iSheet--${resolvedTone}`, { 'is-dragging': dragging, 'is-ready': ready }]"
|
||
:style="cardStyle"
|
||
dir="rtl"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
@touchstart.passive="onTouchStart"
|
||
@touchmove.passive="onTouchMove"
|
||
@touchend="onTouchEnd"
|
||
@touchcancel="onTouchEnd"
|
||
>
|
||
<div class="iSheet__stage">
|
||
<span class="iSheet__handle" />
|
||
<span class="iSheet__ring iSheet__ring--outer" />
|
||
<span class="iSheet__ring iSheet__ring--inner" />
|
||
<span v-for="n in 4" :key="n" class="iSheet__spark" :class="`iSheet__spark--${n}`" />
|
||
<div class="iSheet__art">
|
||
<slot name="art">
|
||
<SheetArt v-if="art" :name="art" />
|
||
</slot>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 class="iSheet__title">{{ title }}</h3>
|
||
<p v-if="description" class="iSheet__desc">{{ description }}</p>
|
||
|
||
<div class="iSheet__actions">
|
||
<button type="button" class="iSheet__btn iSheet__btn--primary" @click="emit('confirm')">
|
||
{{ confirmText }}
|
||
</button>
|
||
<button v-if="cancelText" type="button" class="iSheet__btn iSheet__btn--ghost" @click="cancel">
|
||
{{ cancelText }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { Popup } from 'vant'
|
||
import SheetArt, { type SheetArtName } from './SheetArt.vue'
|
||
|
||
type Tone = 'ink' | 'wine' | 'navy' | 'gold' | 'green'
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
show: boolean
|
||
title: string
|
||
description?: string
|
||
confirmText: string
|
||
cancelText?: string
|
||
art?: SheetArtName
|
||
tone?: Tone
|
||
closeOnClickOverlay?: boolean
|
||
}>(),
|
||
{ closeOnClickOverlay: true },
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:show', value: boolean): void
|
||
(e: 'confirm'): void
|
||
(e: 'cancel'): void
|
||
}>()
|
||
|
||
const artTone: Record<SheetArtName, Tone> = {
|
||
logout: 'wine',
|
||
login: 'navy',
|
||
order: 'gold',
|
||
update: 'navy',
|
||
success: 'green',
|
||
}
|
||
|
||
const resolvedTone = computed<Tone>(() => props.tone ?? (props.art ? artTone[props.art] : 'ink'))
|
||
|
||
const ready = ref(false)
|
||
|
||
function close() {
|
||
emit('update:show', false)
|
||
}
|
||
|
||
function onClosed() {
|
||
ready.value = false
|
||
dragY.value = 0
|
||
}
|
||
|
||
function cancel() {
|
||
emit('cancel')
|
||
close()
|
||
}
|
||
|
||
// The popup is a full-width transparent strip with the card inset inside it;
|
||
// a tap in that inset gap is visually a tap on the backdrop, so treat it as one.
|
||
function onGapClick(e: MouseEvent) {
|
||
if (props.closeOnClickOverlay && e.target === e.currentTarget) close()
|
||
}
|
||
|
||
// Swipe down to dismiss. Only the card moves while dragging; on release it
|
||
// either springs back or the popup's own leave transition carries it off
|
||
// from wherever the finger left it (reset once fully closed).
|
||
const DISMISS_DISTANCE = 90
|
||
const DISMISS_VELOCITY = 0.5 // px per ms
|
||
|
||
const dragY = ref(0)
|
||
const dragging = ref(false)
|
||
let startY = 0
|
||
let startTime = 0
|
||
|
||
const cardStyle = computed(() =>
|
||
dragY.value ? { transform: `translate3d(0, ${dragY.value}px, 0)` } : undefined,
|
||
)
|
||
|
||
function onTouchStart(e: TouchEvent) {
|
||
if ((e.target as HTMLElement).closest('button')) return
|
||
startY = e.touches[0].clientY
|
||
startTime = Date.now()
|
||
dragging.value = true
|
||
}
|
||
|
||
function onTouchMove(e: TouchEvent) {
|
||
if (!dragging.value) return
|
||
const dy = e.touches[0].clientY - startY
|
||
// Downward follows the finger when dismissable, otherwise rubber-bands;
|
||
// upward always rubber-bands.
|
||
dragY.value = dy > 0 ? (props.closeOnClickOverlay ? dy : dy * 0.2) : dy * 0.12
|
||
}
|
||
|
||
function onTouchEnd() {
|
||
if (!dragging.value) return
|
||
dragging.value = false
|
||
const velocity = dragY.value / Math.max(1, Date.now() - startTime)
|
||
if (props.closeOnClickOverlay && (dragY.value > DISMISS_DISTANCE || (dragY.value > 30 && velocity > DISMISS_VELOCITY))) {
|
||
close()
|
||
} else {
|
||
dragY.value = 0
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="less">
|
||
// Global, not scoped: the popup is teleported to <body>.
|
||
|
||
.iSheet__overlay {
|
||
background: rgba(20, 22, 26, 0.48);
|
||
}
|
||
|
||
.iSheet.van-popup {
|
||
background: transparent;
|
||
overflow: visible;
|
||
padding: 0 12px calc(12px + env(safe-area-inset-bottom, 0px));
|
||
}
|
||
|
||
// The iOS sheet curve: fast out of the gate, long soft landing. Transform
|
||
// only, on its own compositor layer, so it never waits on paint.
|
||
.iSheet-slide-enter-active,
|
||
.iSheet-slide-leave-active {
|
||
will-change: transform;
|
||
}
|
||
|
||
.iSheet-slide-enter-active {
|
||
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
|
||
}
|
||
|
||
.iSheet-slide-leave-active {
|
||
transition: transform 0.24s cubic-bezier(0.4, 0, 1, 1);
|
||
}
|
||
|
||
// Hold every decorative animation (the stage's rings and sparks, and the
|
||
// illustration's own) until the sheet has landed.
|
||
.iSheet__card:not(.is-ready) *,
|
||
.iSheet__card:not(.is-ready) *::after {
|
||
animation-play-state: paused !important;
|
||
}
|
||
|
||
.iSheet-slide-enter-from,
|
||
.iSheet-slide-leave-to {
|
||
transform: translate3d(0, 100%, 0);
|
||
}
|
||
|
||
// Tones: one accent per job. --t is the accent, --t-rgb its channels for
|
||
// translucent uses, --t-hi the lighter top of the button gradient, --t-wash
|
||
// and --t-wash2 the header's two background stops.
|
||
.iSheet--ink {
|
||
--t: @ink;
|
||
--t-rgb: 20, 22, 26;
|
||
--t-hi: @inkSoft;
|
||
--t-wash: #F7F7F8;
|
||
--t-wash2: #ECEDEF;
|
||
}
|
||
|
||
.iSheet--wine {
|
||
--t: @wine;
|
||
--t-rgb: 122, 27, 51;
|
||
--t-hi: @wineSoft;
|
||
--t-wash: #FDF6F7;
|
||
--t-wash2: #F7E2E7;
|
||
}
|
||
|
||
.iSheet--navy {
|
||
--t: @primary;
|
||
--t-rgb: 28, 45, 86;
|
||
--t-hi: #2D4682;
|
||
--t-wash: #F5F7FC;
|
||
--t-wash2: #E2E8F4;
|
||
}
|
||
|
||
.iSheet--gold {
|
||
--t: @secondary;
|
||
--t-rgb: 141, 106, 70;
|
||
--t-hi: #C39350;
|
||
--t-wash: #FDF9F2;
|
||
--t-wash2: #F5E7CF;
|
||
}
|
||
|
||
.iSheet--green {
|
||
--t: #15803d;
|
||
--t-rgb: 22, 163, 74;
|
||
--t-hi: #22a052;
|
||
--t-wash: #F4FBF6;
|
||
--t-wash2: #D9F1E1;
|
||
}
|
||
|
||
.iSheet__card {
|
||
position: relative;
|
||
max-width: 440px;
|
||
margin: 0 auto;
|
||
padding: 8px 8px 12px;
|
||
border-radius: 30px;
|
||
background: @canvas;
|
||
text-align: center;
|
||
box-shadow:
|
||
0 30px 60px -22px rgba(20, 22, 26, 0.5),
|
||
0 0 0 1px rgba(20, 22, 26, 0.04);
|
||
transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
|
||
touch-action: none;
|
||
|
||
&.is-dragging {
|
||
transition: none;
|
||
}
|
||
}
|
||
|
||
// Header: a tinted window with a faint dot grid that fades out towards the
|
||
// edges, two soft rings framing the illustration, and a few twinkles.
|
||
.iSheet__stage {
|
||
position: relative;
|
||
height: 172px;
|
||
border-radius: 23px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: radial-gradient(130% 95% at 50% 0%, var(--t-wash2) 0%, var(--t-wash) 72%);
|
||
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
background-image: radial-gradient(rgba(var(--t-rgb), 0.13) 1px, transparent 1.3px);
|
||
background-size: 14px 14px;
|
||
-webkit-mask-image: radial-gradient(closest-side, #000 30%, transparent 100%);
|
||
mask-image: radial-gradient(closest-side, #000 30%, transparent 100%);
|
||
}
|
||
}
|
||
|
||
.iSheet__handle {
|
||
position: absolute;
|
||
top: 8px;
|
||
left: 50%;
|
||
width: 38px;
|
||
height: 4px;
|
||
margin-left: -19px;
|
||
border-radius: 4px;
|
||
background: rgba(var(--t-rgb), 0.18);
|
||
z-index: 2;
|
||
}
|
||
|
||
.iSheet__ring {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
border-radius: 50%;
|
||
pointer-events: none;
|
||
|
||
&--outer {
|
||
width: 196px;
|
||
height: 196px;
|
||
margin: -98px 0 0 -98px;
|
||
border: 1.5px dashed rgba(var(--t-rgb), 0.16);
|
||
}
|
||
|
||
&--inner {
|
||
width: 132px;
|
||
height: 132px;
|
||
margin: -66px 0 0 -66px;
|
||
background: radial-gradient(circle, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.55) 62%, rgba(255, 255, 255, 0) 72%);
|
||
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0.35);
|
||
animation: iSheet-breathe 3.2s ease-in-out infinite;
|
||
}
|
||
}
|
||
|
||
.iSheet__spark {
|
||
position: absolute;
|
||
width: 10px;
|
||
height: 10px;
|
||
background: rgba(var(--t-rgb), 0.45);
|
||
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
|
||
animation: iSheet-twinkle 2.6s ease-in-out infinite;
|
||
|
||
&--1 { top: 30px; right: 24%; }
|
||
&--2 { top: 62px; left: 19%; width: 7px; height: 7px; animation-delay: 0.7s; }
|
||
&--3 { bottom: 32px; right: 17%; width: 8px; height: 8px; animation-delay: 1.3s; }
|
||
&--4 { bottom: 26px; left: 27%; width: 6px; height: 6px; animation-delay: 1.9s; }
|
||
}
|
||
|
||
.iSheet__art {
|
||
position: relative;
|
||
z-index: 1;
|
||
width: 128px;
|
||
height: 128px;
|
||
|
||
> img,
|
||
> svg,
|
||
> canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
}
|
||
|
||
// Every element sets its own font-size: style.less's `*` rule pins each one
|
||
// to 14px, so nothing inherits.
|
||
.iSheet__title {
|
||
margin: 20px 16px 0;
|
||
font-size: 16.5px;
|
||
font-weight: 800;
|
||
line-height: 1.75;
|
||
color: @ink;
|
||
}
|
||
|
||
.iSheet__desc {
|
||
margin: 6px 20px 0;
|
||
font-size: 13px;
|
||
font-weight: 400;
|
||
line-height: 2;
|
||
color: @inkMuted;
|
||
}
|
||
|
||
.iSheet__actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin: 22px 4px 0;
|
||
}
|
||
|
||
// Scoped under the card on purpose: Tailwind's preflight resets
|
||
// `[type='button']` to a transparent, image-less background with the same
|
||
// specificity as a single class, and App.vue (which uses this sheet) is
|
||
// imported before main.ts loads tailwind.css — so a bare `.iSheet__btn--primary`
|
||
// loses on source order and the button renders white-on-white.
|
||
.iSheet__card .iSheet__btn {
|
||
position: relative;
|
||
height: 52px;
|
||
border: 0;
|
||
border-radius: 18px;
|
||
font-size: 14.5px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
-webkit-tap-highlight-color: transparent;
|
||
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||
|
||
&:active {
|
||
transform: scale(0.97);
|
||
}
|
||
|
||
&--primary {
|
||
color: #fff;
|
||
background: linear-gradient(180deg, var(--t-hi) 0%, var(--t) 100%);
|
||
box-shadow:
|
||
0 12px 24px -12px rgba(var(--t-rgb), 0.75),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||
|
||
// A single sheen across the button once the sheet has settled.
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 40%;
|
||
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.28), transparent);
|
||
transform: translateX(-130%) skewX(-18deg);
|
||
animation: iSheet-sheen 0.9s ease-in-out 0.15s both;
|
||
}
|
||
}
|
||
|
||
&--ghost {
|
||
color: @inkMuted;
|
||
background: transparent;
|
||
|
||
&:active {
|
||
background: @hairlineSoft;
|
||
}
|
||
}
|
||
}
|
||
|
||
@keyframes iSheet-breathe {
|
||
50% { transform: scale(1.06); }
|
||
}
|
||
|
||
@keyframes iSheet-twinkle {
|
||
0%, 100% { transform: scale(0.4) rotate(0deg); opacity: 0.2; }
|
||
50% { transform: scale(1) rotate(45deg); opacity: 1; }
|
||
}
|
||
|
||
@keyframes iSheet-sheen {
|
||
from { transform: translateX(-130%) skewX(-18deg); }
|
||
to { transform: translateX(300%) skewX(-18deg); }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.iSheet-slide-enter-active,
|
||
.iSheet-slide-leave-active {
|
||
transition-duration: 0.01s;
|
||
}
|
||
|
||
.iSheet__card *,
|
||
.iSheet__card *::after {
|
||
animation: none !important;
|
||
}
|
||
}
|
||
</style>
|