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