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:
co-authored by
Claude Sonnet 5
parent
148b99c4a6
commit
c05352d6fd
@@ -1,142 +0,0 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/components/DialogVant.vue.
|
||||
EXISTING ISSUE, ported as-is (not fixed): this component doesn't declare a
|
||||
`show`/`modelValue` prop, and the internal `watch(() => props.modelValue, ...)`
|
||||
references a prop that was never declared (always undefined, so that watcher
|
||||
effectively never fires — its `showNotify`/timeout auto-hide logic is dead
|
||||
code). Actual visibility works anyway, because <van-dialog> is this
|
||||
component's sole root element and Vue's automatic attribute fallthrough
|
||||
passes the parent's `v-model:show` straight through to it. Preserved exactly.
|
||||
|
||||
Visual pass: redesigned to the app's own card language instead of Vant's
|
||||
stock dialog chrome — rounded @canvas card, the illustration slot framed in
|
||||
a soft circular badge (@wineWash) rather than floating loose, and the
|
||||
button row rebuilt as two pill buttons (primary filled @ink, secondary
|
||||
ghost) instead of Vant's bare text-grid. Every call site only ever fills
|
||||
the img/title/button slots with content (never Vant's own button classes),
|
||||
so this is a pure chrome swap: no call site needs to change, and slot
|
||||
content (including per-call-site text color utility classes already baked
|
||||
into that content) still renders as-is inside the new layout.
|
||||
-->
|
||||
<template>
|
||||
<van-dialog class="mDialog">
|
||||
<div class="mDialog__body">
|
||||
<div class="mDialog__art">
|
||||
<slot name="img">{{ text2 }}</slot>
|
||||
</div>
|
||||
<div class="mDialog__title">
|
||||
<slot name="title">{{ text1 }}</slot>
|
||||
</div>
|
||||
<div class="mDialog__actions">
|
||||
<slot name="button">{{ text3 }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</van-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Dialog as VanDialog } from 'vant'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
timeOut: number
|
||||
text1?: string
|
||||
text2?: string
|
||||
text3?: string
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.van-dialog__confirm,
|
||||
.van-dialog__cancel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// Vant's own dialog chrome (white slab, hard corners, drop shadow) is
|
||||
// replaced wholesale — the shape and elevation now match the rest of the
|
||||
// app's cards (HomeView's hero, Dashboard's rows) rather than the library
|
||||
// default.
|
||||
.mDialog.van-dialog {
|
||||
width: min(320px, calc(100vw - 48px));
|
||||
border-radius: 26px;
|
||||
background: @canvas;
|
||||
box-shadow: 0 30px 60px -24px rgba(20, 22, 26, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mDialog__body {
|
||||
padding: 26px 22px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// The illustration sits inside a soft circular badge in the page's wine
|
||||
// wash, sized to a consistent footprint regardless of the source SVG's
|
||||
// native dimensions — mirrors DashboardView's icon-badge treatment.
|
||||
.mDialog__art {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: @wineWash;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.mDialog__art img,
|
||||
.mDialog__art svg,
|
||||
.mDialog__art canvas {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: contain;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.mDialog__title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1.9;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
// Two pill buttons instead of Vant's bordered text-grid. Each call site
|
||||
// passes two <div>s with a click handler; the first (the affirmative action)
|
||||
// is promoted to the filled primary pill by DOM order, the second becomes
|
||||
// the ghost/secondary pill — no call-site markup changes needed.
|
||||
.mDialog__actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
margin-top: 22px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mDialog__actions > * {
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.mDialog__actions > *:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.mDialog__actions > *:first-child {
|
||||
color: #fff !important;
|
||||
background: @ink;
|
||||
}
|
||||
|
||||
.mDialog__actions > *:last-child {
|
||||
color: @inkFaint !important;
|
||||
background: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<!--
|
||||
Ported from legacy henza_pwa/src/components/DialogVantOneButton.vue — the
|
||||
single-action variant of DialogVant (one full-width button instead of a
|
||||
two-column grid). Used by App.vue's "new version available" dialog.
|
||||
|
||||
Same structure as DialogVant.vue: `show` is not a declared prop; the parent's
|
||||
v-model:show falls through onto <van-dialog>, which is this component's sole
|
||||
root element. Legacy's internal watch on an undeclared `modelValue` prop was
|
||||
dead code and is not carried over.
|
||||
|
||||
Visual pass: same card chrome as DialogVant.vue's redesign (rounded
|
||||
@canvas card, wine-wash art badge, filled @ink pill button), kept in sync
|
||||
so the two dialog variants read as one family. The art badge is sized
|
||||
larger here than DialogVant's since this component's sole caller
|
||||
(App.vue's update dialog) fills it with a full animated Lottie player, not
|
||||
a static icon.
|
||||
-->
|
||||
<template>
|
||||
<van-dialog class="mDialog mDialog--single">
|
||||
<div class="mDialog__body">
|
||||
<div class="mDialog__art">
|
||||
<slot name="img">{{ text2 }}</slot>
|
||||
</div>
|
||||
<div class="mDialog__title">
|
||||
<slot name="title">{{ text1 }}</slot>
|
||||
</div>
|
||||
<div class="mDialog__actions">
|
||||
<slot name="button">{{ text3 }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</van-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Dialog as VanDialog } from 'vant'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
timeOut: number
|
||||
text1?: string
|
||||
text2?: string
|
||||
text3?: string
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.van-dialog__confirm,
|
||||
.van-dialog__cancel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mDialog.van-dialog {
|
||||
width: min(320px, calc(100vw - 48px));
|
||||
border-radius: 26px;
|
||||
background: @canvas;
|
||||
box-shadow: 0 30px 60px -24px rgba(20, 22, 26, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mDialog__body {
|
||||
padding: 26px 22px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mDialog--single .mDialog__art {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mDialog__title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1.9;
|
||||
color: @ink;
|
||||
}
|
||||
|
||||
.mDialog__actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mDialog__actions > * {
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
color: #fff !important;
|
||||
background: @ink;
|
||||
}
|
||||
|
||||
.mDialog__actions > *:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
</style>
|
||||
@@ -1,50 +0,0 @@
|
||||
<!-- Ported from legacy henza_pwa/src/components/NotifyVant.vue -->
|
||||
<template>
|
||||
<div v-if="modelValue" class="flex">
|
||||
<van-notify v-model:show="show" :style="color">
|
||||
<div class="w-full text-white my-2">
|
||||
<div class="w-full flex content-end">
|
||||
<Icon icon="vuesax-linear:info-circle" class="ml-[20px] text-white" size="25" />
|
||||
<div>
|
||||
<slot>{{ text }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-notify>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Notify as VanNotify } from 'vant'
|
||||
import Icon from './icon/Icon.vue'
|
||||
|
||||
const show = ref(true)
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
timeOut: number
|
||||
text?: string
|
||||
color?: string
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (props.timeOut !== -1) showNotify()
|
||||
},
|
||||
)
|
||||
const showNotify = () => {
|
||||
setTimeout(() => {
|
||||
emit('update:modelValue', false)
|
||||
}, props.timeOut)
|
||||
}
|
||||
</script>
|
||||
<!--
|
||||
Legacy also had a `.notify-enter-active`/`.notify-leave-active` style block
|
||||
referencing an undefined `notify-in` @keyframes and unused by any
|
||||
<transition name="notify"> in this component — genuinely dead, dropped.
|
||||
-->
|
||||
@@ -295,25 +295,15 @@
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<NotifyVant :timeOut="3000" v-model="notify" :color="'background-color: #14161A'">
|
||||
<div class="font-bold text-right mb-2">ثبت سفارش</div>
|
||||
<div>محصول با موفقیت به سبد خرید افزوده شد</div>
|
||||
</NotifyVant>
|
||||
|
||||
<DialogVant :timeOut="-1" v-model:show="loginNotify" :closeOnClickOverlay="true">
|
||||
<template v-slot:img>
|
||||
<img :src="loginDialogImage" alt="" class="opacity-80" />
|
||||
</template>
|
||||
<template v-slot:title>
|
||||
<div class="p-8 text-justify">
|
||||
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید.
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:button>
|
||||
<div class="text-center font-bold" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
|
||||
<div class="text-center" @click="loginNotify = false">بیخیال</div>
|
||||
</template>
|
||||
</DialogVant>
|
||||
<IslandSheet
|
||||
v-model:show="loginNotify"
|
||||
art="login"
|
||||
title="ابتدا وارد حساب کاربری شوید"
|
||||
description="برای دسترسی به تمامی امکانات نرم افزار منتزر، گزینه ورود/ثبت نام را لمس کنید و در صورت نداشتن حساب کاربری، ثبت نام نمایید."
|
||||
confirm-text="ورود/ثبت نام"
|
||||
cancel-text="بیخیال"
|
||||
@confirm="router.push({ name: 'login' })"
|
||||
/>
|
||||
</Spinner>
|
||||
</template>
|
||||
|
||||
@@ -323,15 +313,14 @@ import { useRouter } from 'vue-router'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import GallerySlider from './GallerySlider.vue'
|
||||
import Spinner from '../Spinner.vue'
|
||||
import NotifyVant from '../NotifyVant.vue'
|
||||
import DialogVant from '../DialogVant.vue'
|
||||
import { toast } from '../../composables/useToast'
|
||||
import IslandSheet from '../ui/IslandSheet.vue'
|
||||
import { useProduct, isVariantOrderable } from '../../composables/useProduct'
|
||||
import { useProductDetail } from '../../composables/useProductDetail'
|
||||
import { useFavoritesStore } from '../../stores/favoritesStore'
|
||||
import { useFiltersStore } from '../../stores/filtersStore'
|
||||
import { useCartStore, type CartItem } from '../../stores/cartStore'
|
||||
import { isUserLoggedIn } from '../../utils/checkAccess'
|
||||
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const emit = defineEmits<{ (e: 'closeDrawer'): void }>()
|
||||
@@ -350,7 +339,6 @@ const close = () => {
|
||||
if (router.currentRoute.value.name === 'singleProduct') router.back()
|
||||
}
|
||||
|
||||
const notify = ref(false)
|
||||
const loginNotify = ref(false)
|
||||
const imageIndex = ref(0)
|
||||
const bodyOpen = ref(false)
|
||||
@@ -499,7 +487,7 @@ const isOrderable = (v: Parameters<typeof isVariantOrderable>[0]) => isVariantOr
|
||||
const addToCart = () => {
|
||||
// The store refuses an un-orderable variant; only confirm when it accepted.
|
||||
if (!cart.addToCart(storeProduct.value)) return
|
||||
notify.value = true
|
||||
toast.success('محصول با موفقیت به سبد خرید افزوده شد', { title: 'ثبت سفارش' })
|
||||
}
|
||||
|
||||
const toggleFav = (e: Event) => {
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -1,19 +1,14 @@
|
||||
<!--
|
||||
Redesigned bottom nav — replaces the old GSAP SVG-notch bar (4 tabs: user,
|
||||
cart, search, home) with a flat pill-bar of 5 tabs (Home, Search, Likes,
|
||||
Notifications, Profile), each showing icon + label, and a single sliding
|
||||
indicator behind the active tab instead of a cut-out notch.
|
||||
|
||||
windowData/SVG path math from the old version is gone — the indicator is
|
||||
now pure CSS, positioned with a transform driven by the active tab's index,
|
||||
so it needs no measurement of the container width.
|
||||
Bottom nav — a compact rounded bar of square, icon-only tiles (Home,
|
||||
Search, Cart, Orders, Profile). A single square indicator slides behind the
|
||||
active tile; labels live in aria-label/title instead of under the icon.
|
||||
-->
|
||||
<template>
|
||||
<nav class="tabbar" v-show="nav.showNav">
|
||||
<div class="tabbar__inner" :style="{ '--count': nav.navBarRoutes.length }">
|
||||
<div class="tabbar__inner">
|
||||
<div
|
||||
class="tabbar__indicator"
|
||||
:style="{ transform: `translateX(${indicatorOffset}%)` }"
|
||||
:style="{ transform: `translateX(${indicatorOffset})` }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<router-link
|
||||
@@ -22,9 +17,10 @@
|
||||
:to="{ name: item.name }"
|
||||
class="tabbar__item"
|
||||
:class="{ 'is-active': nav.activeLink === item.index }"
|
||||
:aria-label="item.label"
|
||||
:title="item.label"
|
||||
>
|
||||
<Icon :icon="item.icon" size="21" />
|
||||
<span class="tabbar__label">{{ item.label }}</span>
|
||||
<Icon :icon="item.icon" size="22" />
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -37,11 +33,14 @@ import { useNavStore } from '../../stores/navStore'
|
||||
|
||||
const nav = useNavStore()
|
||||
|
||||
// Index is 1-based; the indicator sits in tab slot (activeLink - 1) out of N.
|
||||
// The whole app runs `direction: rtl` (see App.vue), which flips the grid's
|
||||
// visual column order but NOT a physical translateX — so slot 0 renders on
|
||||
// the right, and moving right needs a NEGATIVE offset here.
|
||||
const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
|
||||
// Index is 1-based; the indicator sits on tile (activeLink - 1). Tiles are a
|
||||
// fixed size, so one step is one tile plus the gap between tiles.
|
||||
// The whole app runs `direction: rtl` (see App.vue), which flips the flex
|
||||
// order but NOT a physical translateX — so tile 0 renders on the right, and
|
||||
// moving left needs a NEGATIVE offset here.
|
||||
const indicatorOffset = computed(
|
||||
() => `calc(${-(nav.activeLink - 1)} * (var(--tile) + var(--gap)))`,
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -51,69 +50,140 @@ const indicatorOffset = computed(() => -(nav.activeLink - 1) * 100)
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Compact bar that hugs its square tiles instead of spanning the screen.
|
||||
.tabbar__inner {
|
||||
--tile: 52px;
|
||||
--gap: 6px;
|
||||
--pad: 6px;
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--count), 1fr);
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
padding: var(--pad);
|
||||
border-radius: 22px;
|
||||
background: @canvas;
|
||||
border: 1px solid @hairline;
|
||||
box-shadow: 0 10px 28px -12px rgba(20, 22, 26, 0.18);
|
||||
pointer-events: auto;
|
||||
|
||||
// Frosted glass: a thin, mostly clear pane that heavily blurs and
|
||||
// brightens whatever scrolls beneath it. A light spot sits top-left, a
|
||||
// faint wine tint pools bottom-right, and the drop shadow lifts the pane.
|
||||
background:
|
||||
radial-gradient(120% 140% at 0% 0%, fade(@canvas, 55%) 0%, fade(@canvas, 0%) 55%),
|
||||
radial-gradient(90% 120% at 100% 100%, fade(@wine, 7%) 0%, fade(@wine, 0%) 60%),
|
||||
linear-gradient(160deg, fade(@canvas, 42%) 0%, fade(@canvas, 18%) 100%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
|
||||
backdrop-filter: blur(26px) saturate(210%) brightness(1.06);
|
||||
border: 0;
|
||||
box-shadow:
|
||||
inset 0 1px 1px fade(@canvas, 90%),
|
||||
inset 0 -8px 16px -10px fade(@canvas, 60%),
|
||||
inset 0 0 0 0.5px fade(@canvas, 35%),
|
||||
0 18px 40px -14px rgba(20, 22, 26, 0.32),
|
||||
0 6px 14px -8px fade(@wine, 22%),
|
||||
0 1px 3px rgba(20, 22, 26, 0.06);
|
||||
|
||||
// Curved specular gloss across the top, like light on a bent pane.
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px 8% auto 8%;
|
||||
height: 55%;
|
||||
border-radius: 20px 20px 50% 50% / 20px 20px 100% 100%;
|
||||
background: linear-gradient(180deg, fade(@canvas, 60%) 0%, fade(@canvas, 0%) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Edge light: a 1px gradient rim, bright where the light hits (top-left)
|
||||
// and fading out on the far edges. Masked so only the border ring shows.
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
fade(@canvas, 100%) 0%,
|
||||
fade(@canvas, 35%) 35%,
|
||||
fade(@canvas, 10%) 65%,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Browsers without backdrop-filter get a mostly opaque fill so it stays legible.
|
||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||
.tabbar__inner {
|
||||
background: fade(@canvas, 94%);
|
||||
}
|
||||
}
|
||||
|
||||
// Paints above the tiles' own fill (z-index 1) but below their icons (z 2).
|
||||
.tabbar__indicator {
|
||||
position: absolute;
|
||||
inset: 6px 6px 6px auto;
|
||||
width: calc((100% - 12px) / var(--count));
|
||||
top: var(--pad);
|
||||
right: var(--pad);
|
||||
z-index: 1;
|
||||
width: var(--tile);
|
||||
height: var(--tile);
|
||||
border-radius: 16px;
|
||||
background: @wineWash;
|
||||
box-shadow: 0 4px 12px -6px rgba(20, 22, 26, 0.25);
|
||||
transition: transform 0.35s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.tabbar__item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 8px 4px;
|
||||
flex: 0 0 var(--tile);
|
||||
width: var(--tile);
|
||||
height: var(--tile);
|
||||
border-radius: 16px;
|
||||
color: @inkFaint;
|
||||
background: @hairlineSoft;
|
||||
color: @inkMuted;
|
||||
text-decoration: none;
|
||||
transition: color 0.25s ease, transform 0.25s cubic-bezier(0.65, 0, 0.35, 1.4);
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
|
||||
.tabbar__item :deep(svg) {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: transform 0.3s cubic-bezier(0.65, 0, 0.35, 1.4);
|
||||
}
|
||||
|
||||
.tabbar__item.is-active {
|
||||
color: @wine;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tabbar__item.is-active :deep(svg) {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.tabbar__item:active {
|
||||
transform: scale(0.94);
|
||||
// Scale the icon, not the tile: a transform on the tile would make it a
|
||||
// stacking context and drop the icon beneath the indicator.
|
||||
.tabbar__item:active :deep(svg) {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.tabbar__label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
opacity: 0.9;
|
||||
@media (max-width: 360px) {
|
||||
.tabbar__inner {
|
||||
--tile: 46px;
|
||||
--gap: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,6 +38,8 @@ const props = withDefaults(
|
||||
back?: boolean
|
||||
/** Route name to go to instead of history.back(). */
|
||||
backTo?: string
|
||||
/** Custom back handler; wins over backTo and history (e.g. a multi-step form stepping back). */
|
||||
backAction?: () => void
|
||||
}>(),
|
||||
{ back: true },
|
||||
)
|
||||
@@ -45,7 +47,8 @@ const props = withDefaults(
|
||||
const router = useRouter()
|
||||
|
||||
function onBack() {
|
||||
if (props.backTo) router.push({ name: props.backTo })
|
||||
if (props.backAction) props.backAction()
|
||||
else if (props.backTo) router.push({ name: props.backTo })
|
||||
else router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
<!--
|
||||
Inline, animated illustrations for IslandSheet — one per job the sheet does,
|
||||
so the picture says what the action is before the text does:
|
||||
|
||||
logout — a door swung open with an arrow walking out of it
|
||||
login — a phone showing a sign-in form, with a lock badge easing open
|
||||
order — a gold shopping bag that a coin keeps dropping into, ticked
|
||||
update — a rocket lifting off (a new version "launched")
|
||||
success — the uploaded documents, stamped with a green check
|
||||
|
||||
Inline SVG rather than image files so they can animate, pick up the
|
||||
sheet's tone, and cost no network request (the update dialog used to pull
|
||||
a Lottie file from a third-party host on every launch).
|
||||
|
||||
Gradient/clip ids go through useId(): several sheets can be mounted on one
|
||||
page, and a duplicate id would resolve to the first copy in the document —
|
||||
which, inside a hidden sheet, renders as nothing.
|
||||
|
||||
The one-off entrance animations (badge pop, check drawing) replay every
|
||||
time the sheet opens, because the sheet hides with display:none and CSS
|
||||
animations restart when an element is displayed again.
|
||||
-->
|
||||
<template>
|
||||
<svg class="sa" :class="`sa--${name}`" viewBox="0 0 120 120" fill="none" aria-hidden="true">
|
||||
<!-- logout -->
|
||||
<template v-if="name === 'logout'">
|
||||
<defs>
|
||||
<linearGradient :id="ids.a" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#A33C55" />
|
||||
<stop offset="1" stop-color="#5E1427" />
|
||||
</linearGradient>
|
||||
<radialGradient :id="ids.b" cx=".35" cy=".5" r=".75">
|
||||
<stop offset="0" stop-color="#FFF7E6" />
|
||||
<stop offset="1" stop-color="#F4C57A" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse class="sa-shadow" cx="62" cy="106" rx="36" ry="4" fill="#7A1B33" opacity=".12" />
|
||||
<rect x="48" y="18" width="40" height="84" rx="5" :fill="`url(#${ids.b})`" stroke="#7A1B33" stroke-width="3" />
|
||||
<g class="sa-door">
|
||||
<path d="M88 18 L88 102 L69 108 L69 12 Z" :fill="`url(#${ids.a})`" stroke="#5E1427" stroke-width="2" stroke-linejoin="round" />
|
||||
<circle cx="73.5" cy="61" r="2.6" fill="#F4C57A" />
|
||||
</g>
|
||||
<g class="sa-arrow" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#fff" stroke-width="12" />
|
||||
<path d="M60 60 H22 M33 48 L21 60 L33 72" stroke="#7A1B33" stroke-width="6" />
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<!-- login -->
|
||||
<template v-else-if="name === 'login'">
|
||||
<ellipse class="sa-shadow" cx="60" cy="110" rx="28" ry="4" fill="#1C2D56" opacity=".12" />
|
||||
<g class="sa-float">
|
||||
<rect x="34" y="10" width="52" height="92" rx="12" fill="#1C2D56" />
|
||||
<rect x="38" y="14" width="44" height="84" rx="9" fill="#fff" />
|
||||
<rect x="52" y="18.5" width="16" height="4" rx="2" fill="#1C2D56" opacity=".85" />
|
||||
<circle cx="60" cy="42" r="12" fill="#E3E8F4" />
|
||||
<circle cx="60" cy="39" r="4.6" fill="#1C2D56" />
|
||||
<path d="M51.5 51 a8.5 6.5 0 0 1 17 0 Z" fill="#1C2D56" />
|
||||
<rect x="44" y="62" width="32" height="6" rx="3" fill="#ECEDEF" />
|
||||
<rect class="sa-typing" x="44" y="62" width="22" height="6" rx="3" fill="#CACED8" />
|
||||
<rect x="44" y="74" width="32" height="10" rx="5" fill="#1C2D56" />
|
||||
</g>
|
||||
<g class="sa-pop">
|
||||
<circle cx="85" cy="86" r="15" fill="#D9A441" stroke="#fff" stroke-width="3" />
|
||||
<path class="sa-shackle" d="M80 85 v-3.4 a4 4 0 0 1 8 0 V85" stroke="#fff" stroke-width="2.4" stroke-linecap="round" />
|
||||
<rect x="77.5" y="84.5" width="15" height="10.5" rx="2.6" fill="#fff" />
|
||||
<circle cx="85" cy="89.8" r="1.7" fill="#D9A441" />
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<!-- order -->
|
||||
<template v-else-if="name === 'order'">
|
||||
<defs>
|
||||
<linearGradient :id="ids.a" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#EBC06A" />
|
||||
<stop offset="1" stop-color="#B5843B" />
|
||||
</linearGradient>
|
||||
<radialGradient :id="ids.b" cx=".38" cy=".35" r=".7">
|
||||
<stop offset="0" stop-color="#FFF1C7" />
|
||||
<stop offset="1" stop-color="#E2AC4A" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse class="sa-shadow" cx="60" cy="108" rx="32" ry="4" fill="#8D6A46" opacity=".16" />
|
||||
<path d="M46 57 V40 a14 14 0 0 1 28 0 V57" stroke="#8D6A46" stroke-width="4.5" stroke-linecap="round" />
|
||||
<g class="sa-coin">
|
||||
<circle cx="60" cy="31" r="10" :fill="`url(#${ids.b})`" stroke="#B5843B" stroke-width="2" />
|
||||
<circle cx="60" cy="31" r="5.5" stroke="#B5843B" stroke-width="1.5" opacity=".7" />
|
||||
</g>
|
||||
<path d="M30 48 H90 L85 100 a6 6 0 0 1 -6 5.5 H41 a6 6 0 0 1 -6 -5.5 Z" :fill="`url(#${ids.a})`" />
|
||||
<path d="M30 48 H90 L89.4 54.5 H30.6 Z" fill="#8D6A46" opacity=".28" />
|
||||
<path d="M37 60 L39.5 96" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".35" />
|
||||
<circle cx="46" cy="57" r="2.4" fill="#8D6A46" />
|
||||
<circle cx="74" cy="57" r="2.4" fill="#8D6A46" />
|
||||
<g class="sa-pop">
|
||||
<circle cx="60" cy="80" r="13" fill="#fff" />
|
||||
<path class="sa-draw" d="M54 80.5 l4.2 4.2 L66.5 76" stroke="#16a34a" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<!-- update -->
|
||||
<template v-else-if="name === 'update'">
|
||||
<defs>
|
||||
<linearGradient :id="ids.a" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#FFD27A" />
|
||||
<stop offset="1" stop-color="#F2793B" />
|
||||
</linearGradient>
|
||||
<clipPath :id="ids.b">
|
||||
<path d="M60 10 C76 22 80 44 76 72 H44 C40 44 44 22 60 10 Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g stroke="#CACED8" stroke-width="3" stroke-linecap="round">
|
||||
<path class="sa-trail" d="M28 28 v14" />
|
||||
<path class="sa-trail sa-trail--2" d="M94 40 v12" />
|
||||
<path class="sa-trail sa-trail--3" d="M22 64 v10" />
|
||||
<path class="sa-trail sa-trail--4" d="M99 70 v12" />
|
||||
</g>
|
||||
<g class="sa-float">
|
||||
<g class="sa-flame">
|
||||
<path d="M51 77 Q60 112 69 77 Z" :fill="`url(#${ids.a})`" />
|
||||
<path d="M55.5 77 Q60 96 64.5 77 Z" fill="#FFF1C9" />
|
||||
</g>
|
||||
<path d="M44 56 L31 76 Q31 80 35 79 L46 74 Z" fill="#D9A441" />
|
||||
<path d="M76 56 L89 76 Q89 80 85 79 L74 74 Z" fill="#D9A441" />
|
||||
<path d="M60 10 C76 22 80 44 76 72 H44 C40 44 44 22 60 10 Z" fill="#fff" />
|
||||
<g :clip-path="`url(#${ids.b})`">
|
||||
<rect x="30" y="0" width="60" height="31" fill="#1C2D56" />
|
||||
<rect x="30" y="64" width="60" height="10" fill="#E3E8F4" />
|
||||
</g>
|
||||
<path d="M60 10 C76 22 80 44 76 72 H44 C40 44 44 22 60 10 Z" stroke="#1C2D56" stroke-width="3" stroke-linejoin="round" />
|
||||
<circle cx="60" cy="46" r="7.5" fill="#DCE5F7" stroke="#1C2D56" stroke-width="3" />
|
||||
<circle cx="57.6" cy="43.6" r="2" fill="#fff" />
|
||||
<rect x="49" y="71" width="22" height="7" rx="2.5" fill="#1C2D56" />
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<!-- success -->
|
||||
<template v-else-if="name === 'success'">
|
||||
<ellipse class="sa-shadow" cx="58" cy="108" rx="32" ry="4" fill="#16a34a" opacity=".14" />
|
||||
<rect x="26" y="24" width="50" height="70" rx="8" fill="#D6F0DF" transform="rotate(-9 51 59)" />
|
||||
<rect x="34" y="16" width="50" height="72" rx="8" fill="#fff" stroke="#1f7a45" stroke-width="2.5" />
|
||||
<circle cx="46" cy="31" r="6" fill="#BFE8CD" />
|
||||
<rect x="56" y="27" width="20" height="4" rx="2" fill="#E1EFE6" />
|
||||
<rect x="56" y="34" width="13" height="4" rx="2" fill="#E1EFE6" />
|
||||
<rect x="42" y="48" width="34" height="4" rx="2" fill="#E1EFE6" />
|
||||
<rect x="42" y="56" width="27" height="4" rx="2" fill="#E1EFE6" />
|
||||
<rect x="42" y="64" width="31" height="4" rx="2" fill="#E1EFE6" />
|
||||
<circle class="sa-ring" cx="84" cy="85" r="17" fill="#16a34a" />
|
||||
<g class="sa-pop">
|
||||
<circle cx="84" cy="85" r="17" fill="#16a34a" stroke="#fff" stroke-width="3" />
|
||||
<path class="sa-draw" d="M76.5 85.5 l5 5 L92 80" stroke="#fff" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</g>
|
||||
<g class="sa-confetti">
|
||||
<rect x="18" y="30" width="6" height="6" rx="1.5" fill="#F4C57A" transform="rotate(20 21 33)" />
|
||||
<circle cx="98" cy="26" r="3.2" fill="#16a34a" />
|
||||
<rect x="100" y="52" width="7" height="3.5" rx="1.75" fill="#A33C55" transform="rotate(-30 103.5 53.75)" />
|
||||
<circle cx="20" cy="72" r="2.6" fill="#335CB1" />
|
||||
<rect x="88" y="8" width="3.5" height="7" rx="1.75" fill="#D9A441" transform="rotate(35 89.75 11.5)" />
|
||||
</g>
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
export type SheetArtName = 'logout' | 'login' | 'order' | 'update' | 'success'
|
||||
|
||||
defineProps<{ name: SheetArtName }>()
|
||||
|
||||
const uid = useId()
|
||||
const ids = { a: `${uid}-a`, b: `${uid}-b` }
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.sa {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// Everything that moves scales/rotates about its own box, not the SVG origin.
|
||||
.sa * {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
// Entrances (once per open)
|
||||
.sa-pop {
|
||||
animation: sa-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.25s both;
|
||||
}
|
||||
|
||||
.sa-draw {
|
||||
stroke-dasharray: 30;
|
||||
stroke-dashoffset: 30;
|
||||
animation: sa-draw 0.45s ease-out 0.6s forwards;
|
||||
}
|
||||
|
||||
// Loops
|
||||
.sa-shadow {
|
||||
animation: sa-shadow 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sa-float {
|
||||
animation: sa-float 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sa-door {
|
||||
transform-origin: right center;
|
||||
animation: sa-door 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sa-arrow {
|
||||
animation: sa-nudge 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sa-typing {
|
||||
transform-origin: right center; // the form is RTL — text grows from the right
|
||||
animation: sa-typing 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sa-shackle {
|
||||
animation: sa-shackle 2.6s ease-in-out 0.9s infinite;
|
||||
}
|
||||
|
||||
.sa-coin {
|
||||
animation: sa-coin 2.4s cubic-bezier(0.55, 0, 0.75, 0.4) 0.4s infinite backwards;
|
||||
}
|
||||
|
||||
.sa-flame {
|
||||
transform-origin: center top;
|
||||
animation: sa-flame 0.22s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.sa-trail {
|
||||
animation: sa-trail 1.1s linear infinite;
|
||||
|
||||
&--2 { animation-delay: 0.35s; }
|
||||
&--3 { animation-delay: 0.6s; }
|
||||
&--4 { animation-delay: 0.85s; }
|
||||
}
|
||||
|
||||
.sa-ring {
|
||||
animation: sa-ring 2s ease-out 0.8s infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sa-confetti > * {
|
||||
animation: sa-confetti 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0.7s; }
|
||||
&:nth-child(2) { animation-delay: 0.78s; }
|
||||
&:nth-child(3) { animation-delay: 0.86s; }
|
||||
&:nth-child(4) { animation-delay: 0.94s; }
|
||||
&:nth-child(5) { animation-delay: 1.02s; }
|
||||
}
|
||||
|
||||
@keyframes sa-pop {
|
||||
from { transform: scale(0); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes sa-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes sa-shadow {
|
||||
50% { transform: scaleX(0.82); opacity: 0.08; }
|
||||
}
|
||||
|
||||
@keyframes sa-float {
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes sa-door {
|
||||
0%, 100% { transform: scaleX(1); }
|
||||
50% { transform: scaleX(0.72); }
|
||||
}
|
||||
|
||||
@keyframes sa-nudge {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(-6px); }
|
||||
}
|
||||
|
||||
@keyframes sa-typing {
|
||||
0% { transform: scaleX(0.15); }
|
||||
60%, 100% { transform: scaleX(1); }
|
||||
}
|
||||
|
||||
@keyframes sa-shackle {
|
||||
0%, 55%, 100% { transform: translateY(0); }
|
||||
20%, 40% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
@keyframes sa-coin {
|
||||
0% { transform: translateY(-24px); opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
100% { transform: translateY(26px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes sa-flame {
|
||||
from { transform: scaleY(0.82) scaleX(0.94); }
|
||||
to { transform: scaleY(1.12) scaleX(1.04); }
|
||||
}
|
||||
|
||||
@keyframes sa-trail {
|
||||
from { transform: translateY(-8px); opacity: 0; }
|
||||
30% { opacity: 1; }
|
||||
to { transform: translateY(22px); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sa-ring {
|
||||
from { transform: scale(1); opacity: 0.35; }
|
||||
to { transform: scale(1.7); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sa-confetti {
|
||||
from { transform: scale(0) translateY(6px); opacity: 0; }
|
||||
to { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sa * {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.sa-draw {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user