Files
tehran_tala/apps/user-app-v2/src/components/ui/IslandSheet.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

488 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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>